/* * Slopbot client lifecycle. Separate from the Vesktop core by design. * Setup and security: docs/slopbot.md. */ import { Client, Events, GatewayIntentBits } from "discord.js"; import type { SlopbotConfig } from "./config.js"; import type { SlopbotFetcher } from "./backend.js"; export interface SlopbotStatus { connected: boolean; user: string | null; guilds: string[]; } /** * Log in and return a ready client. The bot needs no message events, so it * declares only the Guilds intent; REST history does not use the gateway. */ export async function startSlopbot(config: SlopbotConfig): Promise { if (!config.enabled || !config.token) { throw new Error("slopbot is disabled or has no token"); } const client = new Client({ intents: [GatewayIntentBits.Guilds] }); // login() resolves before the READY dispatch, so wait for it: the guild cache // and client.user are only populated then. const ready = new Promise((resolve, reject) => { client.once(Events.ClientReady, () => resolve()); client.once(Events.Error, reject); }); await client.login(config.token); await ready; return client; } export async function stopSlopbot(client: Client | null): Promise { if (!client) return; await client.destroy(); } /** The Bot tab reads this. Safe with a null client. */ export function slopbotStatus(client: Client | null): SlopbotStatus { if (!client || !client.isReady()) { return { connected: false, user: null, guilds: [] }; } return { connected: true, user: client.user?.tag ?? null, guilds: [...client.guilds.cache.values()].map(guild => guild.name) }; } /** The REST surface the search count needs. discord.js `client.rest` satisfies it. */ export interface SlopbotRest { get(path: string, options?: { query?: URLSearchParams }): Promise; } /** * Discord search `total_results` for a channel or range. One request. * Null when the search fails (rate limit, permissions, or no index). */ export async function countMessages( rest: SlopbotRest, input: { channelId: string; guildId: string; afterId?: string | null; beforeId?: string | null } ): Promise { const query = new URLSearchParams({ channel_id: input.channelId, limit: "1" }); if (input.afterId) query.set("min_id", input.afterId); if (input.beforeId) query.set("max_id", input.beforeId); try { const data = (await rest.get(`/guilds/${input.guildId}/messages/search`, { query })) as { total_results?: number; }; return typeof data?.total_results === "number" ? data.total_results : null; } catch { return null; } } /** The ScrapeBackend fetcher over a live client. */ export function slopbotFetcher(client: Client): SlopbotFetcher { return { async fetchMessages(channelId, options) { const channel = await client.channels.fetch(channelId); if (!channel || !channel.isTextBased()) { throw new Error(`channel ${channelId} is not text based`); } const messages = await channel.messages.fetch({ limit: options.limit, ...(options.before ? { before: options.before } : {}) }); return [...messages.values()]; } }; }