/** * rest-poll.js — REST-only Discord client for HuggingFace Spaces * ================================================================ * When WebSocket connection to Discord gateway fails (common on HF), * this module provides a polling-based fallback that: * * 1. Polls for new messages in monitored channels every 2-3 seconds * 2. Processes them through zelinRespond() * 3. Sends responses via REST API * 4. Maintains a minimal cache of guild/channels/members * * REST API via discordapp.com works from HF (28ms latency). * WebSocket via gateway.discord.gg does NOT work from HF. */ import { REST, Routes, GuildMember, User, Message } from 'discord.js'; // ── Configuration ───────────────────────────────────────────────────────────── const API_BASE = 'https://discordapp.com/api/v10'; const POLL_INTERVAL_MS = 2500; // Poll every 2.5s const CHANNEL_BATCH_SIZE = 3; // Poll 3 channels per cycle const MAX_MESSAGES_PER_POLL = 10; // Check last 10 messages per channel const ZELIN_BOT_ID = '1477476406819557507'; // Channels to monitor (by name or ID) — prioritize test-bot and chat const PRIORITY_CHANNELS = [ '1491133019140919337', // 🧪︱test-bot '1243623374568292474', // 💬︱chat '1459976980404633673', // 💬︱chat-staff '1241168930354237463', // 🤖︱comandos ]; // ── State ───────────────────────────────────────────────────────────────────── let restClient = null; let botToken = ''; let guildId = ''; let isRunning = false; let lastSeenMessageIds = new Map(); // channelId → lastMessageId let channelCache = new Map(); // channelId → channel object let memberCache = new Map(); // userId → member data let guildData = null; // ── REST API helpers ────────────────────────────────────────────────────────── async function apiCall(method, path, body = null) { const opts = { method, headers: { 'Authorization': `Bot ${botToken}`, 'Content-Type': 'application/json', }, }; if (body) opts.body = JSON.stringify(body); const res = await fetch(`${API_BASE}${path}`, opts); // Rate limit handling if (res.status === 429) { const retryAfter = parseFloat(res.headers.get('Retry-After') || '2'); console.warn(`[RestPoll] Rate limited, waiting ${retryAfter}s`); await new Promise(r => setTimeout(r, retryAfter * 1000)); return apiCall(method, path, body); // Retry } if (!res.ok && res.status !== 204) { const text = await res.text().catch(() => ''); throw new Error(`API ${res.status}: ${text.slice(0, 200)}`); } if (res.status === 204) return null; return res.json(); } // ── Initialize caches ───────────────────────────────────────────────────────── async function initCaches() { try { // Get guild data guildData = await apiCall('GET', `/guilds/${guildId}`); console.log(`[RestPoll] Guild: ${guildData?.name}`); // Get all channels const channels = await apiCall('GET', `/guilds/${guildId}/channels`); if (Array.isArray(channels)) { for (const ch of channels) { channelCache.set(ch.id, ch); // Add to priority channels if not already there if (ch.name?.includes('test-bot') || ch.name?.includes('chat') || ch.name?.includes('comando')) { if (!PRIORITY_CHANNELS.includes(ch.id)) { PRIORITY_CHANNELS.push(ch.id); } } } console.log(`[RestPoll] Cached ${channelCache.size} channels`); } // Initialize last seen message IDs for (const chId of PRIORITY_CHANNELS) { try { const msgs = await apiCall('GET', `/channels/${chId}/messages?limit=1`); if (Array.isArray(msgs) && msgs.length > 0) { lastSeenMessageIds.set(chId, msgs[0].id); } } catch {} } console.log(`[RestPoll] Initialized ${lastSeenMessageIds.size} channel cursors`); } catch (err) { console.error('[RestPoll] Cache init error:', err.message); } } // ── Build a minimal Message-like object from REST API data ──────────────────── function buildMessageProxy(msgData) { // Create a proxy object that mimics Discord.js Message enough for zelinRespond() const author = { id: msgData.author.id, username: msgData.author.username, globalName: msgData.author.global_name, bot: msgData.author.bot || false, avatar: msgData.author.avatar, }; const channelId = msgData.channel_id; const channelObj = channelCache.get(channelId); // Minimal message object return { id: msgData.id, content: msgData.content || '', author, channelId, guildId: msgData.guild_id || guildId, guild: guildData ? { id: guildId, name: guildData.name } : null, member: msgData.member ? { displayName: msgData.member.nick || msgData.author.global_name || msgData.author.username, roles: msgData.member.roles || [], user: author, } : null, channel: { id: channelId, name: channelObj?.name || 'unknown', send: async (content) => { const result = await apiCall('POST', `/channels/${channelId}/messages`, typeof content === 'string' ? { content } : content ); return result; }, sendTyping: async () => { await apiCall('POST', `/channels/${channelId}/typing`).catch(() => {}); }, messages: { fetch: async (options) => { if (typeof options === 'string') { // Fetch single message by ID const msg = await apiCall('GET', `/channels/${channelId}/messages/${options}`); return msg ? buildMessageProxy(msg) : null; } const limit = options?.limit || 10; const msgs = await apiCall('GET', `/channels/${channelId}/messages?limit=${limit}`); if (!Array.isArray(msgs)) return new Map(); const map = new Map(); for (const m of msgs) map.set(m.id, buildMessageProxy(m)); return map; }, }, }, reference: msgData.message_reference || null, reply: async ({ content, allowedMentions }) => { const body = { content: typeof content === 'string' ? content : content?.content || '', message_reference: { message_id: msgData.id, channel_id: channelId, guild_id: guildId }, allowed_mentions: allowedMentions || { repliedUser: true }, }; const result = await apiCall('POST', `/channels/${channelId}/messages`, body); return result; }, react: async (emoji) => { await apiCall('PUT', `/channels/${channelId}/messages/${msgData.id}/reactions/${encodeURIComponent(emoji)}/@me`).catch(() => {}); }, webhookId: msgData.webhook_id || null, attachments: { size: (msgData.attachments || []).length }, embeds: msgData.embeds || [], createdAt: new Date(msgData.timestamp || Date.now()), _raw: msgData, // Keep raw data for debugging }; } // ── Poll for new messages ───────────────────────────────────────────────────── async function pollChannel(channelId) { try { const lastId = lastSeenMessageIds.get(channelId); let url = `/channels/${channelId}/messages?limit=${MAX_MESSAGES_PER_POLL}`; if (lastId) url += `&after=${lastId}`; const msgs = await apiCall('GET', url); if (!Array.isArray(msgs) || msgs.length === 0) return []; // Messages come newest-first, reverse to chronological const newMsgs = msgs.reverse(); // Update cursor to newest message lastSeenMessageIds.set(channelId, msgs[msgs.length - 1].id); return newMsgs; } catch (err) { // Channel might not exist or no access return []; } } // ── Main polling loop ───────────────────────────────────────────────────────── let onMessageCallback = null; let pollTimer = null; async function pollCycle() { if (!isRunning) return; try { // Poll each priority channel for (const chId of PRIORITY_CHANNELS) { if (!isRunning) break; const newMsgs = await pollChannel(chId); for (const msgData of newMsgs) { // Skip bot messages (including our own) if (msgData.author?.bot) continue; // Skip empty messages if (!msgData.content?.trim()) continue; // Build proxy message and call handler const proxy = buildMessageProxy(msgData); if (onMessageCallback) { // Run async, don't await — process all messages in parallel onMessageCallback(proxy).catch(err => { console.error(`[RestPoll] Message handler error:`, err.message?.slice(0, 100)); }); } } // Small delay between channels to avoid rate limits if (PRIORITY_CHANNELS.indexOf(chId) < PRIORITY_CHANNELS.length - 1) { await new Promise(r => setTimeout(r, 500)); } } } catch (err) { console.error('[RestPoll] Poll cycle error:', err.message?.slice(0, 100)); } // Schedule next poll if (isRunning) { pollTimer = setTimeout(pollCycle, POLL_INTERVAL_MS); } } // ── Presence update via REST ────────────────────────────────────────────────── async function updatePresence(status = 'online', activity = 'TomateSMP') { try { // Note: Bots can't set presence via REST API directly // But we can update it through the gateway if connected // For REST-only mode, presence will show as offline // This is a known limitation console.log(`[RestPoll] Presence: ${status} / ${activity} (limited in REST mode)`); } catch {} } // ── Public API ──────────────────────────────────────────────────────────────── export async function startRestPolling(token, gid, messageHandler) { botToken = token; guildId = gid; onMessageCallback = messageHandler; isRunning = true; console.log('[RestPoll] Starting REST polling mode...'); console.log('[RestPoll] API base:', API_BASE); // Test REST API access try { const me = await apiCall('GET', '/users/@me'); console.log(`[RestPoll] Authenticated as: ${me.username}#${me.discriminator} (${me.id})`); } catch (err) { console.error('[RestPoll] Authentication failed:', err.message); isRunning = false; return false; } // Initialize caches await initCaches(); // Start polling pollCycle(); console.log(`[RestPoll] ✅ Polling ${PRIORITY_CHANNELS.length} channels every ${POLL_INTERVAL_MS}ms`); return true; } export function stopRestPolling() { isRunning = false; if (pollTimer) clearTimeout(pollTimer); console.log('[RestPoll] Stopped'); } export function isRestPollingActive() { return isRunning; } export function addChannel(channelId) { if (!PRIORITY_CHANNELS.includes(channelId)) { PRIORITY_CHANNELS.push(channelId); console.log(`[RestPoll] Added channel: ${channelId}`); } } export function removeChannel(channelId) { const idx = PRIORITY_CHANNELS.indexOf(channelId); if (idx >= 0) { PRIORITY_CHANNELS.splice(idx, 1); console.log(`[RestPoll] Removed channel: ${channelId}`); } } export function getRestPollStats() { return { mode: 'rest-poll', isRunning, channels: PRIORITY_CHANNELS.length, cachedChannels: channelCache.size, lastMessageCursors: lastSeenMessageIds.size, pollIntervalMs: POLL_INTERVAL_MS, }; } // ── Send message to channel (utility for modules) ───────────────────────────── export async function sendToChannel(channelId, content) { return apiCall('POST', `/channels/${channelId}/messages`, typeof content === 'string' ? { content } : content ); } export async function readChannelMessages(channelId, limit = 10) { return apiCall('GET', `/channels/${channelId}/messages?limit=${limit}`); } // ── Get guild members (minimal, for tools) ──────────────────────────────────── export async function fetchMember(userId) { if (memberCache.has(userId)) return memberCache.get(userId); try { const member = await apiCall('GET', `/guilds/${guildId}/members/${userId}`); if (member) { memberCache.set(userId, member); return member; } } catch {} return null; } export async function fetchGuildMembers(limit = 100) { try { const members = await apiCall('GET', `/guilds/${guildId}/members?limit=${limit}`); if (Array.isArray(members)) { for (const m of members) { memberCache.set(m.user.id, m); } return members; } } catch {} return []; }