Files
Veskslop-t/media-scrape/slopbot/client.ts
T

102 lines
3.3 KiB
TypeScript

/*
* 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<Client> {
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<void>((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<void> {
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<unknown>;
}
/**
* 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<number | null> {
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()];
}
};
}