all repos — stealth-developers @ 4ae4b372e0e80fda5fcece291d886c578b9e229d

feat: check across multiple channels for new forum posts
willow hai@wlo.moe
Wed, 08 Oct 2025 08:52:57 +0100
commit

4ae4b372e0e80fda5fcece291d886c578b9e229d

parent

f848a995c217b0c37ef4591eb802988a808c0e4d

4 files changed, 120 insertions(+), 86 deletions(-)

jump to
M src/config.tssrc/config.ts

@@ -45,6 +45,10 @@ const catChannelSchema = z.object({

channelId: z.string(), }); +const forumChannel = z.object({ + channelId: z.string(), + channelName: z.string(), +}); const forumWatcher = z.object({ enabled: z.boolean().default(false), interval: z

@@ -55,7 +59,9 @@ .default(60)

.describe("how often to check for new posts in seconds"), groupId: z.string(), groupName: z.string().transform((val) => val.replace(/\s+/g, "-")), + /** @deprecated */ channelId: z.string(), + channels: z.array(forumChannel).min(1), notificationChannelId: z.string(), });
M src/index.tssrc/index.ts

@@ -4,7 +4,7 @@ import { connectDatabase } from "./database/connection";

import registerEvents from "./handlers/events.ts"; import registerInteractions from "./handlers/interactions.ts"; -import cfg from "./config.ts"; +import config from "./config.ts"; import { watchForum } from "./utils/forumWatcher.ts"; import logger from "./utils/logging.ts";

@@ -24,7 +24,8 @@ await Promise.all([registerEvents(client), registerInteractions(client)]);

logger.info("events and interactions registered!"); console.log(); - const forumConfig = cfg.data.roblox?.forumWatcher; + const forumConfig = config.data.roblox?.forumWatcher; + const forumChannels = forumConfig?.channels ?? []; if (!forumConfig || !forumConfig.enabled) return; await watchForum(client); setInterval(

@@ -37,7 +38,7 @@ forumConfig.interval * 1000 || 60000,

); }); -await client.login(cfg.data.discord.token); +await client.login(config.data.discord.token); process.on("unhandledRejection", (error) => { logger.error("unhandled promise rejection:", error);
M src/utils/bans.tssrc/utils/bans.ts

@@ -65,7 +65,7 @@ ): Promise<ContainerBuilder> {

const bansContainer = new ContainerBuilder(); { const bansTitle = new TextDisplayBuilder().setContent( - `## Bans for ${user.displayName || user.name}`, + `**Bans for ${user.displayName || user.name}**`, ); const bans = await getBans(user.id);

@@ -79,9 +79,12 @@ }

const restriction: Restriction = ban.gameJoinRestriction; return new TextDisplayBuilder().setContent( - `**${ban.project}**\n> **Active since** <t:${Math.round( - new Date(restriction.startTime).getTime() / 1000, - )}:R>\n> **Display reason:** ${restriction.displayReason}\n> **Reason:** ${restriction.privateReason}\n`, + [ + `**${ban.project}**`, + `> **Active since** <t:${Math.round(new Date(restriction.startTime).getTime() / 1000)}:R>`, + `> **Display reason:** ${restriction.displayReason}`, + `> **Reason:** ${restriction.privateReason}`, + ].join("\n"), ); };
M src/utils/forumWatcher.tssrc/utils/forumWatcher.ts

@@ -32,12 +32,15 @@ if (new Date(post.createdAt).getTime() < startTime) isNew = false;

return isNew; } -async function fetchPosts(): Promise<Post[]> { +async function fetchPosts(channel: { + channelId: string; + channelName: string; +}): Promise<Post[]> { if (!forumConfig) throw new Error("the forum watcher is not enabled in the configuration."); const response = await fetch( - `https://groups.roblox.com/v1/groups/${forumConfig.groupId}/forums/${forumConfig.channelId}/posts`, + `https://groups.roblox.com/v1/groups/${forumConfig.groupId}/forums/${channel.channelId}/posts`, { headers: { Cookie: config.data.roblox?.cookie || "",

@@ -114,6 +117,7 @@ export async function watchForum(client: Client) {

if (!forumConfig || !forumConfig.enabled) return; const channelId = forumConfig.notificationChannelId; const channel = client.channels.cache.get(channelId) as GuildTextBasedChannel; + const forumChannels = forumConfig.channels; if (!channel || !channel.isTextBased()) { logger.error(

@@ -122,96 +126,116 @@ );

return; } - try { - const posts = await fetchPosts(); - const newPosts = posts.filter(isNewPost); + for (const forumChannel of forumChannels) { + try { + const posts = await fetchPosts(forumChannel); + const newPosts = posts.filter(isNewPost); - const flaggedWords = [ - "ban", - "appeal", - "hacker", - "exploit", - "cheater", - "unban", - "moderator", - "admin", - "mod", - "exploiters", - "exploits", - ]; + const flaggedWords = [ + "ban", + "appeal", + "hacker", + "exploit", + "cheater", + "unban", + "moderator", + "admin", + "mod", + "exploiters", + "exploits", + ]; - for (const post of newPosts) { - if (!post.firstComment || !post.firstComment.content) continue; - const foundWords = flaggedWords.filter( - (word) => - post.name.toLowerCase().includes(word) || - post.firstComment.content.plainText.toLowerCase().includes(word), - ); - if (foundWords.length === 0) continue; + for (const post of newPosts) { + if (!post.firstComment || !post.firstComment.content) continue; + const foundWords = flaggedWords.filter( + (word) => + post.name.toLowerCase().includes(word) || + post.firstComment.content.plainText.toLowerCase().includes(word), + ); + const flagged = foundWords.length !== 0; - const postLink = `https://roblox.com/communities/${forumConfig.groupId}/${forumConfig.groupName}#!/forums/${forumConfig.channelId}/post/${post.id}`; - const postContainer = new ContainerBuilder(); + const postLink = `https://roblox.com/communities/${forumConfig.groupId}/${forumConfig.groupName}#!/forums/${forumChannel.channelId}/post/${post.id}`; + const postContainer = new ContainerBuilder(); - const { user, thumbnail } = await getRobloxUser( - String(post.createdBy), - 48, - ); + const { user, thumbnail } = await getRobloxUser( + String(post.createdBy), + 48, + ); - let emoji: { id: string; name: string } | null = null; + let emoji: { id: string; name: string } | null = null; - { - if (thumbnail.done) { - emoji = await uploadThumbnailAsEmoji( - client, - thumbnail.response.imageUri, - user.displayName || user.name, + { + if (thumbnail.done) { + emoji = await uploadThumbnailAsEmoji( + client, + thumbnail.response.imageUri, + user.displayName || user.name, + ); + } + + const postTitle = new TextDisplayBuilder().setContent( + `## ${post.name}`, + ); + const postDescription = new TextDisplayBuilder().setContent( + post.firstComment.content.plainText || "no description provided", + ); + const postAuthor = new TextDisplayBuilder().setContent( + [ + emoji ? `<:${emoji.name}:${emoji.id}>` : "", + `**${inlineLink(user.displayName || user.name, `https://www.roblox.com/users/${user.id}/profile`)}**`, + ].join(" "), + ); + + const footerParts = [ + `user id: ${inlineCode(String(post.createdBy))}`, + `posted <t:${Math.round(new Date(post.createdAt).getTime() / 1000)}:R>`, + flagged + ? `matched words: ${foundWords.map((word) => inlineCode(word)).join(", ")}` + : undefined, + ].filter(Boolean); + const postFooter = new TextDisplayBuilder().setContent( + `-# ${footerParts.join(" • ")}`, + ); + + postContainer.addTextDisplayComponents( + postAuthor, + postTitle, + postDescription, + postFooter, ); } - const postTitle = new TextDisplayBuilder().setContent( - `## ${post.name}`, - ); - const postDescription = new TextDisplayBuilder().setContent( - post.firstComment.content.plainText || "no description provided", - ); - const postAuthor = new TextDisplayBuilder().setContent( - [ - emoji ? `<:${emoji.name}:${emoji.id}>` : "", - `**${inlineLink(user.displayName || user.name, `https://www.roblox.com/users/${user.id}/profile`)}**`, - ].join(" "), - ); + const bansContainer = await buildBansContainer(user); + const buttonRow = new ActionRowBuilder<ButtonBuilder>(); + + if (flagged) + buttonRow.addComponents( + new ButtonBuilder() + .setLabel("🚩") + .setCustomId("flagged_post") + .setStyle(ButtonStyle.Danger) + .setDisabled(true), + ); - const footerParts = [ - `user id: ${inlineCode(String(post.createdBy))}`, - `posted <t:${Math.round(new Date(post.createdAt).getTime() / 1000)}:R>`, - `matched words: ${foundWords.map((word) => inlineCode(word)).join(", ")}`, - ]; - const postFooter = new TextDisplayBuilder().setContent( - `-# ${footerParts.join(" • ")}`, + buttonRow.addComponents( + new ButtonBuilder() + .setLabel(forumChannel.channelName) + .setCustomId("forum_channel") + .setStyle(ButtonStyle.Primary) + .setDisabled(true), + new ButtonBuilder() + .setLabel("view post") + .setStyle(ButtonStyle.Link) + .setURL(postLink), ); - postContainer.addTextDisplayComponents( - postAuthor, - postTitle, - postDescription, - postFooter, - ); + await channel.send({ + flags: ["IsComponentsV2"], + components: [postContainer, bansContainer, buttonRow], + }); } - - const bansContainer = await buildBansContainer(user); - const buttonRow = new ActionRowBuilder<ButtonBuilder>().addComponents( - new ButtonBuilder() - .setLabel("view post") - .setStyle(ButtonStyle.Link) - .setURL(postLink), - ); - - await channel.send({ - flags: ["IsComponentsV2"], - components: [postContainer, bansContainer, buttonRow], - }); + } catch (error) { + logger.error(`error while fetching forum posts: ${error}`); } - } catch (error) { - logger.error(`error while fetching forum posts: ${error}`); } }