diff --git a/src/BaseClient/Bot/Cache/automod.ts b/src/BaseClient/Bot/Cache/automod.ts new file mode 100644 index 00000000..9dcd1291 --- /dev/null +++ b/src/BaseClient/Bot/Cache/automod.ts @@ -0,0 +1,59 @@ +import type { APIAutoModerationRule } from 'discord.js'; +import type Redis from 'ioredis'; +import Cache from './base.js'; + +export type RAutomod = APIAutoModerationRule; + +export const RAutomodKeys = [ + 'id', + 'guild_id', + 'name', + 'creator_id', + 'event_type', + 'trigger_type', + 'trigger_metadata', + 'actions', + 'enabled', + 'exempt_roles', + 'exempt_channels', +] as const; + +export default class AutomodCache extends Cache { + public keys = RAutomodKeys; + + constructor(prefix: string, redis: Redis) { + super(`${prefix}:automod`, redis); + } + + key() { + return this.prefix; + } + + async set(data: APIAutoModerationRule) { + const rData = this.apiToR(data); + if (!rData) return false; + + await this.redis.set(`${this.key()}:${rData.guild_id}:${rData.id}`, JSON.stringify(rData)); + + return true; + } + + get(id: string) { + return this.redis.get(`${this.key()}:${id}`).then((data) => this.stringToData(data)); + } + + del(id: string): Promise { + return this.redis + .keys(`${this.key()}:${id}`) + .then((keys) => (keys.length ? this.redis.del(keys) : 0)); + } + + apiToR(data: APIAutoModerationRule) { + const keysNotToCache = Object.keys(data).filter( + (key): key is keyof typeof data => !this.keys.includes(key), + ); + + keysNotToCache.forEach((k) => delete data[k]); + return structuredClone(data) as RAutomod; + } +} diff --git a/src/BaseClient/Bot/Cache/ban.ts b/src/BaseClient/Bot/Cache/ban.ts new file mode 100644 index 00000000..32e442be --- /dev/null +++ b/src/BaseClient/Bot/Cache/ban.ts @@ -0,0 +1,50 @@ +import type { APIBan } from 'discord.js'; +import type Redis from 'ioredis'; +import Cache from './base.js'; + +export type RBan = Omit & { user_id: string; guild_id: string }; + +export const RBanKeys = ['reason', 'user_id', 'guild_id'] as const; + +export default class BanCache extends Cache { + public keys = RBanKeys; + + constructor(prefix: string, redis: Redis) { + super(`${prefix}:bans`, redis); + } + + key() { + return this.prefix; + } + + async set(data: APIBan, guildId: string) { + const rData = this.apiToR(data, guildId); + if (!rData) return false; + + await this.redis.set(`${this.key()}:${rData.guild_id}:${rData.user_id}`, JSON.stringify(rData)); + + return true; + } + + get(gId: string, uId: string) { + return this.redis.get(`${this.key()}:${gId}:${uId}`).then((data) => this.stringToData(data)); + } + + del(gId: string, uId: string): Promise { + return this.redis.del(`${this.key()}:${gId}:${uId}`); + } + + apiToR(data: APIBan, guildId: string) { + const keysNotToCache = Object.keys(data).filter( + (key): key is keyof typeof data => !this.keys.includes(key), + ); + + const rData = structuredClone(data) as unknown as RBan; + rData.guild_id = guildId; + rData.user_id = data.user.id; + + keysNotToCache.forEach((k) => delete (rData as Record)[k as string]); + + return rData; + } +} diff --git a/src/BaseClient/Bot/Cache/base.ts b/src/BaseClient/Bot/Cache/base.ts new file mode 100644 index 00000000..e0ad86b1 --- /dev/null +++ b/src/BaseClient/Bot/Cache/base.ts @@ -0,0 +1,162 @@ +import type { + APIApplicationCommand, + APIApplicationCommandPermission, + APIAutoModerationRule, + APIBan, + APIEmoji, + APIGuild, + APIGuildChannel, + APIGuildIntegration, + APIGuildMember, + APIGuildScheduledEvent, + APIInvite, + APIMessage, + APIReaction, + APIRole, + APISoundboardSound, + APIStageInstance, + APISticker, + APIThreadChannel, + APIThreadMember, + APIUser, + APIVoiceState, + APIWebhook, +} from 'discord.js'; +import type Redis from 'ioredis'; +import type { RAutomod } from './automod'; +import type { RBan } from './ban'; +import type { RChannel, RChannelTypes } from './channel'; +import type { RCommand } from './command'; +import type { RCommandPermission } from './commandPermission'; +import type { REmoji } from './emoji'; +import type { REvent } from './event'; +import type { RGuild } from './guild'; +import type { RGuildCommand } from './guildCommand'; +import type { RIntegration } from './integration'; +import type { RInvite } from './invite'; +import type { RMember } from './member'; +import type { RMessage } from './message'; +import type { RReaction } from './reaction'; +import type { RRole } from './role'; +import type { RSoundboardSound } from './soundboard'; +import type { RStageInstance } from './stage'; +import type { RSticker } from './sticker'; +import type { RThread } from './thread'; +import type { RThreadMember } from './threadMember'; +import type { RUser } from './user'; +import type { RVoiceState } from './voice'; +import type { RWebhook } from './webhook'; + +type GuildBasedCommand = T extends true + ? APIApplicationCommand & { guild_id: string } + : APIApplicationCommand; + +type DeriveRFromAPI = T extends APIThreadChannel & { + guild_id: string; + member_id: string; +} + ? RThread + : T extends APIGuildIntegration & { + user_id: string; + guild_id: string; + } + ? RIntegration + : T extends APIApplicationCommand + ? K extends true + ? RGuildCommand + : RCommand + : T extends APIUser + ? RUser + : T extends GuildBasedCommand + ? K extends true + ? RGuildCommand + : RCommand + : T extends APIGuild + ? RGuild + : T extends APISoundboardSound + ? RSoundboardSound + : T extends APIGuildChannel + ? RChannel + : T extends APISticker + ? RSticker + : T extends APIStageInstance + ? RStageInstance + : T extends APIRole + ? RRole + : T extends APIVoiceState + ? RVoiceState + : T extends APIAutoModerationRule + ? RAutomod + : T extends APIBan + ? RBan + : T extends APIInvite + ? RInvite + : T extends APIGuildMember + ? RMember + : T extends APIGuildScheduledEvent + ? REvent + : T extends APIWebhook + ? RWebhook + : T extends APIEmoji + ? REmoji + : T extends APIThreadChannel + ? RThread + : T extends APIApplicationCommandPermission + ? RCommandPermission + : T extends APIMessage + ? RMessage + : T extends APIGuildIntegration + ? RIntegration + : T extends APIReaction + ? RReaction + : T extends APIThreadMember + ? RThreadMember + : never; + +export default abstract class Cache< + T extends + | APIUser + | APIGuild + | APISoundboardSound + | GuildBasedCommand + | APISticker + | APIStageInstance + | APIRole + | APIVoiceState + | APIAutoModerationRule + | APIBan + | APIInvite + | APIGuildMember + | APIGuildScheduledEvent + | APIEmoji + | APIGuildChannel + | APIThreadChannel + | APIApplicationCommandPermission + | APIMessage + | APIWebhook + | APIGuildIntegration + | APIReaction + | APIThreadMember, + K extends boolean = false, +> { + public prefix: string; + public redis: Redis; + abstract keys: ReadonlyArray>; + + constructor(prefix: string, redis: Redis) { + this.prefix = prefix; + this.redis = redis; + } + + // eslint-disable-next-line class-methods-use-this + stringToData = (data: string | null) => (data ? (JSON.parse(data) as DeriveRFromAPI) : null); + + key(id?: string) { + return `${this.prefix}${id ? `:${id}` : '*'}`; + } + + abstract set(...args: [T, string, string, string]): Promise; + abstract get(...args: string[]): Promise>; + abstract del(...args: string[]): Promise; + abstract apiToR(...args: [T, string, string, string]): DeriveRFromAPI | false; +} diff --git a/src/BaseClient/Bot/Cache/channel.ts b/src/BaseClient/Bot/Cache/channel.ts new file mode 100644 index 00000000..96e4e534 --- /dev/null +++ b/src/BaseClient/Bot/Cache/channel.ts @@ -0,0 +1,97 @@ +import type { APIGuildChannel, ChannelType } from 'discord.js'; +import type Redis from 'ioredis'; +import Cache from './base.js'; + +export type RChannelTypes = + | ChannelType.GuildAnnouncement + | ChannelType.GuildCategory + | ChannelType.GuildDirectory + | ChannelType.GuildForum + | ChannelType.GuildMedia + | ChannelType.GuildStageVoice + | ChannelType.GuildText + | ChannelType.GuildVoice; + +export type RChannel = Omit, 'guild'> & { + guild_id: string; +}; + +export const RChannelKeys = [ + 'name', + 'id', + 'type', + 'flags', + 'guild_id', + 'permission_overwrites', + 'position', + 'parent_id', + 'nsfw', + 'rate_limit_per_user', + 'default_auto_archive_duration', + 'default_thread_rate_limit_per_user', + 'topic', + 'bitrate', + 'user_limit', + 'rtc_region', + 'video_quality_mode', + 'last_pin_timestamp', + 'available_tags', + 'default_reaction_emoji', + 'default_sort_order', + 'default_forum_layout', +] as (keyof APIGuildChannel & + keyof APIGuildChannel & + keyof APIGuildChannel & + keyof APIGuildChannel & + keyof APIGuildChannel & + keyof APIGuildChannel & + keyof APIGuildChannel & + keyof APIGuildChannel)[]; + +export default class ChannelCache extends Cache< + APIGuildChannel & { guild_id: string } +> { + public keys = RChannelKeys; + + constructor(prefix: string, redis: Redis) { + super(`${prefix}:channels`, redis); + } + + key() { + return this.prefix; + } + + async set(data: APIGuildChannel) { + const rData = this.apiToR(data); + if (!rData) return false; + + await this.redis.set(`${this.key()}:${data.guild_id}:${data.id}`, JSON.stringify(rData)); + + return true; + } + + get(id: string) { + return this.redis.get(`${this.key()}:${id}`).then((data) => this.stringToData(data)); + } + + del(id: string): Promise { + return this.redis + .keys(`${this.key()}:${id}`) + .then((keys) => (keys.length ? this.redis.del(keys) : 0)); + } + + apiToR(data: APIGuildChannel) { + if (!data.guild_id) return false; + + const keysNotToCache = Object.keys(data).filter( + (key): key is keyof typeof data => !this.keys.includes(key), + ); + + const rData = structuredClone(data) as unknown as RChannel; + rData.guild_id = data.guild_id; + + keysNotToCache.forEach((k) => delete (rData as Record)[k as string]); + + return rData; + } +} diff --git a/src/BaseClient/Bot/Cache/command.ts b/src/BaseClient/Bot/Cache/command.ts new file mode 100644 index 00000000..d1b33e4f --- /dev/null +++ b/src/BaseClient/Bot/Cache/command.ts @@ -0,0 +1,67 @@ +import type { APIApplicationCommand } from 'discord.js'; +import type Redis from 'ioredis'; +import Cache from './base.js'; + +export type RCommand = Omit; + +export const RCommandKeys = [ + 'id', + 'type', + 'application_id', + 'name', + 'name_localizations', + 'name_localized', + 'description', + 'description_localizations', + 'description_localized', + 'options', + 'default_member_permissions', + 'dm_permission', + 'default_permission', + 'nsfw', + 'integration_types', + 'contexts', + 'version', + 'handler', +] as const; + +export default class CommandCache extends Cache { + public keys = RCommandKeys; + + constructor(prefix: string, redis: Redis) { + super(`${prefix}:commands`, redis); + } + + key() { + return this.prefix; + } + + async set(data: APIApplicationCommand) { + const rData = this.apiToR(data); + if (!rData) return false; + + await this.redis.set(`${this.key()}:${data.id}`, JSON.stringify(rData)); + + return true; + } + + get(id: string) { + return this.redis.get(`${this.key()}:${id}`).then((data) => this.stringToData(data)); + } + + del(id: string): Promise { + return this.redis.del(`${this.key()}:${id}`); + } + + apiToR(data: APIApplicationCommand) { + if (!data.guild_id) return false; + + const keysNotToCache = Object.keys(data).filter( + (key): key is keyof typeof data => !this.keys.includes(key), + ); + + keysNotToCache.forEach((k) => delete data[k]); + + return structuredClone(data) as RCommand; + } +} diff --git a/src/BaseClient/Bot/Cache/commandPermission.ts b/src/BaseClient/Bot/Cache/commandPermission.ts new file mode 100644 index 00000000..2c5027dd --- /dev/null +++ b/src/BaseClient/Bot/Cache/commandPermission.ts @@ -0,0 +1,51 @@ +import type { APIApplicationCommandPermission } from 'discord.js'; +import type Redis from 'ioredis'; +import Cache from './base.js'; + +export type RCommandPermission = APIApplicationCommandPermission & { guild_id: string }; + +export const RCommandPermissionKeys = ['id', 'type', 'permission', 'guild_id'] as const; + +export default class CommandPermissionCache extends Cache { + public keys = RCommandPermissionKeys; + + constructor(prefix: string, redis: Redis) { + super(`${prefix}:commandPermissions`, redis); + } + + key() { + return this.prefix; + } + + async set(data: APIApplicationCommandPermission, guildId: string) { + const rData = this.apiToR(data, guildId); + if (!rData) return false; + + await this.redis.set(`${this.key()}:${rData.guild_id}:${data.id}`, JSON.stringify(rData)); + + return true; + } + + get(id: string) { + return this.redis.get(`${this.key()}:${id}`).then((data) => this.stringToData(data)); + } + + del(id: string): Promise { + return this.redis + .keys(`${this.key()}:${id}`) + .then((keys) => (keys.length ? this.redis.del(keys) : 0)); + } + + apiToR(data: APIApplicationCommandPermission, guildId: string) { + const keysNotToCache = Object.keys(data).filter( + (key): key is keyof typeof data => !this.keys.includes(key), + ); + + const rData = structuredClone(data) as unknown as RCommandPermission; + rData.guild_id = guildId; + + keysNotToCache.forEach((k) => delete (rData as unknown as Record)[k as string]); + + return structuredClone(rData); + } +} diff --git a/src/BaseClient/Bot/Cache/emoji.ts b/src/BaseClient/Bot/Cache/emoji.ts new file mode 100644 index 00000000..b753073f --- /dev/null +++ b/src/BaseClient/Bot/Cache/emoji.ts @@ -0,0 +1,61 @@ +import type { APIEmoji } from 'discord.js'; +import type Redis from 'ioredis'; +import Cache from './base.js'; + +export type REmoji = Omit & { user_id: string | null; guild_id: string }; + +export const REmojiKeys = [ + 'id', + 'name', + 'animated', + 'roles', + 'user_id', + 'require_colons', + 'managed', + 'guild_id', +] as const; + +export default class EmojiCache extends Cache { + public keys = REmojiKeys; + + constructor(prefix: string, redis: Redis) { + super(`${prefix}:emojis`, redis); + } + + key() { + return this.prefix; + } + + async set(data: APIEmoji, guildId: string) { + const rData = this.apiToR(data, guildId); + if (!rData) return false; + + await this.redis.set(`${this.key()}:${rData.guild_id}:${data.id}`, JSON.stringify(rData)); + + return true; + } + + get(id: string) { + return this.redis.get(`${this.key()}:${id}`).then((data) => this.stringToData(data)); + } + + del(id: string): Promise { + return this.redis + .keys(`${this.key()}:${id}`) + .then((keys) => (keys.length ? this.redis.del(keys) : 0)); + } + + apiToR(data: APIEmoji, guildId: string) { + const keysNotToCache = Object.keys(data).filter( + (key): key is keyof typeof data => !this.keys.includes(key), + ); + + const rData = structuredClone(data) as unknown as REmoji; + rData.guild_id = guildId; + rData.user_id = data.user?.id || null; + + keysNotToCache.forEach((k) => delete (rData as Record)[k as string]); + + return rData as REmoji; + } +} diff --git a/src/BaseClient/Bot/Cache/event.ts b/src/BaseClient/Bot/Cache/event.ts new file mode 100644 index 00000000..8997d82d --- /dev/null +++ b/src/BaseClient/Bot/Cache/event.ts @@ -0,0 +1,77 @@ +import type { APIGuildScheduledEvent } from 'discord.js'; +import type Redis from 'ioredis'; +import Cache from './base.js'; + +export type REvent = Omit & { + image_url: string | null; + creator_id: string | null; +}; + +export const REventKeys = [ + 'channel_id', + 'entity_metadata', + 'id', + 'guild_id', + 'creator_id', + 'name', + 'description', + 'scheduled_start_time', + 'scheduled_end_time', + 'privacy_level', + 'entity_type', + 'entity_id', + 'entity_metadata', + 'user_count', + 'image_url', + 'recurrence_rule', + 'status', +] as const; + +export default class EventCache extends Cache { + public keys = REventKeys; + + public static getImageUrl(hash: string, guildId: string) { + return `https://cdn.discordapp.com/guild-events/${guildId}/${hash}.${hash.startsWith('a_') ? 'gif' : 'webp'}`; + } + + constructor(prefix: string, redis: Redis) { + super(`${prefix}:events`, redis); + } + + key() { + return this.prefix; + } + + async set(data: APIGuildScheduledEvent) { + const rData = this.apiToR(data); + if (!rData) return false; + + await this.redis.set(`${this.key()}:${data.guild_id}:${data.id}`, JSON.stringify(rData)); + + return true; + } + + get(id: string) { + return this.redis.get(`${this.key()}:${id}`).then((data) => this.stringToData(data)); + } + + del(id: string): Promise { + return this.redis + .keys(`${this.key()}:${id}`) + .then((keys) => (keys.length ? this.redis.del(keys) : 0)); + } + + apiToR(data: APIGuildScheduledEvent) { + const keysNotToCache = Object.keys(data).filter( + (key): key is keyof typeof data => !this.keys.includes(key), + ); + + const rData = structuredClone(data) as unknown as REvent; + rData.image_url = data.image ? EventCache.getImageUrl(data.image, data.guild_id) : null; + rData.creator_id = data.creator?.id || null; + + keysNotToCache.forEach((k) => delete (rData as Record)[k as string]); + + return rData; + } +} diff --git a/src/BaseClient/Bot/Cache/guild.ts b/src/BaseClient/Bot/Cache/guild.ts new file mode 100644 index 00000000..d2628055 --- /dev/null +++ b/src/BaseClient/Bot/Cache/guild.ts @@ -0,0 +1,119 @@ +import type { APIGuild } from 'discord.js'; +import type Redis from 'ioredis'; +import Cache from './base.js'; + +export type RGuild = Omit< + APIGuild, + 'roles' | 'emojis' | 'stickers' | 'icon_hash' | 'discovery_splash' | 'banner' +> & { + roles: string[]; + emojis: string[]; + stickers: string[]; + icon_url: string | null; + discovery_splash_url: string | null; + banner_url: string | null; +}; + +export const RGuildKeys = [ + 'icon_url', + 'discovery_splash_url', + 'owner', + 'owner_id', + 'permissions', + 'region', + 'afk_channel_id', + 'afk_timeout', + 'widget_enabled', + 'widget_channel_id', + 'verification_level', + 'default_message_notifications', + 'explicit_content_filter', + 'roles', + 'emojis', + 'features', + 'mfa_level', + 'application_id', + 'system_channel_id', + 'system_channel_flags', + 'rules_channel_id', + 'max_presences', + 'max_members', + 'vanity_url_code', + 'description', + 'banner_url', + 'premium_tier', + 'premium_subscription_count', + 'preferred_locale', + 'public_updates_channel_id', + 'max_video_channel_users', + 'max_stage_video_channel_users', + 'approximate_member_count', + 'approximate_presence_count', + 'welcome_screen', + 'nsfw_level', + 'stickers', + 'premium_progress_bar_enabled', + 'hub_type', + 'safety_alerts_channel_id', +] as const; + +export default class GuildCache extends Cache { + public keys = RGuildKeys; + + public static getIconUrl(hash: string, guildId: string) { + return `https://cdn.discordapp.com/icons/${guildId}/${hash}.${hash.startsWith('a_') ? 'gif' : 'webp'}`; + } + + public static getPublicSplashUrl(hash: string, guildId: string) { + return `https://cdn.discordapp.com/discovery-splashes/${guildId}/${hash}.${hash.startsWith('a_') ? 'gif' : 'webp'}`; + } + + public static getBannerUrl(hash: string, guildId: string) { + return `https://cdn.discordapp.com/banners/${guildId}/${hash}.${hash.startsWith('a_') ? 'gif' : 'webp'}`; + } + + constructor(prefix: string, redis: Redis) { + super(`${prefix}:guilds`, redis); + } + + key() { + return this.prefix; + } + + async set(data: APIGuild) { + const rData = this.apiToR(data); + if (!rData) return false; + + await this.redis.set(`${this.key()}:${data.id}`, JSON.stringify(rData)); + + return true; + } + + get(id: string) { + return this.redis.get(`${this.key()}:${id}`).then((data) => this.stringToData(data)); + } + + del(id: string): Promise { + return this.redis.del(`${this.key()}:${id}`); + } + + apiToR(data: APIGuild) { + const keysNotToCache = Object.keys(data).filter( + (key): key is keyof typeof data => !this.keys.includes(key), + ); + + const rData = structuredClone(data) as unknown as RGuild; + rData.roles = data.roles.map((r) => r.id); + rData.emojis = data.emojis.map((e) => e.id).filter((e): e is string => !!e); + rData.stickers = data.stickers.map((s) => s.id); + rData.icon_url = data.icon ? GuildCache.getIconUrl(data.icon, data.id) : null; + rData.discovery_splash_url = data.discovery_splash + ? GuildCache.getPublicSplashUrl(data.discovery_splash, data.id) + : null; + rData.banner_url = data.banner ? GuildCache.getBannerUrl(data.banner, data.id) : null; + + keysNotToCache.forEach((k) => delete (rData as Record)[k as string]); + + return rData; + } +} diff --git a/src/BaseClient/Bot/Cache/guildCommand.ts b/src/BaseClient/Bot/Cache/guildCommand.ts new file mode 100644 index 00000000..12281d89 --- /dev/null +++ b/src/BaseClient/Bot/Cache/guildCommand.ts @@ -0,0 +1,73 @@ +import type { APIApplicationCommand } from 'discord.js'; +import type Redis from 'ioredis'; +import type { MakeRequired } from 'src/Typings/Typings'; +import Cache from './base.js'; + +export type RGuildCommand = MakeRequired; + +export const RGuildCommandKeys = [ + 'id', + 'type', + 'application_id', + 'name', + 'name_localizations', + 'name_localized', + 'description', + 'description_localizations', + 'description_localized', + 'options', + 'default_member_permissions', + 'dm_permission', + 'default_permission', + 'nsfw', + 'integration_types', + 'contexts', + 'version', + 'handler', + 'guild_id', +] as const; + +export default class GuildCommandCache extends Cache< + APIApplicationCommand & { guild_id: string }, + true +> { + public keys = RGuildCommandKeys; + + constructor(prefix: string, redis: Redis) { + super(`${prefix}:commands`, redis); + } + + key() { + return this.prefix; + } + + async set(data: APIApplicationCommand & { guild_id: string }) { + const rData = this.apiToR(data); + if (!rData) return false; + + await this.redis.set(`${this.key()}:${data.guild_id}:${data.id}`, JSON.stringify(rData)); + + return true; + } + + get(gId: string, id: string) { + return this.redis.get(`${this.key()}:${gId}:${id}`).then((data) => this.stringToData(data)); + } + + del(id: string): Promise { + return this.redis + .keys(`${this.key()}:${id}`) + .then((keys) => (keys.length ? this.redis.del(keys) : 0)); + } + + // eslint-disable-next-line class-methods-use-this + apiToR(data: APIApplicationCommand & { guild_id: string }) { + if (!data.guild_id) return false; + const keysNotToCache = Object.keys(data).filter( + (key): key is keyof typeof data => !this.keys.includes(key), + ); + keysNotToCache.forEach((k) => delete data[k]); + + return structuredClone(data) as unknown as RGuildCommand; + } +} diff --git a/src/BaseClient/Bot/Cache/integration.ts b/src/BaseClient/Bot/Cache/integration.ts new file mode 100644 index 00000000..b485bc94 --- /dev/null +++ b/src/BaseClient/Bot/Cache/integration.ts @@ -0,0 +1,72 @@ +import type { APIGuildIntegration } from 'discord.js'; +import type Redis from 'ioredis'; +import Cache from './base.js'; + +export type RIntegration = Omit & { + user_id: string | null; + guild_id: string; +}; + +export const RIntegrationKeys = [ + 'id', + 'name', + 'type', + 'enabled', + 'syncing', + 'role_id', + 'enable_emoticons', + 'expire_behavior', + 'expire_grace_period', + 'user_id', + 'account', + 'synced_at', + 'scopes', + 'guild_id', +] as const; + +export default class IntegrationCache extends Cache< + APIGuildIntegration & { user_id: string; guild_id: string } +> { + public keys: ReadonlyArray = RIntegrationKeys; + + constructor(prefix: string, redis: Redis) { + super(`${prefix}:integrations`, redis); + } + + key() { + return this.prefix; + } + + async set(data: APIGuildIntegration, guildId: string) { + const rData = this.apiToR(data, guildId); + if (!rData) return false; + + await this.redis.set(`${this.key()}:${rData.guild_id}:${data.id}`, JSON.stringify(rData)); + + return true; + } + + get(id: string) { + return this.redis.get(`${this.key()}:${id}`).then((data) => this.stringToData(data)); + } + + del(id: string): Promise { + return this.redis + .keys(`${this.key()}:${id}`) + .then((keys) => (keys.length ? this.redis.del(keys) : 0)); + } + + apiToR(data: APIGuildIntegration, guildId: string) { + const keysNotToCache = Object.keys(data).filter( + (key): key is keyof typeof data => !this.keys.includes(key), + ); + + const rData = structuredClone(data) as unknown as RIntegration; + rData.guild_id = guildId; + rData.user_id = data.user?.id || null; + + keysNotToCache.forEach((k) => delete (rData as Record)[k as string]); + + return rData; + } +} diff --git a/src/BaseClient/Bot/Cache/invite.ts b/src/BaseClient/Bot/Cache/invite.ts new file mode 100644 index 00000000..7dcade2a --- /dev/null +++ b/src/BaseClient/Bot/Cache/invite.ts @@ -0,0 +1,80 @@ +import type { APIInvite } from 'discord.js'; +import type Redis from 'ioredis'; +import Cache from './base.js'; + +export type RInvite = Omit< + APIInvite, + 'guild' | 'channel' | 'inviter' | 'target_user' | 'guild_scheduled_event' | 'stage_instance' +> & { + guild_id: string; + channel_id: string | null; + inviter_id: string | null; + target_user_id: string | null; + guild_scheduled_event_id: string | null; + application_id: string | null; +}; + +export const RInviteKeys = [ + 'code', + 'target_type', + 'approximate_presence_count', + 'approximate_member_count', + 'expires_at', + 'type', + 'guild_id', + 'channel_id', + 'inviter_id', + 'target_user_id', + 'guild_scheduled_event_id', + 'application_id', +] as const; + +export default class InviteCache extends Cache { + public keys = RInviteKeys; + + constructor(prefix: string, redis: Redis) { + super(`${prefix}:invites`, redis); + } + + key() { + return this.prefix; + } + + async set(data: APIInvite) { + const rData = this.apiToR(data); + if (!rData) return false; + + await this.redis.set(`${this.key()}:${rData.guild_id}:${data.code}`, JSON.stringify(rData)); + + return true; + } + + get(code: string) { + return this.redis.get(`${this.key()}:${code}`).then((data) => this.stringToData(data)); + } + + del(code: string): Promise { + return this.redis + .keys(`${this.key()}:${code}`) + .then((keys) => (keys.length ? this.redis.del(keys) : 0)); + } + + apiToR(data: APIInvite) { + if (!data.guild) return false; + + const keysNotToCache = Object.keys(data).filter( + (key): key is keyof typeof data => !this.keys.includes(key), + ); + + const rData = structuredClone(data) as unknown as RInvite; + rData.guild_id = data.guild.id; + rData.channel_id = data.channel?.id || null; + rData.inviter_id = data.inviter?.id || null; + rData.guild_scheduled_event_id = data.guild_scheduled_event?.id || null; + rData.application_id = data.target_application?.id || null; + + keysNotToCache.forEach((k) => delete (rData as Record)[k as string]); + + return rData; + } +} diff --git a/src/BaseClient/Bot/Cache/member.ts b/src/BaseClient/Bot/Cache/member.ts new file mode 100644 index 00000000..a3c61116 --- /dev/null +++ b/src/BaseClient/Bot/Cache/member.ts @@ -0,0 +1,80 @@ +import type { APIGuildMember } from 'discord.js'; +import type Redis from 'ioredis'; +import Cache from './base.js'; + +export type RMember = Omit & { + user_id: string; + guild_id: string; + avatar_url: string | null; + banner_url: string | null; +}; + +export const RMemberKeys = [ + 'user_id', + 'nick', + 'avatar_url', + 'banner_url', + 'roles', + 'joined_at', + 'premium_since', + 'deaf', + 'mute', + 'flags', + 'pending', + 'communication_disabled_until', + 'avatar_decoration_data', + 'guild_id', +] as const; + +export default class MemberCache extends Cache { + public keys = RMemberKeys; + + constructor(prefix: string, redis: Redis) { + super(`${prefix}:members`, redis); + } + + public static bannerUrl(banner: string, userId: string, guildId: string) { + return `https://cdn.discordapp.com/guilds/${guildId}/users/${userId}/banners/${banner}.${banner.startsWith('a_') ? 'gif' : 'webp'}`; + } + + public static avatarUrl(avatar: string, userId: string, guildId: string) { + return `https://cdn.discordapp.com/guilds/${guildId}/users/${userId}/avatars/${avatar}.${avatar.startsWith('a_') ? 'gif' : 'webp'}`; + } + + key() { + return this.prefix; + } + + async set(data: APIGuildMember, guildId: string) { + const rData = this.apiToR(data, guildId); + if (!rData) return false; + + await this.redis.set(`${this.key()}:${rData.guild_id}:${rData.user_id}`, JSON.stringify(rData)); + + return true; + } + + get(gId: string, id: string) { + return this.redis.get(`${this.key()}:${gId}:${id}`).then((data) => this.stringToData(data)); + } + + del(gId: string, id: string): Promise { + return this.redis.del(`${this.key()}:${gId}:${id}`); + } + + apiToR(data: APIGuildMember, guildId: string) { + const keysNotToCache = Object.keys(data).filter( + (key): key is keyof typeof data => !this.keys.includes(key), + ); + + const rData = structuredClone(data) as unknown as RMember; + rData.guild_id = guildId; + rData.user_id = data.user.id; + rData.avatar_url = data.avatar ? MemberCache.avatarUrl(data.avatar, data.user.id, guildId) : null; + rData.banner_url = data.banner ? MemberCache.bannerUrl(data.banner, data.user.id, guildId) : null; + + keysNotToCache.forEach((k) => delete (rData as Record)[k as string]); + + return rData; + } +} diff --git a/src/BaseClient/Bot/Cache/message.ts b/src/BaseClient/Bot/Cache/message.ts new file mode 100644 index 00000000..2026915b --- /dev/null +++ b/src/BaseClient/Bot/Cache/message.ts @@ -0,0 +1,114 @@ +import type { APIMessage, APIMessageSnapshotFields } from 'discord.js'; +import type Redis from 'ioredis'; +import Cache from './base.js'; + +export type RMessage = Omit< + APIMessage, + | 'application' + | 'author' + | 'mentions' + | 'thread' + | 'resolved' + | 'message_snapshots' + | 'stickers' + | 'application_id' +> & { + author_id: string; + guild_id: string; + application_id: string | null; + mention_users: string[]; + thread_id: string | null; + message_snapshots: Omit[] | null; +}; + +export const RMessageKeys = [ + 'id', + 'channel_id', + 'author_id', + 'guild_id', + 'content', + 'timestamp', + 'edited_timestamp', + 'tts', + 'mention_everyone', + 'mention_users', + 'mention_roles', + 'mention_channels', + 'attachments', + 'reactions', + 'embeds', + 'pinned', + 'webhook_id', + 'type', + 'activity', + 'application_id', + 'message_reference', + 'flags', + 'referenced_message', + 'thread_id', + 'components', + 'sticker_items', + 'position', + 'role_subscription_data', + 'poll', + 'message_snapshots', +] as const; + +export default class MessageCache extends Cache { + public keys = RMessageKeys; + + constructor(prefix: string, redis: Redis) { + super(`${prefix}:messages`, redis); + } + + key() { + return this.prefix; + } + + async set(data: APIMessage, guildId: string) { + const rData = this.apiToR(data, guildId); + if (!rData) return false; + + const key = `${this.key()}:${rData.guild_id}:${data.channel_id}:${data.id}`; + const exists = await this.redis.exists(key); + + if (exists) await this.redis.set(key, JSON.stringify(rData), 'KEEPTTL'); + else await this.redis.setex(key, 1209600, JSON.stringify(rData)); + + return true; + } + + get(id: string) { + return this.redis.get(`${this.key()}:${id}`).then((data) => this.stringToData(data)); + } + + del(id: string): Promise { + return this.redis + .keys(`${this.key()}:${id}`) + .then((keys) => (keys.length ? this.redis.del(keys) : 0)); + } + + apiToR(data: APIMessage, guildId: string) { + const keysNotToCache = Object.keys(data).filter( + (key): key is keyof typeof data => !this.keys.includes(key), + ); + + const rData = structuredClone(data) as unknown as RMessage; + rData.guild_id = guildId; + rData.author_id = data.author.id; + rData.application_id = data.application_id || data.application?.id || null; + rData.mention_users = data.mentions.map((u) => u.id); + rData.thread_id = data.thread?.id || null; + rData.message_snapshots = + data.message_snapshots?.map((s) => { + const rS = structuredClone(s) as unknown as NonNullable[number]; + delete (rS as APIMessage).stickers; + + return rS; + }) || null; + + keysNotToCache.forEach((k) => delete (rData as Record)[k as string]); + + return rData; + } +} diff --git a/src/BaseClient/Bot/Cache/reaction.ts b/src/BaseClient/Bot/Cache/reaction.ts new file mode 100644 index 00000000..fdc7a597 --- /dev/null +++ b/src/BaseClient/Bot/Cache/reaction.ts @@ -0,0 +1,65 @@ +import type { APIReaction } from 'discord.js'; +import type Redis from 'ioredis'; +import Cache from './base.js'; + +export type RReaction = APIReaction & { guild_id: string; channe_id: string; message_id: string }; + +export const RReactionKeys = [ + 'count', + 'count_details', + 'me', + 'me_burst', + 'emoji', + 'burst_colors', + 'guild_id', + 'message_id', +] as const; + +export default class ReactionCache extends Cache { + public keys = RReactionKeys; + + constructor(prefix: string, redis: Redis) { + super(`${prefix}:reactions`, redis); + } + + key() { + return this.prefix; + } + + async set(data: APIReaction, guildId: string, channelId: string, messageId: string) { + const rData = this.apiToR(data, guildId, channelId, messageId); + if (!rData) return false; + + await this.redis.set( + `${this.key()}:${rData.guild_id}:${rData.message_id}:${data.emoji.id || data.emoji.name}`, + JSON.stringify(rData), + ); + + return true; + } + + get(mId: string, eId: string) { + return this.redis.get(`${this.key()}:${mId}:${eId}`).then((data) => this.stringToData(data)); + } + + del(mId: string, eId: string): Promise { + return this.redis + .keys(`${this.key()}:${mId}:${eId}`) + .then((keys) => (keys.length ? this.redis.del(keys) : 0)); + } + + apiToR(data: APIReaction, guildId: string, channelId: string, messageId: string) { + const keysNotToCache = Object.keys(data).filter( + (key): key is keyof typeof data => !this.keys.includes(key), + ); + + const rData = structuredClone(data) as unknown as RReaction; + rData.guild_id = guildId; + rData.channe_id = channelId; + rData.message_id = messageId; + + keysNotToCache.forEach((k) => delete (rData as unknown as Record)[k as string]); + + return rData; + } +} diff --git a/src/BaseClient/Bot/Cache/role.ts b/src/BaseClient/Bot/Cache/role.ts new file mode 100644 index 00000000..dbcba08c --- /dev/null +++ b/src/BaseClient/Bot/Cache/role.ts @@ -0,0 +1,70 @@ +import type { APIRole } from 'discord.js'; +import type Redis from 'ioredis'; +import Cache from './base.js'; + +export type RRole = Omit & { icon_url: string | null; guild_id: string }; + +export const RRoleKeys = [ + 'id', + 'name', + 'color', + 'hoist', + 'icon_url', + 'unicode_emoji', + 'position', + 'permissions', + 'managed', + 'mentionable', + 'tags', + 'flags', + 'guild_id', +] as const; + +export default class RoleCache extends Cache { + public keys = RRoleKeys; + + constructor(prefix: string, redis: Redis) { + super(`${prefix}:roles`, redis); + } + + public static iconUrl(icon: string, roleId: string) { + return `https://cdn.discordapp.com/role-icons/${roleId}/${icon}.${icon.startsWith('a_') ? 'gif' : 'webp'}`; + } + + key() { + return this.prefix; + } + + async set(data: APIRole, guildId: string) { + const rData = this.apiToR(data, guildId); + if (!rData) return false; + + await this.redis.set(`${this.key()}:${rData.guild_id}:${data.id}`, JSON.stringify(rData)); + + return true; + } + + get(id: string) { + return this.redis.get(`${this.key()}:${id}`).then((data) => this.stringToData(data)); + } + + del(id: string): Promise { + return this.redis + .keys(`${this.key()}:${id}`) + .then((keys) => (keys.length ? this.redis.del(keys) : 0)); + } + + apiToR(data: APIRole, guildId: string) { + const keysNotToCache = Object.keys(data).filter( + (key): key is keyof typeof data => !this.keys.includes(key), + ); + + const rData = structuredClone(data) as unknown as RRole; + rData.guild_id = guildId; + rData.icon_url = data.icon ? RoleCache.iconUrl(data.icon, guildId) : null; + + keysNotToCache.forEach((k) => delete (rData as Record)[k as string]); + + return rData; + } +} diff --git a/src/BaseClient/Bot/Cache/soundboard.ts b/src/BaseClient/Bot/Cache/soundboard.ts new file mode 100644 index 00000000..9d129c3e --- /dev/null +++ b/src/BaseClient/Bot/Cache/soundboard.ts @@ -0,0 +1,60 @@ +import type { APISoundboardSound } from 'discord.js'; +import type Redis from 'ioredis'; +import Cache from './base.js'; + +export type RSoundboardSound = Omit & { user_id: string | null }; + +export const RSoundboardSoundKeys = [ + 'name', + 'sound_id', + 'volume', + 'emoji_id', + 'emoji_name', + 'guild_id', + 'available', + 'user_id', +] as const; + +export default class SoundboardCache extends Cache { + public keys = RSoundboardSoundKeys; + + constructor(prefix: string, redis: Redis) { + super(`${prefix}:soundboards`, redis); + } + + key() { + return this.prefix; + } + + async set(data: APISoundboardSound) { + const rData = this.apiToR(data); + if (!rData) return false; + + await this.redis.set(`${this.key()}:${rData.guild_id}:${data.sound_id}`, JSON.stringify(rData)); + + return true; + } + + get(id: string) { + return this.redis.get(`${this.key()}:${id}`).then((data) => this.stringToData(data)); + } + + del(id: string): Promise { + return this.redis + .keys(`${this.key()}:${id}`) + .then((keys) => (keys.length ? this.redis.del(keys) : 0)); + } + + apiToR(data: APISoundboardSound) { + const keysNotToCache = Object.keys(data).filter( + (key): key is keyof typeof data => !this.keys.includes(key), + ); + + const rData = structuredClone(data) as unknown as RSoundboardSound; + rData.user_id = data.user?.id || null; + + keysNotToCache.forEach((k) => delete (rData as Record)[k as string]); + + return rData; + } +} diff --git a/src/BaseClient/Bot/Cache/stage.ts b/src/BaseClient/Bot/Cache/stage.ts new file mode 100644 index 00000000..4592b694 --- /dev/null +++ b/src/BaseClient/Bot/Cache/stage.ts @@ -0,0 +1,56 @@ +import type { APIStageInstance } from 'discord.js'; +import type Redis from 'ioredis'; +import Cache from './base.js'; + +export type RStageInstance = APIStageInstance; + +export const RStageInstanceKeys = [ + 'id', + 'guild_id', + 'channel_id', + 'topic', + 'privacy_level', + 'discoverable_disabled', + 'guild_scheduled_event_id', +] as const; + +export default class StageCache extends Cache { + public keys = RStageInstanceKeys; + + constructor(prefix: string, redis: Redis) { + super(`${prefix}:stages`, redis); + } + + key() { + return this.prefix; + } + + async set(data: APIStageInstance) { + const rData = this.apiToR(data); + if (!rData) return false; + + await this.redis.set(`${this.key()}:${data.guild_id}:${data.id}`, JSON.stringify(rData)); + + return true; + } + + get(id: string) { + return this.redis.get(`${this.key()}:${id}`).then((data) => this.stringToData(data)); + } + + del(id: string): Promise { + return this.redis + .keys(`${this.key()}:${id}`) + .then((keys) => (keys.length ? this.redis.del(keys) : 0)); + } + + apiToR(data: APIStageInstance) { + const keysNotToCache = Object.keys(data).filter( + (key): key is keyof typeof data => !this.keys.includes(key), + ); + + keysNotToCache.forEach((k) => delete data[k]); + + return structuredClone(data); + } +} diff --git a/src/BaseClient/Bot/Cache/sticker.ts b/src/BaseClient/Bot/Cache/sticker.ts new file mode 100644 index 00000000..e9619919 --- /dev/null +++ b/src/BaseClient/Bot/Cache/sticker.ts @@ -0,0 +1,63 @@ +import type { APISticker } from 'discord.js'; +import type Redis from 'ioredis'; +import type { MakeRequired } from 'src/Typings/Typings'; +import Cache from './base.js'; + +export type RSticker = MakeRequired & { user_id: string | null }; + +export const RStickerKeys = [ + 'id', + 'pack_id', + 'name', + 'description', + 'tags', + 'type', + 'format_type', + 'available', + 'guild_id', + 'sort_value', +] as const; + +export default class StickerCache extends Cache { + public keys = RStickerKeys; + + constructor(prefix: string, redis: Redis) { + super(`${prefix}:stickers`, redis); + } + + key() { + return this.prefix; + } + + async set(data: APISticker) { + const rData = this.apiToR(data); + if (!rData) return false; + + await this.redis.set(`${this.key()}:${data.guild_id}:${data.id}`, JSON.stringify(rData)); + + return true; + } + + get(id: string) { + return this.redis.get(`${this.key()}:${id}`).then((data) => this.stringToData(data)); + } + + del(id: string): Promise { + return this.redis + .keys(`${this.key()}:${id}`) + .then((keys) => (keys.length ? this.redis.del(keys) : 0)); + } + + apiToR(data: APISticker) { + const keysNotToCache = Object.keys(data).filter( + (key): key is keyof typeof data => !this.keys.includes(key), + ); + + const rData = structuredClone(data) as unknown as RSticker; + rData.user_id = data.user?.id || null; + + keysNotToCache.forEach((k) => delete (rData as unknown as Record)[k as string]); + + return rData; + } +} diff --git a/src/BaseClient/Bot/Cache/thread.ts b/src/BaseClient/Bot/Cache/thread.ts new file mode 100644 index 00000000..fca3fdd6 --- /dev/null +++ b/src/BaseClient/Bot/Cache/thread.ts @@ -0,0 +1,89 @@ +import type { APIThreadChannel } from 'discord.js'; +import type Redis from 'ioredis'; +import Cache from './base.js'; + +export type RThread = Pick< + APIThreadChannel, + | 'id' + | 'name' + | 'type' + | 'flags' + | 'last_message_id' + | 'last_pin_timestamp' + | 'rate_limit_per_user' + | 'owner_id' + | 'thread_metadata' + | 'message_count' + | 'member_count' + | 'total_message_sent' + | 'applied_tags' +> & { + guild_id: string; + member_id: string | null; +}; + +export const RThreadKeys = [ + 'id', + 'name', + 'type', + 'flags', + 'last_message_id', + 'last_pin_timestamp', + 'rate_limit_per_user', + 'owner_id', + 'thread_metadata', + 'message_count', + 'member_count', + 'total_message_sent', + 'applied_tags', + 'guild_id', + 'member_id', +] as const; + +export default class ThreadCache extends Cache< + APIThreadChannel & { guild_id: string; member_id: string }, + true +> { + public keys = RThreadKeys; + + constructor(prefix: string, redis: Redis) { + super(`${prefix}:threads`, redis); + } + + key() { + return this.prefix; + } + + async set(data: Omit) { + const rData = this.apiToR(data); + if (!rData) return false; + + await this.redis.set(`${this.key()}:${data.guild_id}:${data.id}`, JSON.stringify(rData)); + + return true; + } + + get(id: string) { + return this.redis.get(`${this.key()}:${id}`).then((data) => this.stringToData(data)); + } + + del(id: string): Promise { + return this.redis + .keys(`${this.key()}:${id}`) + .then((keys) => (keys.length ? this.redis.del(keys) : 0)); + } + + apiToR(data: Omit) { + if (!data.guild_id) return false; + const keysNotToCache = Object.keys(data).filter( + (key): key is keyof typeof data => !this.keys.includes(key), + ); + + const rData = structuredClone(data) as unknown as RThread; + rData.member_id = data.member?.id || null; + + keysNotToCache.forEach((k) => delete (rData as Record)[k as string]); + + return rData; + } +} diff --git a/src/BaseClient/Bot/Cache/threadMember.ts b/src/BaseClient/Bot/Cache/threadMember.ts new file mode 100644 index 00000000..b9a3caf2 --- /dev/null +++ b/src/BaseClient/Bot/Cache/threadMember.ts @@ -0,0 +1,62 @@ +import type { APIThreadMember } from 'discord.js'; +import type Redis from 'ioredis'; +import Cache from './base.js'; + +export type RThreadMember = Omit & { + guild_id: string; + user_id: string; + id: string; +}; + +export const RThreadMemberKeys = ['id', 'user_id', 'join_timestamp', 'flags', 'guild_id'] as const; + +export default class ThreadMemberCache extends Cache { + public keys = RThreadMemberKeys; + + constructor(prefix: string, redis: Redis) { + super(`${prefix}:threadMembers`, redis); + } + + key() { + return this.prefix; + } + + async set(data: APIThreadMember, guildId: string) { + const rData = this.apiToR(data, guildId); + if (!rData) return false; + + await this.redis.set( + `${this.key()}:${rData.guild_id}:${data.id}:${data.user_id}`, + JSON.stringify(rData), + ); + + return true; + } + + get(tId: string, id: string) { + return this.redis.get(`${this.key()}:${tId}:${id}`).then((data) => this.stringToData(data)); + } + + del(tId: string, id: string): Promise { + return this.redis + .keys(`${this.key()}:${tId}:${id}`) + .then((keys) => (keys.length ? this.redis.del(keys) : 0)); + } + + apiToR(data: APIThreadMember, guildId: string) { + if (!data.member?.user.id && !data.user_id) return false; + if (!data.id) return false; + + const keysNotToCache = Object.keys(data).filter( + (key): key is keyof typeof data => !this.keys.includes(key), + ); + + const rData = structuredClone(data) as unknown as RThreadMember; + rData.user_id = (data.member?.user.id || data.user_id)!; + rData.guild_id = guildId; + + keysNotToCache.forEach((k) => delete (rData as Record)[k as string]); + + return rData; + } +} diff --git a/src/BaseClient/Bot/Cache/user.ts b/src/BaseClient/Bot/Cache/user.ts new file mode 100644 index 00000000..d25b280d --- /dev/null +++ b/src/BaseClient/Bot/Cache/user.ts @@ -0,0 +1,94 @@ +import type { APIUser } from 'discord.js'; +import type Redis from 'ioredis'; +import Cache from './base.js'; + +export type RUser = Omit & { + avatar_decoration_data?: { asset_url: string; sku_id: string }; + avatar_url: string | null; + banner_url: string | null; +}; + +const RUserKeys = [ + 'id', + 'username', + 'discriminator', + 'global_name', + 'avatar_url', + 'bot', + 'system', + 'mfa_enabled', + 'banner_url', + 'accent_color', + 'locale', + 'verified', + 'flags', + 'premium_type', + 'public_flags', + 'avatar_decoration', + 'avatar_decoration_data', +] as const; + +export default class UserCache extends Cache { + public keys = RUserKeys; + + public extraKeys = { + avatar_decoration_data: ['asset_url', 'sku_id'] as const, + }; + + constructor(prefix: string, redis: Redis) { + super(`${prefix}:users`, redis); + } + + public static assetUrl(asset: string) { + return `https://cdn.discordapp.com/avatar-decoration-presets/${asset}.png`; + } + + public static avatarUrl(avatar: string, userId: string) { + return `https://cdn.discordapp.com/avatars/${userId}/${avatar}.${avatar.startsWith('a_') ? 'gif' : 'webp'}`; + } + + public static bannerUrl(banner: string, userId: string) { + return `https://cdn.discordapp.com/banners/${userId}/${banner}.${banner.startsWith('a_') ? 'gif' : 'webp'}`; + } + + key() { + return this.prefix; + } + + async set(data: APIUser) { + const rData = this.apiToR(data); + if (!rData) return false; + + await this.redis.set(`${this.key()}:${data.id}`, JSON.stringify(rData)); + + return true; + } + + get(id: string) { + return this.redis.get(`${this.key()}:${id}`).then((data) => this.stringToData(data)); + } + + del(id: string): Promise { + return this.redis.del(`${this.key()}:${id}`); + } + + apiToR(data: APIUser) { + const keysNotToCache = Object.keys(data).filter( + (key): key is keyof typeof data => !this.keys.includes(key), + ); + + const rData = structuredClone(data) as unknown as RUser; + rData.avatar_decoration_data = data.avatar_decoration_data + ? { + asset_url: UserCache.assetUrl(data.avatar_decoration_data.asset), + sku_id: data.avatar_decoration_data.sku_id, + } + : undefined; + rData.avatar_url = data.avatar ? UserCache.avatarUrl(data.avatar, data.id) : null; + rData.banner_url = data.banner ? UserCache.bannerUrl(data.banner, data.id) : null; + + keysNotToCache.forEach((k) => delete (rData as Record)[k as string]); + + return rData; + } +} diff --git a/src/BaseClient/Bot/Cache/voice.ts b/src/BaseClient/Bot/Cache/voice.ts new file mode 100644 index 00000000..939730d9 --- /dev/null +++ b/src/BaseClient/Bot/Cache/voice.ts @@ -0,0 +1,62 @@ +import type { APIVoiceState } from 'discord.js'; +import type Redis from 'ioredis'; +import type { MakeRequired } from 'src/Typings/Typings'; +import Cache from './base.js'; + +export type RVoiceState = MakeRequired, 'guild_id'>; + +export const RVoiceStateKeys = [ + 'guild_id', + 'channel_id', + 'user_id', + 'session_id', + 'deaf', + 'mute', + 'self_deaf', + 'self_mute', + 'self_stream', + 'self_video', + 'suppress', + 'request_to_speak_timestamp', +] as const; + +export default class VoiceCache extends Cache { + public keys = RVoiceStateKeys; + + constructor(prefix: string, redis: Redis) { + super(`${prefix}:voices`, redis); + } + + key() { + return this.prefix; + } + + async set(data: APIVoiceState) { + const rData = this.apiToR(data); + if (!rData) return false; + + await this.redis.set(`${this.key()}:${data.guild_id}:${data.user_id}`, JSON.stringify(rData)); + + return true; + } + + get(gId: string, id: string) { + return this.redis.get(`${this.key()}:${gId}:${id}`).then((data) => this.stringToData(data)); + } + + del(gId: string, id: string): Promise { + return this.redis.del(`${this.key()}:${gId}:${id}`); + } + + apiToR(data: APIVoiceState) { + if (!data.guild_id) return false; + + const keysNotToCache = Object.keys(data).filter( + (key): key is keyof typeof data => !this.keys.includes(key), + ); + + keysNotToCache.forEach((k) => delete data[k]); + + return structuredClone(data) as RVoiceState; + } +} diff --git a/src/BaseClient/Bot/Cache/webhook.ts b/src/BaseClient/Bot/Cache/webhook.ts new file mode 100644 index 00000000..41bf43a3 --- /dev/null +++ b/src/BaseClient/Bot/Cache/webhook.ts @@ -0,0 +1,77 @@ +import { type APIWebhook } from 'discord.js'; +import type Redis from 'ioredis'; +import Cache from './base.js'; + +export type RWebhook = Omit & { + user_id: string | null; + avatar_url: string | null; +}; + +export const RWebhookKeys = [ + 'id', + 'type', + 'guild_id', + 'channel_id', + 'user_id', + 'name', + 'avatar_url', + 'token', + 'application_id', + 'source_guild', + 'source_channel', + 'url', +] as const; + +export default class WebhookCache extends Cache< + APIWebhook & { user_id: string | null; avatar_url: string | null } +> { + public keys = RWebhookKeys; + + constructor(prefix: string, redis: Redis) { + super(`${prefix}:webhooks`, redis); + } + + public static avatarUrl(avatar: string, webhookId: string) { + return `https://cdn.discordapp.com/avatars/${webhookId}/${avatar}.${avatar.startsWith('a_') ? 'gif' : 'webp'}`; + } + + key() { + return this.prefix; + } + + async set(data: APIWebhook) { + const rData = this.apiToR(data); + if (!rData) return false; + + await this.redis.set( + `${this.key()}:${data.guild_id}:${data.channel_id}:${data.id}`, + JSON.stringify(rData), + ); + + return true; + } + + get(id: string) { + return this.redis.get(`${this.key()}:${id}`).then((data) => this.stringToData(data)); + } + + del(id: string): Promise { + return this.redis + .keys(`${this.key()}:${id}`) + .then((keys) => (keys.length ? this.redis.del(keys) : 0)); + } + + apiToR(data: APIWebhook) { + const keysNotToCache = Object.keys(data).filter( + (key): key is keyof typeof data => !this.keys.includes(key), + ); + + const rData = structuredClone(data) as unknown as RWebhook; + rData.user_id = data.user?.id || null; + rData.avatar_url = data.avatar ? WebhookCache.avatarUrl(data.avatar, data.id) : null; + + keysNotToCache.forEach((k) => delete (rData as Record)[k as string]); + + return rData; + } +} diff --git a/src/BaseClient/Bot/DataBase.ts b/src/BaseClient/Bot/DataBase.ts index 06a8e0ba..f18d857a 100644 --- a/src/BaseClient/Bot/DataBase.ts +++ b/src/BaseClient/Bot/DataBase.ts @@ -209,7 +209,7 @@ export const handleOperation = Redis.del(key)); + keys.forEach((key) => (key.length ? Redis.del(key) : 0)); return data.query(data.args); default: return data.query(data.args); diff --git a/src/BaseClient/Bot/Redis.ts b/src/BaseClient/Bot/Redis.ts index 830ae158..1c34a6aa 100644 --- a/src/BaseClient/Bot/Redis.ts +++ b/src/BaseClient/Bot/Redis.ts @@ -1,5 +1,58 @@ import Redis from 'ioredis'; -const client = new Redis({ host: 'redis' }); +import AutomodCache from './Cache/automod.js'; +import BanCache from './Cache/ban.js'; +import ChannelCache from './Cache/channel.js'; +import CommandCache from './Cache/command.js'; +import CommandPermissionCache from './Cache/commandPermission.js'; +import EmojiCache from './Cache/emoji.js'; +import EventCache from './Cache/event.js'; +import GuildCache from './Cache/guild.js'; +import GuildCommandCache from './Cache/guildCommand.js'; +import IntegrationCache from './Cache/integration.js'; +import InviteCache from './Cache/invite.js'; +import MemberCache from './Cache/member.js'; +import MessageCache from './Cache/message.js'; +import ReactionCache from './Cache/reaction.js'; +import RoleCache from './Cache/role.js'; +import SoundboardCache from './Cache/soundboard.js'; +import StageCache from './Cache/stage.js'; +import StickerCache from './Cache/sticker.js'; +import ThreadCache from './Cache/thread.js'; +import ThreadMemberCache from './Cache/threadMember.js'; +import UserCache from './Cache/user.js'; +import VoiceCache from './Cache/voice.js'; +import WebhookCache from './Cache/webhook.js'; -export default client; +const redis = new Redis({ host: 'redis' }); + +export default redis; + +export const prefix = `${process.env.mainId}:cache:${process.argv.includes('--dev') ? 'dev' : 'prod'}`; +await redis.keys(`${prefix}:*`).then((r) => (r.length ? redis.del(r) : 0)); + +export const cache = { + automods: new AutomodCache(prefix, redis), + bans: new BanCache(prefix, redis), + channels: new ChannelCache(prefix, redis), + commands: new CommandCache(prefix, redis), + commandPermissions: new CommandPermissionCache(prefix, redis), + emojis: new EmojiCache(prefix, redis), + events: new EventCache(prefix, redis), + guilds: new GuildCache(prefix, redis), + guildCommands: new GuildCommandCache(prefix, redis), + integrations: new IntegrationCache(prefix, redis), + invites: new InviteCache(prefix, redis), + members: new MemberCache(prefix, redis), + messages: new MessageCache(prefix, redis), + reactions: new ReactionCache(prefix, redis), + roles: new RoleCache(prefix, redis), + soundboards: new SoundboardCache(prefix, redis), + stages: new StageCache(prefix, redis), + stickers: new StickerCache(prefix, redis), + threads: new ThreadCache(prefix, redis), + threadMembers: new ThreadMemberCache(prefix, redis), + users: new UserCache(prefix, redis), + voices: new VoiceCache(prefix, redis), + webhooks: new WebhookCache(prefix, redis), +}; diff --git a/src/Events/BotEvents/raw/Cache/AutoModeration.ts b/src/Events/BotEvents/raw/Cache/AutoModeration.ts new file mode 100644 index 00000000..ec5107ac --- /dev/null +++ b/src/Events/BotEvents/raw/Cache/AutoModeration.ts @@ -0,0 +1,26 @@ +/* eslint-disable @typescript-eslint/no-unused-vars */ +import { + GatewayDispatchEvents, + type GatewayAutoModerationActionExecutionDispatchData, + type GatewayAutoModerationRuleCreateDispatchData, + type GatewayAutoModerationRuleUpdateDispatchData, +} from 'discord.js'; +import { cache as redis } from '../../../../BaseClient/Bot/Redis.js'; + +export default { + [GatewayDispatchEvents.AutoModerationActionExecution]: ( + _: GatewayAutoModerationActionExecutionDispatchData, + ) => undefined, + + [GatewayDispatchEvents.AutoModerationRuleCreate]: ( + data: GatewayAutoModerationRuleCreateDispatchData, + ) => redis.automods.set(data), + + [GatewayDispatchEvents.AutoModerationRuleDelete]: ( + data: GatewayAutoModerationRuleCreateDispatchData, + ) => redis.automods.del(data.id), + + [GatewayDispatchEvents.AutoModerationRuleUpdate]: ( + data: GatewayAutoModerationRuleUpdateDispatchData, + ) => redis.automods.set(data), +} as const; diff --git a/src/Events/BotEvents/raw/Cache/Channel.ts b/src/Events/BotEvents/raw/Cache/Channel.ts new file mode 100644 index 00000000..ba507042 --- /dev/null +++ b/src/Events/BotEvents/raw/Cache/Channel.ts @@ -0,0 +1,26 @@ +/* eslint-disable @typescript-eslint/no-unused-vars */ +import { + GatewayDispatchEvents, + type GatewayChannelCreateDispatchData, + type GatewayChannelDeleteDispatchData, + type GatewayChannelPinsUpdateDispatchData, + type GatewayChannelUpdateDispatchData, +} from 'discord.js'; +import RedisClient, { cache as redis } from '../../../../BaseClient/Bot/Redis.js'; + +export default { + [GatewayDispatchEvents.ChannelCreate]: (data: GatewayChannelCreateDispatchData) => + redis.channels.set(data), + + [GatewayDispatchEvents.ChannelDelete]: (data: GatewayChannelDeleteDispatchData) => { + redis.channels.del(data.id); + RedisClient.keys(`${redis.messages.key}:${data.guild_id}:${data.id}:*`).then((keys) => + keys.length ? RedisClient.del(keys) : 0, + ); + }, + + [GatewayDispatchEvents.ChannelPinsUpdate]: (_: GatewayChannelPinsUpdateDispatchData) => undefined, + + [GatewayDispatchEvents.ChannelUpdate]: (data: GatewayChannelUpdateDispatchData) => + redis.channels.set(data), +} as const; diff --git a/src/Events/BotEvents/raw/Cache/Entitlements.ts b/src/Events/BotEvents/raw/Cache/Entitlements.ts new file mode 100644 index 00000000..99d54464 --- /dev/null +++ b/src/Events/BotEvents/raw/Cache/Entitlements.ts @@ -0,0 +1,15 @@ +/* eslint-disable @typescript-eslint/no-unused-vars */ +import { + GatewayDispatchEvents, + type GatewayEntitlementCreateDispatchData, + type GatewayEntitlementDeleteDispatchData, + type GatewayEntitlementUpdateDispatchData, +} from 'discord.js'; + +export default { + [GatewayDispatchEvents.EntitlementCreate]: (_: GatewayEntitlementCreateDispatchData) => undefined, + + [GatewayDispatchEvents.EntitlementDelete]: (_: GatewayEntitlementDeleteDispatchData) => undefined, + + [GatewayDispatchEvents.EntitlementUpdate]: (_: GatewayEntitlementUpdateDispatchData) => undefined, +} as const; diff --git a/src/Events/BotEvents/raw/Cache/Guilds.ts b/src/Events/BotEvents/raw/Cache/Guilds.ts new file mode 100644 index 00000000..4f961ac1 --- /dev/null +++ b/src/Events/BotEvents/raw/Cache/Guilds.ts @@ -0,0 +1,198 @@ +/* eslint-disable @typescript-eslint/no-unused-vars */ +import { + GatewayDispatchEvents, + GuildMemberFlagsBitField, + type APIGuildMember, + type GatewayGuildAuditLogEntryCreateDispatchData, + type GatewayGuildBanAddDispatchData, + type GatewayGuildBanRemoveDispatchData, + type GatewayGuildCreateDispatchData, + type GatewayGuildDeleteDispatchData, + type GatewayGuildEmojisUpdateDispatchData, + type GatewayGuildIntegrationsUpdateDispatchData, + type GatewayGuildMemberAddDispatchData, + type GatewayGuildMemberRemoveDispatchData, + type GatewayGuildMembersChunkDispatchData, + type GatewayGuildMemberUpdateDispatchData, + type GatewayGuildRoleCreateDispatchData, + type GatewayGuildScheduledEventCreateDispatchData, + type GatewayGuildScheduledEventDeleteDispatchData, + type GatewayGuildScheduledEventUpdateDispatchData, + type GatewayGuildScheduledEventUserAddDispatchData, + type GatewayGuildScheduledEventUserRemoveDispatchData, + type GatewayGuildSoundboardSoundCreateDispatchData, + type GatewayGuildSoundboardSoundDeleteDispatchData, + type GatewayGuildSoundboardSoundsUpdateDispatchData, + type GatewayGuildSoundboardSoundUpdateDispatchData, + type GatewayGuildStickersUpdateDispatchData, + type GatewayGuildUpdateDispatchData, +} from 'discord.js'; +import RedisClient, { + cache as redis, + prefix as redisKey, +} from '../../../../BaseClient/Bot/Redis.js'; + +export default { + [GatewayDispatchEvents.GuildAuditLogEntryCreate]: ( + _: GatewayGuildAuditLogEntryCreateDispatchData, + ) => undefined, + + [GatewayDispatchEvents.GuildBanAdd]: (data: GatewayGuildBanAddDispatchData) => { + redis.bans.set({ reason: '-', user: data.user }, data.guild_id); + redis.users.set(data.user); + }, + [GatewayDispatchEvents.GuildBanRemove]: (data: GatewayGuildBanRemoveDispatchData) => { + redis.bans.del(data.guild_id, data.user.id); + redis.users.set(data.user); + }, + + [GatewayDispatchEvents.GuildCreate]: (data: GatewayGuildCreateDispatchData) => { + if (data.unavailable) return; + if ('geo_restricted' in data && data.geo_restricted) return; + + redis.guilds.set(data); + data.soundboard_sounds.forEach((sound) => redis.soundboards.set({ ...sound, guild_id: data.id })); + data.emojis.forEach((emoji) => redis.emojis.set(emoji, data.id)); + data.threads.forEach((thread) => redis.threads.set({ ...thread, guild_id: data.id })); + data.guild_scheduled_events.forEach((event) => redis.events.set(event)); + data.roles.forEach((role) => redis.roles.set(role, data.id)); + data.members.forEach((member) => redis.members.set(member, data.id)); + data.members.forEach((member) => redis.users.set(member.user)); + data.voice_states.forEach((voice) => redis.voices.set({ ...voice, guild_id: data.id })); + data.channels.forEach((channel) => redis.channels.set({ ...channel, guild_id: data.id })); + data.stickers.forEach((sticker) => redis.stickers.set({ ...sticker, guild_id: data.id })); + }, + + [GatewayDispatchEvents.GuildDelete]: (data: GatewayGuildDeleteDispatchData) => + RedisClient.keys(`${redisKey}:${data.id}:*`).then((r) => (r.length ? RedisClient.del(r) : 0)), + + [GatewayDispatchEvents.GuildUpdate]: (data: GatewayGuildUpdateDispatchData) => + redis.guilds.set(data), + + [GatewayDispatchEvents.GuildEmojisUpdate]: async (data: GatewayGuildEmojisUpdateDispatchData) => { + await RedisClient.keys(`${redis.emojis.key()}:${data.guild_id}:*`).then((r) => + r.length ? RedisClient.del(r) : 0, + ); + + data.emojis.forEach((emoji) => redis.emojis.set(emoji, data.guild_id)); + }, + + [GatewayDispatchEvents.GuildIntegrationsUpdate]: (_: GatewayGuildIntegrationsUpdateDispatchData) => + undefined, + + [GatewayDispatchEvents.GuildMemberAdd]: (data: GatewayGuildMemberAddDispatchData) => { + redis.members.set(data, data.guild_id); + + redis.users.set(data.user); + }, + + [GatewayDispatchEvents.GuildMemberRemove]: (data: GatewayGuildMemberRemoveDispatchData) => { + redis.members.del(data.guild_id, data.user.id); + + redis.users.set(data.user); + }, + + [GatewayDispatchEvents.GuildMembersChunk]: (data: GatewayGuildMembersChunkDispatchData) => + data.members.forEach((member) => redis.members.set(member, data.guild_id)), + + [GatewayDispatchEvents.GuildMemberUpdate]: async (data: GatewayGuildMemberUpdateDispatchData) => { + if (data.joined_at && data.deaf && data.mute) { + redis.members.set(data as Parameters[0], data.guild_id); + return; + } + + const member = await RedisClient.get( + `${redis.members.key()}:${data.guild_id}:${data.user.id}`, + ).then((r) => (r ? (JSON.parse(r) as APIGuildMember) : null)); + if (!member) { + redis.members.set( + { + ...data, + joined_at: data.joined_at || new Date().toISOString(), + mute: data.mute || false, + deaf: data.deaf || false, + flags: data.flags || new GuildMemberFlagsBitField().bitfield, + }, + data.guild_id, + ); + return; + } + + const mergedMember = { ...data }; + + if (!data.user) return; + redis.members.set( + { + ...mergedMember, + deaf: mergedMember.deaf || false, + mute: mergedMember.mute || false, + flags: mergedMember.flags || new GuildMemberFlagsBitField().bitfield, + joined_at: mergedMember.joined_at || new Date().toISOString(), + }, + data.guild_id, + ); + }, + + [GatewayDispatchEvents.GuildRoleCreate]: (data: GatewayGuildRoleCreateDispatchData) => + redis.roles.set(data.role, data.guild_id), + + [GatewayDispatchEvents.GuildRoleDelete]: (data: GatewayGuildRoleCreateDispatchData) => + redis.roles.del(data.role.id), + + [GatewayDispatchEvents.GuildRoleUpdate]: (data: GatewayGuildRoleCreateDispatchData) => + redis.roles.set(data.role, data.guild_id), + + [GatewayDispatchEvents.GuildScheduledEventCreate]: ( + data: GatewayGuildScheduledEventCreateDispatchData, + ) => redis.events.set(data), + + [GatewayDispatchEvents.GuildScheduledEventDelete]: ( + data: GatewayGuildScheduledEventDeleteDispatchData, + ) => redis.events.del(data.id), + + [GatewayDispatchEvents.GuildScheduledEventUpdate]: ( + data: GatewayGuildScheduledEventUpdateDispatchData, + ) => redis.events.set(data), + + [GatewayDispatchEvents.GuildScheduledEventUserAdd]: ( + _: GatewayGuildScheduledEventUserAddDispatchData, + ) => undefined, + + [GatewayDispatchEvents.GuildScheduledEventUserRemove]: ( + _: GatewayGuildScheduledEventUserRemoveDispatchData, + ) => undefined, + + [GatewayDispatchEvents.GuildSoundboardSoundCreate]: ( + data: GatewayGuildSoundboardSoundCreateDispatchData, + ) => (data.guild_id ? redis.soundboards.set(data) : undefined), + + [GatewayDispatchEvents.GuildSoundboardSoundDelete]: ( + data: GatewayGuildSoundboardSoundDeleteDispatchData, + ) => redis.soundboards.del(data.sound_id), + + [GatewayDispatchEvents.GuildSoundboardSoundUpdate]: ( + data: GatewayGuildSoundboardSoundUpdateDispatchData, + ) => (data.guild_id ? redis.soundboards.set(data) : undefined), + + [GatewayDispatchEvents.GuildSoundboardSoundsUpdate]: async ( + data: GatewayGuildSoundboardSoundsUpdateDispatchData, + ) => { + await RedisClient.keys(`${redis.soundboards.key()}:${data.guild_id}:*`).then((r) => + r.length ? RedisClient.del(r) : 0, + ); + + data.soundboard_sounds.forEach((sound) => + redis.soundboards.set({ ...sound, guild_id: data.guild_id }), + ); + }, + + [GatewayDispatchEvents.GuildStickersUpdate]: async ( + data: GatewayGuildStickersUpdateDispatchData, + ) => { + await RedisClient.keys(`${redis.stickers.key()}:${data.guild_id}:*`).then((r) => + r.length ? RedisClient.del(r) : 0, + ); + + data.stickers.forEach((sticker) => redis.stickers.set({ ...sticker, guild_id: data.guild_id })); + }, +} as const; diff --git a/src/Events/BotEvents/raw/Cache/Integration.ts b/src/Events/BotEvents/raw/Cache/Integration.ts new file mode 100644 index 00000000..51e078af --- /dev/null +++ b/src/Events/BotEvents/raw/Cache/Integration.ts @@ -0,0 +1,19 @@ +/* eslint-disable @typescript-eslint/no-unused-vars */ +import { + GatewayDispatchEvents, + type GatewayIntegrationCreateDispatchData, + type GatewayIntegrationDeleteDispatchData, + type GatewayIntegrationUpdateDispatchData, +} from 'discord.js'; +import { cache as redis } from '../../../../BaseClient/Bot/Redis.js'; + +export default { + [GatewayDispatchEvents.IntegrationCreate]: (data: GatewayIntegrationCreateDispatchData) => + redis.integrations.set(data, data.guild_id), + + [GatewayDispatchEvents.IntegrationDelete]: (data: GatewayIntegrationDeleteDispatchData) => + redis.integrations.del(data.id), + + [GatewayDispatchEvents.IntegrationUpdate]: (data: GatewayIntegrationUpdateDispatchData) => + redis.integrations.set(data, data.guild_id), +} as const; diff --git a/src/Events/BotEvents/raw/Cache/Invite.ts b/src/Events/BotEvents/raw/Cache/Invite.ts new file mode 100644 index 00000000..e1a37589 --- /dev/null +++ b/src/Events/BotEvents/raw/Cache/Invite.ts @@ -0,0 +1,45 @@ +/* eslint-disable @typescript-eslint/no-unused-vars */ +import { + GatewayDispatchEvents, + GuildNSFWLevel, + GuildVerificationLevel, + InviteType, + type GatewayInviteCreateDispatchData, + type GatewayInviteDeleteDispatchData, +} from 'discord.js'; +import { cache as redis } from '../../../../BaseClient/Bot/Redis.js'; + +export default { + [GatewayDispatchEvents.InviteCreate]: (data: GatewayInviteCreateDispatchData) => { + if (data.inviter) redis.users.set(data.inviter); + + if (data.target_user) redis.users.set(data.target_user); + + if (data.guild_id) { + redis.invites.set({ + ...data, + type: InviteType.Guild, + guild: { + id: data.guild_id, + banner: null, + description: null, + features: [], + icon: null, + name: 'Unknown Guild', + nsfw_level: GuildNSFWLevel.Default, + splash: null, + vanity_url_code: null, + verification_level: GuildVerificationLevel.None, + }, + inviter: data.inviter, + target_user: data.target_user, + guild_scheduled_event: undefined, + stage_instance: undefined, + channel: null, + }); + } + }, + + [GatewayDispatchEvents.InviteDelete]: (data: GatewayInviteDeleteDispatchData) => + redis.invites.del(data.code), +} as const; diff --git a/src/Events/BotEvents/raw/Cache/Message.ts b/src/Events/BotEvents/raw/Cache/Message.ts new file mode 100644 index 00000000..a0912cd5 --- /dev/null +++ b/src/Events/BotEvents/raw/Cache/Message.ts @@ -0,0 +1,115 @@ +/* eslint-disable @typescript-eslint/no-unused-vars */ +import { + GatewayDispatchEvents, + type GatewayMessageCreateDispatchData, + type GatewayMessageDeleteBulkDispatchData, + type GatewayMessageDeleteDispatchData, + type GatewayMessagePollVoteDispatchData, + type GatewayMessageReactionAddDispatchData, + type GatewayMessageReactionRemoveAllDispatchData, + type GatewayMessageReactionRemoveDispatchData, + type GatewayMessageReactionRemoveEmojiDispatchData, + type GatewayMessageUpdateDispatchData, +} from 'discord.js'; +import { AllThreadGuildChannelTypes } from '../../../../Typings/Channel.js'; +import RedisClient, { cache as redis } from '../../../../BaseClient/Bot/Redis.js'; + +export default { + [GatewayDispatchEvents.MessageCreate]: async (data: GatewayMessageCreateDispatchData) => { + if (data.guild_id) redis.messages.set(data, data.guild_id); + + if (!data.webhook_id) redis.users.set(data.author); + + if (!AllThreadGuildChannelTypes.includes(data.type)) return; + + const cache = await redis.threads.get(data.channel_id); + if (cache) redis.threads.set({ ...cache, message_count: (cache.message_count || 0) + 1 }); + }, + + [GatewayDispatchEvents.MessageDelete]: (data: GatewayMessageDeleteDispatchData) => + redis.messages.del(data.id), + + [GatewayDispatchEvents.MessageDeleteBulk]: (data: GatewayMessageDeleteBulkDispatchData) => + data.ids.forEach((id) => redis.messages.del(id)), + + [GatewayDispatchEvents.MessageUpdate]: (data: GatewayMessageUpdateDispatchData) => { + if (data.guild_id) redis.messages.set(data, data.guild_id); + }, + + [GatewayDispatchEvents.MessagePollVoteAdd]: (_: GatewayMessagePollVoteDispatchData) => undefined, + + [GatewayDispatchEvents.MessagePollVoteRemove]: (_: GatewayMessagePollVoteDispatchData) => + undefined, + + [GatewayDispatchEvents.MessageReactionAdd]: async ( + data: GatewayMessageReactionAddDispatchData, + ) => { + if (data.member && data.guild_id) redis.members.set(data.member, data.guild_id); + + if (data.member?.user) redis.users.set(data.member.user); + + if (!data.guild_id) return; + + const cache = await redis.reactions.get(data.message_id, (data.emoji.id || data.emoji.name)!); + + redis.reactions.set( + { + burst_colors: data.burst_colors, + emoji: data.emoji, + me: cache?.me || false, + count_details: cache?.count_details + ? { + burst: cache.count_details.burst + (data.burst ? 1 : 0), + normal: cache.count_details.normal + (data.burst ? 0 : 1), + } + : { burst: data.burst ? 1 : 0, normal: data.burst ? 0 : 1 }, + count: cache?.count ? cache.count + 1 : 1, + me_burst: cache?.me_burst || data.user_id === process.env.mainId ? data.burst : false, + }, + data.guild_id, + data.channel_id, + data.message_id, + ); + }, + + [GatewayDispatchEvents.MessageReactionRemove]: async ( + data: GatewayMessageReactionRemoveDispatchData, + ) => { + if (!data.guild_id) return; + + const cache = await redis.reactions.get(data.message_id, (data.emoji.id || data.emoji.name)!); + + redis.reactions.set( + { + burst_colors: cache?.burst_colors || [], + emoji: data.emoji, + me: cache?.me || false, + count_details: cache?.count_details + ? { + burst: cache.count_details.burst - (data.burst ? 1 : 0), + normal: cache.count_details.normal - (data.burst ? 0 : 1), + } + : { burst: 0, normal: 0 }, + count: cache?.count ? cache.count - 1 : 0, + me_burst: cache?.me_burst || data.user_id === process.env.mainId ? data.burst : false, + }, + data.guild_id, + data.channel_id, + data.message_id, + ); + }, + + [GatewayDispatchEvents.MessageReactionRemoveAll]: ( + data: GatewayMessageReactionRemoveAllDispatchData, + ) => + RedisClient.keys(`${redis.reactions.key()}:*:${data.message_id}:*`).then((r) => + r.length ? RedisClient.del(r) : 0, + ), + + [GatewayDispatchEvents.MessageReactionRemoveEmoji]: ( + data: GatewayMessageReactionRemoveEmojiDispatchData, + ) => + RedisClient.keys( + `${redis.reactions.key()}:*:${data.message_id}:${data.emoji.id || data.emoji.name}`, + ).then((r) => (r.length ? RedisClient.del(r) : 0)), +} as const; diff --git a/src/Events/BotEvents/raw/Cache/Stage.ts b/src/Events/BotEvents/raw/Cache/Stage.ts new file mode 100644 index 00000000..435521bf --- /dev/null +++ b/src/Events/BotEvents/raw/Cache/Stage.ts @@ -0,0 +1,19 @@ +/* eslint-disable @typescript-eslint/no-unused-vars */ +import { + GatewayDispatchEvents, + type GatewayStageInstanceCreateDispatchData, + type GatewayStageInstanceDeleteDispatchData, + type GatewayStageInstanceUpdateDispatchData, +} from 'discord.js'; +import { cache as redis } from '../../../../BaseClient/Bot/Redis.js'; + +export default { + [GatewayDispatchEvents.StageInstanceCreate]: (data: GatewayStageInstanceCreateDispatchData) => + redis.stages.set(data), + + [GatewayDispatchEvents.StageInstanceDelete]: (data: GatewayStageInstanceDeleteDispatchData) => + redis.stages.del(data.id), + + [GatewayDispatchEvents.StageInstanceUpdate]: (data: GatewayStageInstanceUpdateDispatchData) => + redis.stages.set(data), +} as const; diff --git a/src/Events/BotEvents/raw/Cache/Subscription.ts b/src/Events/BotEvents/raw/Cache/Subscription.ts new file mode 100644 index 00000000..001c710e --- /dev/null +++ b/src/Events/BotEvents/raw/Cache/Subscription.ts @@ -0,0 +1,18 @@ +/* eslint-disable @typescript-eslint/no-unused-vars */ +import { + GatewayDispatchEvents, + type GatewaySubscriptionCreateDispatchData, + type GatewaySubscriptionDeleteDispatchData, + type GatewaySubscriptionUpdateDispatchData, +} from 'discord.js'; + +export default { + [GatewayDispatchEvents.SubscriptionCreate]: (_: GatewaySubscriptionCreateDispatchData) => + undefined, + + [GatewayDispatchEvents.SubscriptionDelete]: (_: GatewaySubscriptionDeleteDispatchData) => + undefined, + + [GatewayDispatchEvents.SubscriptionUpdate]: (_: GatewaySubscriptionUpdateDispatchData) => + undefined, +} as const; diff --git a/src/Events/BotEvents/raw/Cache/Thread.ts b/src/Events/BotEvents/raw/Cache/Thread.ts new file mode 100644 index 00000000..51cdc192 --- /dev/null +++ b/src/Events/BotEvents/raw/Cache/Thread.ts @@ -0,0 +1,62 @@ +/* eslint-disable @typescript-eslint/no-unused-vars */ +import { + GatewayDispatchEvents, + type GatewayThreadCreateDispatchData, + type GatewayThreadDeleteDispatchData, + type GatewayThreadListSyncDispatchData, + type GatewayThreadMembersUpdateDispatchData, + type GatewayThreadMemberUpdateDispatchData, + type GatewayThreadUpdateDispatchData, +} from 'discord.js'; +import RedisClient, { cache as redis } from '../../../../BaseClient/Bot/Redis.js'; + +export default { + [GatewayDispatchEvents.ThreadCreate]: (data: GatewayThreadCreateDispatchData) => + redis.threads.set(data), + + [GatewayDispatchEvents.ThreadDelete]: (data: GatewayThreadDeleteDispatchData) => { + redis.threads.del(data.id); + + RedisClient.keys(`${redis.messages.key}:${data.guild_id}:${data.id}:*`).then((keys) => + keys.length ? RedisClient.del(keys) : 0, + ); + + RedisClient.keys(`${redis.threadMembers.key}:${data.guild_id}:${data.id}:*`).then((keys) => + keys.length ? RedisClient.del(keys) : 0, + ); + }, + + [GatewayDispatchEvents.ThreadUpdate]: (data: GatewayThreadUpdateDispatchData) => + redis.threads.set(data), + + [GatewayDispatchEvents.ThreadListSync]: (data: GatewayThreadListSyncDispatchData) => { + data.threads.forEach((thread) => + redis.threads.set({ ...thread, guild_id: data.guild_id || thread.guild_id }), + ); + + data.members.forEach((threadMember) => { + redis.threadMembers.set(threadMember, data.guild_id); + + if (!threadMember.member) return; + redis.members.set(threadMember.member, data.guild_id); + }); + }, + + [GatewayDispatchEvents.ThreadMembersUpdate]: (data: GatewayThreadMembersUpdateDispatchData) => { + data.added_members?.forEach((threadMember) => { + redis.threadMembers.set(threadMember, data.guild_id); + + if (!threadMember.member) return; + redis.members.set(threadMember.member, data.guild_id); + }); + + data.removed_member_ids?.forEach((id) => redis.threadMembers.del(data.id, id)); + }, + + [GatewayDispatchEvents.ThreadMemberUpdate]: (data: GatewayThreadMemberUpdateDispatchData) => { + redis.threadMembers.set(data, data.guild_id); + + if (!data.member) return; + redis.members.set(data.member, data.guild_id); + }, +} as const; diff --git a/src/Events/BotEvents/raw/Cache/Voice.ts b/src/Events/BotEvents/raw/Cache/Voice.ts new file mode 100644 index 00000000..cc3d00e1 --- /dev/null +++ b/src/Events/BotEvents/raw/Cache/Voice.ts @@ -0,0 +1,18 @@ +/* eslint-disable @typescript-eslint/no-unused-vars */ +import { + GatewayDispatchEvents, + type GatewayVoiceChannelEffectSendDispatchData, + type GatewayVoiceServerUpdateDispatchData, + type GatewayVoiceStateUpdateDispatchData, +} from 'discord.js'; +import { cache as redis } from '../../../../BaseClient/Bot/Redis.js'; + +export default { + [GatewayDispatchEvents.VoiceChannelEffectSend]: (_: GatewayVoiceChannelEffectSendDispatchData) => + undefined, + + [GatewayDispatchEvents.VoiceServerUpdate]: (_: GatewayVoiceServerUpdateDispatchData) => undefined, + + [GatewayDispatchEvents.VoiceStateUpdate]: (data: GatewayVoiceStateUpdateDispatchData) => + redis.voices.set(data), +} as const; diff --git a/src/Events/BotEvents/raw/cache.ts b/src/Events/BotEvents/raw/cache.ts new file mode 100644 index 00000000..f12ced2c --- /dev/null +++ b/src/Events/BotEvents/raw/cache.ts @@ -0,0 +1,105 @@ +/* eslint-disable @typescript-eslint/no-unused-vars */ +import { + GatewayDispatchEvents, + type APIGuildChannel, + type APIThreadChannel, + type GatewayApplicationCommandPermissionsUpdateDispatchData, + type GatewayDispatchPayload, + type GatewayGuildSoundboardSoundsUpdateDispatchData, + type GatewayInteractionCreateDispatchData, + type GatewayPresenceUpdateDispatchData, + type GatewayReadyDispatchData, + type GatewayResumedDispatch, + type GatewayTypingStartDispatchData, + type GatewayUserUpdateDispatchData, + type GatewayWebhooksUpdateDispatchData, +} from 'discord.js'; +import type { RChannelTypes } from '../../../BaseClient/Bot/Cache/channel.js'; +import { cache as redis } from '../../../BaseClient/Bot/Redis.js'; +import { + AllNonThreadGuildChannelTypes, + AllThreadGuildChannelTypes, +} from '../../../Typings/Channel.js'; + +import AutoModeration from './Cache/AutoModeration.js'; +import Channel from './Cache/Channel.js'; +import Entitlements from './Cache/Entitlements.js'; +import Guilds from './Cache/Guilds.js'; +import Integration from './Cache/Integration.js'; +import Invite from './Cache/Invite.js'; +import Message from './Cache/Message.js'; +import Stage from './Cache/Stage.js'; +import Subscription from './Cache/Subscription.js'; +import Thread from './Cache/Thread.js'; +import Voice from './Cache/Voice.js'; + +export default async (data: GatewayDispatchPayload) => { + const cache = caches[data.t]; + if (!cache) return; + + cache(data.d as Parameters[0]); +}; + +const caches: Record unknown> = { + ...AutoModeration, + ...Channel, + ...Entitlements, + ...Guilds, + ...Integration, + ...Invite, + ...Message, + ...Stage, + ...Thread, + ...Voice, + ...Subscription, + + [GatewayDispatchEvents.ApplicationCommandPermissionsUpdate]: ( + data: GatewayApplicationCommandPermissionsUpdateDispatchData, + ) => data.permissions.forEach((p) => redis.commandPermissions.set(p, data.guild_id)), + + [GatewayDispatchEvents.SoundboardSounds]: (data: GatewayGuildSoundboardSoundsUpdateDispatchData) => + data.soundboard_sounds.forEach((sound) => + redis.soundboards.set({ ...sound, guild_id: data.guild_id || sound.guild_id }), + ), + + [GatewayDispatchEvents.InteractionCreate]: (data: GatewayInteractionCreateDispatchData) => { + if (data.user) redis.users.set(data.user); + + if (data.message && data.guild_id) { + redis.messages.set(data.message, (data.guild_id || data.guild?.id)!); + } + + if (!data.channel || !data.guild_id) return; + + if (AllThreadGuildChannelTypes.includes(data.channel.type)) { + redis.threads.set({ + ...(data.channel as APIThreadChannel), + guild_id: (data.channel as APIThreadChannel).guild_id || data.guild_id, + }); + return; + } + + if (!AllNonThreadGuildChannelTypes.includes(data.channel.type)) return; + + redis.channels.set({ + ...(data.channel as APIGuildChannel), + guild_id: data.guild_id || (data.channel as APIGuildChannel).guild_id, + }); + }, + + [GatewayDispatchEvents.UserUpdate]: (data: GatewayUserUpdateDispatchData) => redis.users.set(data), + + [GatewayDispatchEvents.WebhooksUpdate]: (_: GatewayWebhooksUpdateDispatchData) => undefined, + + [GatewayDispatchEvents.TypingStart]: (data: GatewayTypingStartDispatchData) => { + if (!data.member || !data.guild_id) return; + + redis.members.set(data.member, data.guild_id); + }, + + [GatewayDispatchEvents.Ready]: (data: GatewayReadyDispatchData) => redis.users.set(data.user), + + [GatewayDispatchEvents.Resumed]: (_: GatewayResumedDispatch['d']) => undefined, + + [GatewayDispatchEvents.PresenceUpdate]: (_: GatewayPresenceUpdateDispatchData) => undefined, +}; diff --git a/src/Events/BotEvents/raw/raw.ts b/src/Events/BotEvents/raw/raw.ts index 79f0516a..7525003b 100644 --- a/src/Events/BotEvents/raw/raw.ts +++ b/src/Events/BotEvents/raw/raw.ts @@ -1,13 +1,20 @@ -import voiceChannelStatusUpdate from './voiceChannelStatusUpdate.js'; +import { GatewayDispatchEvents, GatewayOpcodes, type GatewayDispatchPayload } from 'discord.js'; import channelStatuses from './channelStatuses.js'; +import cache from './cache.js'; +import voiceChannelStatusUpdate from './voiceChannelStatusUpdate.js'; + +export default (data: GatewayDispatchPayload) => { + if (data.op !== GatewayOpcodes.Dispatch) return; + cache(data); -export default (data: { t: string; s: number; op: number; d: unknown }) => { switch (data.t) { - case 'VOICE_CHANNEL_STATUS_UPDATE': - voiceChannelStatusUpdate(data.d as Parameters[0]); + // TODO: wait for d to document this + case 'VOICE_CHANNEL_STATUS_UPDATE' as GatewayDispatchEvents: + voiceChannelStatusUpdate(data.d as unknown as Parameters[0]); break; - case 'CHANNEL_STATUSES': - channelStatuses(data.d as Parameters[0]); + // TODO: wait for d to document this + case 'CHANNEL_STATUSES' as GatewayDispatchEvents: + channelStatuses(data.d as unknown as Parameters[0]); break; default: break; diff --git a/src/Typings/Channel.ts b/src/Typings/Channel.ts index 207b1858..4d4b11f9 100644 --- a/src/Typings/Channel.ts +++ b/src/Typings/Channel.ts @@ -19,6 +19,12 @@ export const AllNonThreadGuildChannelTypes = [ Discord.ChannelType.GuildMedia, ] as const; +export const AllThreadGuildChannelTypes = [ + Discord.ChannelType.PublicThread, + Discord.ChannelType.PrivateThread, + Discord.ChannelType.AnnouncementThread, +] as const; + export const ChannelBanBits = [ Discord.PermissionsBitField.Flags.SendMessages, Discord.PermissionsBitField.Flags.SendMessagesInThreads,