all repos — stealth-developers @ 9055619cdb9d935e4e464341ad974b37fa17d10b

feat: watch for new forum posts
willow hai@wlo.moe
Tue, 15 Jul 2025 01:43:51 +0100
commit

9055619cdb9d935e4e464341ad974b37fa17d10b

parent

28a0470c5b3973651d9b1dec66820b5b41f94000

M bun.lockbun.lock

@@ -4,6 +4,7 @@ "workspaces": {

"": { "name": "template", "dependencies": { + "@sillowww/lily": "^0.2.0", "discord.js": "^14.19.3", "mongodb": "^6.16.0", "mongoose": "^8.15.0",

@@ -57,6 +58,8 @@

"@sapphire/shapeshift": ["@sapphire/shapeshift@4.0.0", "", { "dependencies": { "fast-deep-equal": "^3.1.3", "lodash": "^4.17.21" } }, "sha512-d9dUmWVA7MMiKobL3VpLF8P2aeanRTu6ypG2OIaEv/ZHH/SUQ2iHOVyi5wAPjQ+HmnMuL0whK9ez8I/raWbtIg=="], "@sapphire/snowflake": ["@sapphire/snowflake@3.5.3", "", {}, "sha512-jjmJywLAFoWeBi1W7994zZyiNWPIiqRRNAmSERxyg93xRGzNYvGjlZ0gR6x0F4gPRi2+0O6S71kOZYyr3cxaIQ=="], + + "@sillowww/lily": ["@sillowww/lily@0.2.0", "", { "peerDependencies": { "typescript": "^5" } }, "sha512-ayQi4anINUfu5B/evMIBIMD9BlOueeSKwGXoE+z+VtCVxc2ypMEb3sJyMFqjiStAW7ORMGM3oLV65JFK6PoaUw=="], "@types/bun": ["@types/bun@1.2.13", "", { "dependencies": { "bun-types": "1.2.13" } }, "sha512-u6vXep/i9VBxoJl3GjZsl/BFIsvML8DfVDO0RYLEwtSZSp981kEO1V5NwRcO1CPJ7AmvpbnDCiMKo3JvbDEjAg=="],
M package.jsonpackage.json

@@ -24,6 +24,7 @@ "peerDependencies": {

"typescript": "^5.0.0" }, "dependencies": { + "@sillowww/lily": "^0.2.0", "discord.js": "^14.19.3", "mongodb": "^6.16.0", "mongoose": "^8.15.0",
M src/config.tssrc/config.ts

@@ -13,6 +13,7 @@ });

const projectSchema = z.record( z.object({ + universe: z.string(), name: z.string(), displayName: z.string(), iconURL: z.string().optional(),

@@ -29,9 +30,24 @@ .optional(),

}), ); +const forumWatcher = z.object({ + enabled: z.boolean().default(false), + interval: z + .number() + .int() + .min(1) + .default(60) + .describe("how often to check for new posts in seconds"), + groupId: z.string(), + groupName: z.string().transform((val) => val.replace(/\s+/g, "-")), + channelId: z.string(), + notificationChannelId: z.string(), +}); + const robloxSchema = z.object({ apiKey: z.string(), cookie: z.string().optional(), + forumWatcher: forumWatcher.optional(), }); const bloxlinkSchema = z.object({
M src/index.tssrc/index.ts

@@ -5,6 +5,7 @@ import registerEvents from "./handlers/events.ts";

import registerInteractions from "./handlers/interactions.ts"; import cfg from "./config.ts"; +import { watchForum } from "./utils/forumWatcher.ts"; import { COLOURS, Logger } from "./utils/logging.ts"; const logger = new Logger("bot", {

@@ -23,6 +24,18 @@ await connectDatabase();

await Promise.all([registerEvents(client), registerInteractions(client)]); logger.info("events and interactions registered!"); logger.newLine(); + + const forumConfig = cfg.data.roblox?.forumWatcher; + if (!forumConfig || !forumConfig.enabled) return; + await watchForum(client); + setInterval( + () => { + watchForum(client).catch((error) => { + logger.error("error while watching forum:", error); + }); + }, + forumConfig.interval * 1000 || 60000, + ); }); await client.login(cfg.data.discord.token);
M src/interactions/commands/user.tssrc/interactions/commands/user.ts

@@ -5,6 +5,8 @@ SlashCommandBuilder,

} from "discord.js"; import config from "@/config"; +import { buildBansContainer } from "@/utils/bans"; +import { hasManagerPermissions } from "@/utils/permissions"; import { getUserInfoFromDiscord, getUserInfoFromRoblox,

@@ -46,17 +48,23 @@

await interaction.deferReply(); let result: Awaited<ReturnType<typeof getUserInfoFromDiscord>>; + if (!interaction.member) { + await interaction.editReply({ + content: "❌ you must be in a server to use this command.", + }); + return; + } if (subcommand === "discord") { const discordUser = interaction.options.getUser("user", true); - result = await getUserInfoFromDiscord(discordUser.id); + result = await getUserInfoFromDiscord(discordUser.id, interaction.member); } else if (subcommand === "roblox") { const input = interaction.options.getString("input", true); if (/^\d+$/.test(input)) { - result = await getUserInfoFromRoblox(input); + result = await getUserInfoFromRoblox(input, interaction.member); } else { - result = await getUserInfoFromRobloxUsername(input); + result = await getUserInfoFromRobloxUsername(input, interaction.member); } } else { await interaction.editReply({

@@ -73,8 +81,11 @@ return;

} await interaction.editReply({ - embeds: [result.embed], - components: result.components, + flags: ["IsComponentsV2"], + components: [...result.containers, ...result.actionRows], + }); + await interaction.followUp({ + content: result.user.id, }); }
M src/interactions/menus/getInfo.tssrc/interactions/menus/getInfo.ts

@@ -16,7 +16,18 @@ async function execute(

_client: Client, interaction: UserContextMenuCommandInteraction, ) { - const result = await getUserInfoFromDiscord(interaction.targetId); + if (!interaction.member) { + await interaction.reply({ + content: "❌ you must be in a server to use this command.", + flags: ["Ephemeral"], + }); + return; + } + + const result = await getUserInfoFromDiscord( + interaction.targetId, + interaction.member, + ); if ("error" in result) { return interaction.reply({

@@ -26,9 +37,10 @@ });

} await interaction.reply({ - embeds: [result.embed], - components: result.components, + flags: ["IsComponentsV2"], + components: [...result.containers, ...result.actionRows], }); + await interaction.followUp({ content: result.user.id }); } export default {
A src/types/forums.ts

@@ -0,0 +1,52 @@

+export type ForumComment = { + id: string; + parentId: string; + content: { + plainText: string; + }; + createdBy: number; + creatorDisplayName: string | null; + createdtAt: string; + updatedAt: string | null; + deletedAt: string | null; + threadId: string | null; + reactions: []; + threadCommentCount: null | number; + threadComments: null | ForumComment[]; +}; + +export type ForumCommentCreator = { + displayName: string; + groupRoleName: string | null; + hasVerifiedBadge: boolean; +}; + +export type Post = { + categoryId: string; + isLocked: boolean; + isPinned: boolean; + isUnread: boolean; + commentCount: number; + notificationPreference: null; + id: string; + /** title of the post */ + name: string; + groupId: number; + description: string | null; + channelType: string | null; + parentChannelId: string | null; + createdBy: number; + createdAt: string; + updatedAt: string; + deletedAt: string | null; + archivedAt: string | null; + archivedBy: number | null; + /** represents the post's body */ + firstComment: ForumComment; +}; + +export type GetPosts = { + previousPageCursor: string | null; + nextPageCursor: string | null; + data: Post[]; +};
A src/utils/bans.ts

@@ -0,0 +1,100 @@

+import config from "@/config"; +import type { GetUserResponse } from "@/types/roblox"; +import { ContainerBuilder, TextDisplayBuilder } from "discord.js"; + +const projects = Object.values(config.data.projects); +const BASE_URL = "https://apis.roblox.com/user/cloud/v2/universes"; + +export type Restriction = { + active: true | false; + startTime: string; + privateReason: string; + displayReason: string; + excludeAltAccounts: boolean; + inherited: boolean; +}; + +export type GetBansResponse = { + path: string; + user: string; + gameJoinRestriction: Restriction; +}; + +export type GetBansResponseWithProject = GetBansResponse & { + project: string; +}; + +export async function getBans( + userId: string, +): Promise<GetBansResponseWithProject[] | null> { + if (!config.data.roblox?.cookie) return null; + + const fetches = projects.map(async (project) => { + const url = `${BASE_URL}/${project.universe}/user-restrictions/${userId}`; + const bans = await fetch(url, { + headers: { + Cookie: config.data.roblox?.cookie || "", + }, + }); + + if (!bans.ok) { + console.error( + `failed to fetch bans for user ${userId} in project ${project.name}:`, + bans.statusText, + ); + return null; + } + + const data: GetBansResponse = await bans.json(); + return { ...data, project: project.displayName }; + }); + + const results = await Promise.all(fetches); + const bansArray = results.filter( + (data): data is GetBansResponseWithProject => data !== null, + ); + + return bansArray; +} + +export async function buildBansContainer( + user: GetUserResponse, +): Promise<ContainerBuilder> { + const bansContainer = new ContainerBuilder(); + { + const bansTitle = new TextDisplayBuilder().setContent( + `## Bans for ${user.displayName || user.name}`, + ); + + const bans = await getBans(user.id); + + const constructBan = (ban: GetBansResponseWithProject) => { + if (!ban.gameJoinRestriction.active) { + return new TextDisplayBuilder().setContent( + `**${ban.project}**: no active ban`, + ); + } + + 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`, + ); + }; + + if (bans && bans.length > 0) { + const banComponents = bans.map(constructBan); + bansContainer.addTextDisplayComponents(bansTitle, ...banComponents); + } else { + bansContainer.addTextDisplayComponents( + bansTitle, + new TextDisplayBuilder().setContent( + "no active bans found for this user", + ), + ); + } + } + + return bansContainer; +}
A src/utils/forumWatcher.ts

@@ -0,0 +1,212 @@

+import config from "@/config"; +import type { Post } from "@/types/forums"; +import { Logger } from "@sillowww/lily"; +import { + ActionRowBuilder, + ButtonBuilder, + ButtonStyle, + type Client, + ContainerBuilder, + type GuildTextBasedChannel, + REST, + Routes, + TextDisplayBuilder, + inlineCode, +} from "discord.js"; +import { buildBansContainer } from "./bans"; +import { getRobloxUser } from "./roblox"; + +const startTime = Date.now(); +const seenPosts = new Set<string>(); +const forumConfig = config.data.roblox?.forumWatcher; +const logger = new Logger("ForumWatcher"); + +export function isNewPost(post: Post): boolean { + const postId = post.id.toString(); + let isNew = true; + + isNew = !seenPosts.has(postId); + if (isNew) seenPosts.add(postId); + + if (new Date(post.createdAt).getTime() < startTime) isNew = false; + return isNew; +} + +async function fetchPosts(): 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`, + { + headers: { + Cookie: config.data.roblox?.cookie || "", + }, + }, + ); + + if (!response.ok) { + logger.error( + `failed to fetch posts: ${response.status} ${response.statusText}`, + ); + throw new Error("failed to fetch posts from the forum."); + } + + const data = await response.json(); + if (!data || !Array.isArray(data.data)) { + logger.error("invalid response format from the forum API."); + throw new Error("invalid response format from the forum API."); + } + + return data.data as Post[]; +} + +function inlineLink(text: string, link: string): string { + return `[${text}](${link})`; +} + +export async function uploadThumbnailAsEmoji( + client: Client | null, + imageUrl: string, + name: string, +) { + const rest = new REST({ version: "10" }).setToken(config.data.discord.token); + const imageResponse = await fetch(imageUrl); + if (!imageResponse.ok) { + logger.error(`failed to fetch image from ${imageUrl}`); + return null; + } + + const appId = config.data.discord.app_id; + + const imageBuffer = await imageResponse.arrayBuffer(); + + if (imageBuffer.byteLength > 256 * 1024) { + logger.warn("avatar image too large, skipping"); + return null; + } + + const base64Image = Buffer.from(imageBuffer).toString("base64"); + const imageData = `data:image/png;base64,${base64Image}`; + + try { + const randomId = Math.floor(Math.random() * 1000000); + client; + const emoji = await rest.post(Routes.applicationEmojis(appId), { + body: { + name: `${name}_avatar_${randomId}`, + image: imageData, + }, + }); + + return emoji as { id: string; name: string }; + } catch (_err) { + logger.error(`failed to upload emoji for ${name}: ${_err}`); + if (_err instanceof Error) { + logger.error(`error details: ${_err.message}`); + } else logger.error("unknown error occurred while uploading emoji."); + } + + return null; +} + +export async function watchForum(client: Client) { + if (!forumConfig || !forumConfig.enabled) return; + const channelId = forumConfig.notificationChannelId; + const channel = client.channels.cache.get(channelId) as GuildTextBasedChannel; + + if (!channel || !channel.isTextBased()) { + logger.error( + `notification channel with ID ${channelId} is not a valid text channel.`, + ); + return; + } + + try { + const posts = await fetchPosts(); + const newPosts = posts.filter(isNewPost); + + const flaggedWords = [ + "ban", + "appeal", + "hacker", + "exploit", + "cheater", + "unban", + "moderator", + "admin", + "mod", + "exploiters", + "exploits", + ]; + + for (const post of newPosts) { + const includesWord = flaggedWords.some((word) => + post.name.toLowerCase().includes(word), + ); + if (!includesWord) continue; + const postLink = `https://roblox.com/communities/${forumConfig.groupId}/${forumConfig.groupName}#!/forums/${forumConfig.channelId}/post/${post.id}`; + const postContainer = new ContainerBuilder(); + + const { user, thumbnail } = await getRobloxUser( + String(post.createdBy), + 48, + ); + + let emoji: { id: string; name: string } | null = null; + + { + 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>`, + ]; + const postFooter = new TextDisplayBuilder().setContent( + `-# ${footerParts.join(" • ")}`, + ); + + postContainer.addTextDisplayComponents( + postAuthor, + postTitle, + postDescription, + postFooter, + ); + } + + 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}`); + } +}
M src/utils/permissions.tssrc/utils/permissions.ts

@@ -1,8 +1,8 @@

-import type { GuildMember } from "discord.js"; +import type { APIGuildMember, GuildMember } from "discord.js"; import { GuildModel } from "../database/schemas.ts"; export async function hasManagerPermissions( - member: GuildMember, + member: GuildMember | APIGuildMember, ): Promise<boolean> { if ( member.permissions.has("ManageGuild") ||
M src/utils/userInfo.tssrc/utils/userInfo.ts

@@ -5,11 +5,18 @@ getRobloxIdFromUsername,

getRobloxUser, } from "@/utils/roblox"; import { + type APIGuildMember, ActionRowBuilder, ButtonBuilder, ButtonStyle, + ContainerBuilder, EmbedBuilder, + type GuildMember, + TextDisplayBuilder, } from "discord.js"; +import { buildBansContainer } from "./bans"; +import { uploadThumbnailAsEmoji } from "./forumWatcher"; +import { hasManagerPermissions } from "./permissions"; export type AvatarSize = | 48

@@ -24,13 +31,16 @@ | 352

| 420 | 720; -export interface UserInfoResult { +export type UserInfoResult = { + user: GetUserResponse; embed: EmbedBuilder; - components: ActionRowBuilder<ButtonBuilder>[]; -} + containers: ContainerBuilder[]; + actionRows: ActionRowBuilder<ButtonBuilder>[]; +}; export async function getUserInfoFromRobloxUsername( username: string, + member: GuildMember | APIGuildMember, ): Promise<UserInfoResult | { error: string }> { const robloxId = await getRobloxIdFromUsername(username);

@@ -38,29 +48,33 @@ if (!robloxId) {

return { error: "user not found or username is invalid" }; } - return getUserInfoFromRoblox(robloxId); + return getUserInfoFromRoblox(robloxId, member); } export async function getUserInfoFromDiscord( discordId: string, + member: GuildMember | APIGuildMember, ): Promise<UserInfoResult | { error: string }> { const connectedAccount = await getConnectedRobloxUser(discordId); if ("error" in connectedAccount) { return { error: connectedAccount.error }; } - return getUserInfoFromRoblox(connectedAccount.robloxID); + return getUserInfoFromRoblox(connectedAccount.robloxID, member); } export async function getUserInfoFromRoblox( robloxId: string, + member: GuildMember | APIGuildMember, avatarSize: AvatarSize = 420, ): Promise<UserInfoResult | { error: string }> { try { const { user, thumbnail } = await getRobloxUser(robloxId, avatarSize); + return formatUserInfo( user, thumbnail.done ? thumbnail.response.imageUri : null, + member, ); } catch (error) { return {

@@ -70,12 +84,48 @@ };

} } -export function formatUserInfo( +export async function formatUserInfo( user: GetUserResponse, thumbnailUrl: string | null, -): UserInfoResult { + member: GuildMember | APIGuildMember, +): Promise<UserInfoResult> { const createdAt = new Date(user.createTime); const timestamp = Math.floor(createdAt.getTime() / 1000); + const hasManagerPerms = await hasManagerPermissions(member); + + const thumbnailEmoji = await uploadThumbnailAsEmoji( + null, + thumbnailUrl || "", + user.displayName || user.name, + ); + + const userContainer = new ContainerBuilder(); + { + const emojiText = thumbnailEmoji + ? `<:${thumbnailEmoji.name}:${thumbnailEmoji.id}> ` + : ""; + const userTitle = new TextDisplayBuilder().setContent( + `## ${emojiText} ${user.displayName || user.name}`, + ); + const userDescription = new TextDisplayBuilder().setContent( + user.about || "no description provided", + ); + const userId = new TextDisplayBuilder().setContent(`**id:** ${user.id}`); + const userUsername = new TextDisplayBuilder().setContent( + `**username:** ${user.name}`, + ); + const userCreated = new TextDisplayBuilder().setContent( + `**created:** <t:${timestamp}> (<t:${timestamp}:R>)`, + ); + + userContainer.addTextDisplayComponents( + userTitle, + userDescription, + userId, + userUsername, + userCreated, + ); + } const embed = new EmbedBuilder() .setAuthor({

@@ -98,19 +148,20 @@ value: `<t:${timestamp}> (<t:${timestamp}:R>)`,

}, ) .setColor(0x00aaff); - - if (thumbnailUrl) { - embed.setThumbnail(thumbnailUrl); - } - - const components = [ - new ActionRowBuilder<ButtonBuilder>().addComponents( - new ButtonBuilder() - .setURL(`https://www.roblox.com/users/${user.id}/profile`) - .setLabel("view profile") - .setStyle(ButtonStyle.Link), - ), - ]; + if (thumbnailUrl) embed.setThumbnail(thumbnailUrl); + const bansComponent = await buildBansContainer(user); - return { embed, components }; + return { + user: user, + embed: embed, + containers: [userContainer, ...(hasManagerPerms ? [bansComponent] : [])], + actionRows: [ + new ActionRowBuilder<ButtonBuilder>().addComponents( + new ButtonBuilder() + .setURL(`https://www.roblox.com/users/${user.id}/profile`) + .setLabel("view profile") + .setStyle(ButtonStyle.Link), + ), + ], + }; }