Spaces:
Paused
Paused
Z User
v5.8.6: fix greeting spam on restart - 30min grace, channel verification, file fallback
9ef0e78 | /** | |
| * 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 []; | |
| } | |