diff --git a/bots/discord/config.schema.ts b/bots/discord/config.schema.ts index 2df4e54..b55c529 100644 --- a/bots/discord/config.schema.ts +++ b/bots/discord/config.schema.ts @@ -6,6 +6,7 @@ export type Config = { users?: string[] roles?: Record } + stickyMessages?: Record> moderation?: { roles: string[] cure?: { @@ -50,6 +51,12 @@ export type Config = { } } +export type StickyMessageConfig = { + timeout: number + forceSendTimeout?: number + message: BaseMessageOptions +} + export type RolePresetConfig = { give: string[] take: string[] diff --git a/bots/discord/package.json b/bots/discord/package.json index 2e12c33..9c6425b 100644 --- a/bots/discord/package.json +++ b/bots/discord/package.json @@ -43,4 +43,4 @@ "discord-api-types": "^0.37.92", "drizzle-kit": "^0.22.8" } -} \ No newline at end of file +} diff --git a/bots/discord/src/context.ts b/bots/discord/src/context.ts index a52e71f..62ede58 100644 --- a/bots/discord/src/context.ts +++ b/bots/discord/src/context.ts @@ -3,7 +3,7 @@ import { existsSync, readFileSync, readdirSync } from 'fs' import { join } from 'path' import { Client as APIClient } from '@revanced/bot-api' import { createLogger } from '@revanced/bot-shared' -import { Client as DiscordClient, Partials } from 'discord.js' +import { Client as DiscordClient, type Message, Partials } from 'discord.js' import { drizzle } from 'drizzle-orm/bun-sqlite' // Export some things first, as commands require them @@ -85,4 +85,19 @@ export const discord = { string, Command >, + stickyMessages: {} as Record< + string, + Record< + string, + { + forceSendTimerActive?: boolean + timeoutMs: number + forceSendMs?: number + send: (forced?: boolean) => Promise + currentMessage?: Message + interval?: NodeJS.Timeout + forceSendInterval?: NodeJS.Timeout + } + > + >, } as const diff --git a/bots/discord/src/events/discord/cureRequired.ts b/bots/discord/src/events/discord/cure.ts similarity index 100% rename from bots/discord/src/events/discord/cureRequired.ts rename to bots/discord/src/events/discord/cure.ts diff --git a/bots/discord/src/events/discord/messageCreate/scanMessage.ts b/bots/discord/src/events/discord/messageCreate/messageScan.ts similarity index 100% rename from bots/discord/src/events/discord/messageCreate/scanMessage.ts rename to bots/discord/src/events/discord/messageCreate/messageScan.ts diff --git a/bots/discord/src/events/discord/messageCreate/stickyMessageReset.ts b/bots/discord/src/events/discord/messageCreate/stickyMessageReset.ts new file mode 100644 index 0000000..0183c14 --- /dev/null +++ b/bots/discord/src/events/discord/messageCreate/stickyMessageReset.ts @@ -0,0 +1,30 @@ +import { on, withContext } from '$utils/discord/events' + +withContext(on, 'messageCreate', async ({ discord, logger }, msg) => { + if (!msg.inGuild()) return + if (msg.author.id === msg.client.user.id) return + + const store = discord.stickyMessages[msg.guildId]?.[msg.channelId] + if (!store) return + + if (!store.interval) store.interval = setTimeout(store.send, store.timeoutMs) as NodeJS.Timeout + else { + store.interval.refresh() + + if (!store.forceSendTimerActive && store.forceSendMs) { + logger.debug(`Channel ${msg.channelId} in guild ${msg.guildId} is active, starting force send timer`) + + store.forceSendTimerActive = true + + if (!store.forceSendInterval) + store.forceSendInterval = setTimeout( + () => + store.send(true).then(() => { + store.forceSendTimerActive = false + }), + store.forceSendMs, + ) as NodeJS.Timeout + else store.forceSendInterval.refresh() + } + } +}) diff --git a/bots/discord/src/events/discord/ready.ts b/bots/discord/src/events/discord/ready.ts index e40af8a..3e21af1 100644 --- a/bots/discord/src/events/discord/ready.ts +++ b/bots/discord/src/events/discord/ready.ts @@ -1,14 +1,68 @@ import { database, logger } from '$/context' import { appliedPresets } from '$/database/schemas' +import { applyCommonEmbedStyles } from '$/utils/discord/embeds' +import { on, withContext } from '$/utils/discord/events' import { removeRolePreset } from '$/utils/discord/rolePresets' -import type { Client } from 'discord.js' import { lt } from 'drizzle-orm' -import { on, withContext } from 'src/utils/discord/events' -export default withContext(on, 'ready', ({ config, logger }, client) => { +import type { Client } from 'discord.js' + +export default withContext(on, 'ready', async ({ config, discord, logger }, client) => { logger.info(`Connected to Discord API, logged in as ${client.user.displayName} (@${client.user.tag})!`) logger.info(`Bot is in ${client.guilds.cache.size} guilds`) + if (config.stickyMessages) + for (const [guildId, channels] of Object.entries(config.stickyMessages)) { + const guild = await client.guilds.fetch(guildId) + discord.stickyMessages[guildId] = {} + + for (const [channelId, { message, timeout, forceSendTimeout }] of Object.entries(channels)) { + const channel = await guild.channels.fetch(channelId) + if (!channel?.isTextBased()) return + + const send = async (forced = false) => { + try { + const msg = await channel.send({ + ...message, + embeds: message.embeds?.map(it => applyCommonEmbedStyles(it, true, true, true)), + }) + + const store = discord.stickyMessages[guildId]![channelId] + if (!store) return + + await store.currentMessage?.delete().catch() + store.currentMessage = msg + + if (!forced) { + clearTimeout(store.forceSendInterval) + logger.debug( + `Timeout ended for sticky message in channel ${channelId} in guild ${guildId}, channel is inactive`, + ) + } else { + clearTimeout(store.interval) + logger.debug( + `Forced send timeout for sticky message in channel ${channelId} in guild ${guildId} ended, channel is too active`, + ) + } + + logger.debug(`Sent sticky message to channel ${channelId} in guild ${guildId}`) + } catch (e) { + logger.error( + `Error while sending sticky message to channel ${channelId} in guild ${guildId}:`, + e, + ) + } + } + + discord.stickyMessages[guildId]![channelId] = { + forceSendMs: forceSendTimeout, + timeoutMs: timeout, + send, + forceSendTimerActive: false, + } + } + } + if (config.rolePresets) { removeExpiredPresets(client) setTimeout(() => removeExpiredPresets(client), config.rolePresets.checkExpiredEvery) diff --git a/bots/discord/src/utils/discord/embeds.ts b/bots/discord/src/utils/discord/embeds.ts index cb584cd..a486bbb 100644 --- a/bots/discord/src/utils/discord/embeds.ts +++ b/bots/discord/src/utils/discord/embeds.ts @@ -1,5 +1,5 @@ import { DefaultEmbedColor, MessageScanHumanizedMode, ReVancedLogoURL } from '$/constants' -import { EmbedBuilder, type EmbedField, type User } from 'discord.js' +import { type APIEmbed, EmbedBuilder, type EmbedField, type JSONEncodable, type User } from 'discord.js' import type { ConfigMessageScanResponseMessage } from '../../../config.schema' export const createErrorEmbed = (title: string | null, description?: string) => @@ -26,23 +26,12 @@ export const createSuccessEmbed = (title: string | null, description?: string) = export const createMessageScanResponseEmbed = ( response: NonNullable[number], mode: 'ocr' | 'nlp' | 'match', -) => { - // biome-ignore lint/style/noParameterAssign: While this is confusing, it is fine for this purpose - if ('toJSON' in response) response = response.toJSON() - - const embed = new EmbedBuilder().setTitle(response.title ?? null) - - if (response.description) embed.setDescription(response.description) - if (response.fields) embed.addFields(response.fields) - - embed.setFooter({ +) => + applyCommonEmbedStyles(response, true, true, true).setFooter({ text: `ReVanced • Via ${MessageScanHumanizedMode[mode]}`, iconURL: ReVancedLogoURL, }) - return applyCommonEmbedStyles(embed, true, true, true) -} - export const createModerationActionEmbed = ( action: string, user: User, @@ -77,19 +66,23 @@ export const applyReferenceToModerationActionEmbed = (embed: EmbedBuilder, refer } export const applyCommonEmbedStyles = ( - embed: EmbedBuilder, + embed: EmbedBuilder | JSONEncodable | APIEmbed, setThumbnail = false, setFooter = false, setColor = false, ) => { + // biome-ignore lint/style/noParameterAssign: While this is confusing, it is fine for this purpose + if ('toJSON' in embed) embed = embed.toJSON() + const builder = new EmbedBuilder(embed) + if (setFooter) - embed.setFooter({ + builder.setFooter({ text: 'ReVanced', iconURL: ReVancedLogoURL, }) - if (setColor) embed.setColor(DefaultEmbedColor) - if (setThumbnail) embed.setThumbnail(ReVancedLogoURL) + if (setColor) builder.setColor(DefaultEmbedColor) + if (setThumbnail) builder.setThumbnail(ReVancedLogoURL) - return embed + return builder } diff --git a/bun.lockb b/bun.lockb index 598bb47..d84d315 100755 Binary files a/bun.lockb and b/bun.lockb differ