zelin-bot / src /rest-poll.js
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 [];
}