diff --git a/README.md b/README.md index d75be5e..70c05e9 100644 --- a/README.md +++ b/README.md @@ -67,6 +67,18 @@ services: > > Make sure to replace `SOMETHING_SECRET` with a password for your database and `YOUR_DISCORD_TOKEN_HERE` with your bot's token. +And start it up: + +```bash +docker compose up -d +``` + +Once you have it, you should deploy the commands. To do this, run: + +```bash +docker compose run bot npm run deploy:prod +``` + ### With Node.js Make sure to have at least Node.js 20. @@ -106,6 +118,12 @@ POSTGRES_USER=dev POSTGRES_PASSWORD=password ``` +Deploy the commands: + +```bash +npm run deploy:prod +``` + And start the bot: ```bash @@ -123,7 +141,7 @@ You can configure the bot with the following environment variables. | DISCORD_TOKEN | Yes | | The token to connect your bot to Discord. | | DISCORD_SHARDING_ENABLED | No | false | Whether the bot should start in sharded mode or not. This is necessary if your bot is in more than 2000 servers. | | DISCORD_SHARDING_COUNT | No | auto | The amount of shards to spawn if sharding is enabled. It should be a number greater than 1. You can leave this as `auto` to use an automatic value generated for your own needs. | -| DISCORD_PRESENCE_INTERVAL | No | 30000 | The amount of milliseconds to wait before the bot changes its presence or activity. | +| DISCORD_PRESENCE_INTERVAL | No | 300000 | The amount of milliseconds to wait before the bot changes its presence or activity. | | REDIS_URI | Yes | | The Redis URI shared with the crawler service. | | POSTGRES_HOST | Yes | | The database host to connect to. | | POSTGRES_PORT | No | 5432 | The port to use to connect to the database. | diff --git a/package-lock.json b/package-lock.json index dd06bfa..d73fe44 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "discord-free-games-notifier", - "version": "3.0.0", + "version": "3.0.1", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "discord-free-games-notifier", - "version": "3.0.0", + "version": "3.0.1", "license": "MIT", "dependencies": { "@moonstar-x/logger": "^1.0.1", diff --git a/package.json b/package.json index 348a4ba..f1e20cb 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "discord-free-games-notifier", - "version": "3.0.0", + "version": "3.0.1", "description": "A Discord bot that will notify you when games on various storefronts become free.", "private": true, "scripts": { diff --git a/src/base/presence/PresenceResolver.spec.ts b/src/base/presence/PresenceResolver.spec.ts index d014f66..b6c566e 100644 --- a/src/base/presence/PresenceResolver.spec.ts +++ b/src/base/presence/PresenceResolver.spec.ts @@ -6,6 +6,18 @@ import dayjs from 'dayjs'; dayjs.tz.setDefault('America/Guayaquil'); const dateGetTimeSpy = jest.spyOn(Date.prototype, 'getTime', undefined as never); +jest.mock('../../features/gameOffers/functions/getStorefronts', () => { + return { + getStorefronts: jest.fn().mockResolvedValue([1, 2]) + }; +}); + +jest.mock('../../features/gameOffers/functions/getCurrentGameOffers', () => { + return { + getCurrentGameOffers: jest.fn().mockResolvedValue([1, 2]) + }; +}); + describe('Base > Presence > PresenceResolver', () => { beforeAll(() => { (dateGetTimeSpy as jest.Mock).mockReturnValue(1723224679000); @@ -85,10 +97,17 @@ describe('Base > Presence > PresenceResolver', () => { }); }); - describe('n_commands', () => { - it('should return the number of commands.', async () => { - const result = await resolver.get('n_commands'); - expect(result).toBe('with 5 commands!'); + describe('n_storefronts', () => { + it('should return the number of storefronts.', async () => { + const result = await resolver.get('n_storefronts'); + expect(result).toBe('on 2 storefronts!'); + }); + }); + + describe('n_offers', () => { + it('should return the number of storefronts.', async () => { + const result = await resolver.get('n_offers'); + expect(result).toBe('with 2 offers!'); }); }); diff --git a/src/base/presence/PresenceResolver.ts b/src/base/presence/PresenceResolver.ts index 88f0d75..e8d2226 100644 --- a/src/base/presence/PresenceResolver.ts +++ b/src/base/presence/PresenceResolver.ts @@ -5,11 +5,13 @@ import timezone from 'dayjs/plugin/timezone'; import humanizeDuration from 'humanize-duration'; import { Collection, Guild, Snowflake } from 'discord.js'; import { randomItem } from '../../utils/array'; +import { getStorefronts } from '../../features/gameOffers/functions/getStorefronts'; +import { getCurrentGameOffers } from '../../features/gameOffers/functions/getCurrentGameOffers'; dayjs.extend(utc); dayjs.extend(timezone); -const PRESENCE_NAMES = ['n_guilds', 'n_members', 'n_commands', 'time_cur', 'time_ready', 'uptime'] as const; +const PRESENCE_NAMES = ['n_guilds', 'n_members', 'n_commands', 'n_storefronts', 'n_offers', 'time_cur', 'time_ready', 'uptime'] as const; type PresenceName = typeof PRESENCE_NAMES[number]; export class PresenceResolver { @@ -29,6 +31,10 @@ export class PresenceResolver { return `with ${value} users!`; case 'n_commands': return `with ${value} commands!`; + case 'n_storefronts': + return `on ${value} storefronts!`; + case 'n_offers': + return `with ${value} offers!`; case 'time_cur': return `Current time: ${value}`; case 'time_ready': @@ -52,6 +58,10 @@ export class PresenceResolver { return this.getNumberOfMembers(); case 'n_commands': return this.getNumberOfCommands(); + case 'n_storefronts': + return this.getNumberOfStorefronts(); + case 'n_offers': + return this.getNumberOfOffers(); case 'time_cur': return this.getCurrentTime(); case 'time_ready': @@ -88,6 +98,16 @@ export class PresenceResolver { return this.client.registry.size().toString(); } + private async getNumberOfStorefronts(): Promise { + const storefronts = await getStorefronts(); + return storefronts.length.toString(); + } + + private async getNumberOfOffers(): Promise { + const offers = await getCurrentGameOffers(); + return offers.length.toString(); + } + private async getCurrentTime(): Promise { const now = new Date().getTime(); return dayjs(now).tz().format('hh:mm:ss A'); diff --git a/src/commands/ConfigureCommand.spec.ts b/src/commands/ConfigureCommand.spec.ts index 55cfe66..845e71a 100644 --- a/src/commands/ConfigureCommand.spec.ts +++ b/src/commands/ConfigureCommand.spec.ts @@ -54,7 +54,8 @@ describe('Commands > ConfigureCommand', () => { describe('runChannel()', () => { const interaction = { - reply: jest.fn(), + deferReply: jest.fn(), + editReply: jest.fn(), locale: 'en-US', guildId: '1267881983548063785', options: { @@ -63,11 +64,16 @@ describe('Commands > ConfigureCommand', () => { } } as unknown as GuildChatInputCommandInteraction; + it('should defer the reply.', async () => { + await command.run(interaction); + expect(interaction.deferReply).toHaveBeenCalled(); + }); + it('should reply with pre check message if no channel is provided.', async () => { (interaction.options.getChannel as jest.Mock).mockReturnValueOnce(null); await command.run(interaction); - expect(interaction.reply).toHaveBeenCalledWith({ content: 'No channel provided.' }); + expect(interaction.editReply).toHaveBeenCalledWith({ content: 'No channel provided.' }); }); it('should update guild channel.', async () => { @@ -77,7 +83,7 @@ describe('Commands > ConfigureCommand', () => { it('should reply with channel update message.', async () => { await command.run(interaction); - expect(interaction.reply).toHaveBeenCalledWith({ content: 'Successfully updated notifications channel to Channel.' }); + expect(interaction.editReply).toHaveBeenCalledWith({ content: 'Successfully updated notifications channel to Channel.' }); }); }); @@ -90,7 +96,8 @@ describe('Commands > ConfigureCommand', () => { awaitMessageComponent: jest.fn().mockResolvedValue(userResponseMock) }; const interaction = { - reply: jest.fn(), + deferReply: jest.fn(), + editReply: jest.fn(), followUp: jest.fn().mockResolvedValue(followUpResponseMock), locale: 'en-US', guildId: '1267881983548063785', @@ -99,16 +106,21 @@ describe('Commands > ConfigureCommand', () => { } } as unknown as GuildChatInputCommandInteraction; + it('should defer the reply.', async () => { + await command.run(interaction); + expect(interaction.deferReply).toHaveBeenCalled(); + }); + it('should reply with empty storefronts message if no storefronts exist.', async () => { (getStorefronts as jest.Mock).mockResolvedValueOnce([]); await command.run(interaction); - expect(interaction.reply).toHaveBeenCalledWith({ content: 'No storefronts are available right now.' }); + expect(interaction.editReply).toHaveBeenCalledWith({ content: 'No storefronts are available right now.' }); }); it('should reply with start message.', async () => { await command.run(interaction); - expect(interaction.reply).toHaveBeenCalledWith({ content: 'You will receive a follow up for each storefront available. Please, click on the buttons as they appear to enable or disable notifications for each storefront.' }); + expect(interaction.editReply).toHaveBeenCalledWith({ content: 'You will receive a follow up for each storefront available. Please, click on the buttons as they appear to enable or disable notifications for each storefront.' }); }); it('should send follow up with correct components for each storefront.', async () => { @@ -169,7 +181,8 @@ describe('Commands > ConfigureCommand', () => { describe('runLanguage()', () => { const interaction = { - reply: jest.fn(), + deferReply: jest.fn(), + editReply: jest.fn(), locale: 'en-US', guildId: '1267881983548063785', options: { @@ -178,11 +191,16 @@ describe('Commands > ConfigureCommand', () => { } } as unknown as GuildChatInputCommandInteraction; + it('should defer the reply.', async () => { + await command.run(interaction); + expect(interaction.deferReply).toHaveBeenCalled(); + }); + it('should reply with pre check message if no locale is provided.', async () => { (interaction.options.getString as jest.Mock).mockReturnValueOnce(null); await command.run(interaction); - expect(interaction.reply).toHaveBeenCalledWith({ content: 'No language provided.' }); + expect(interaction.editReply).toHaveBeenCalledWith({ content: 'No language provided.' }); }); it('should update guild locale.', async () => { @@ -192,13 +210,14 @@ describe('Commands > ConfigureCommand', () => { it('should reply with language update message.', async () => { await command.run(interaction); - expect(interaction.reply).toHaveBeenCalledWith({ content: 'Successfully updated notifications language to **English**.' }); + expect(interaction.editReply).toHaveBeenCalledWith({ content: 'Successfully updated notifications language to **English**.' }); }); }); describe('runDefault()', () => { const interaction = { - reply: jest.fn(), + deferReply: jest.fn(), + editReply: jest.fn(), locale: 'en-US', guildId: '1267881983548063785', options: { @@ -206,9 +225,14 @@ describe('Commands > ConfigureCommand', () => { } } as unknown as GuildChatInputCommandInteraction; + it('should defer the reply.', async () => { + await command.run(interaction); + expect(interaction.deferReply).toHaveBeenCalled(); + }); + it('should reply with unknown subcommand message.', async () => { await command.run(interaction); - expect(interaction.reply).toHaveBeenCalledWith({ content: 'Unknown subcommand received.' }); + expect(interaction.editReply).toHaveBeenCalledWith({ content: 'Unknown subcommand received.' }); }); }); }); diff --git a/src/commands/ConfigureCommand.ts b/src/commands/ConfigureCommand.ts index 65e0aa8..be5a5f2 100644 --- a/src/commands/ConfigureCommand.ts +++ b/src/commands/ConfigureCommand.ts @@ -68,6 +68,8 @@ export default class ConfigureCommand extends Command { } public override async run(interaction: GuildChatInputCommandInteraction): Promise { + await interaction.deferReply(); + const subCommand = interaction.options.getSubcommand(); switch (subCommand) { @@ -87,12 +89,12 @@ export default class ConfigureCommand extends Command { const channel = interaction.options.getChannel('channel'); if (!channel) { - await interaction.reply({ content: t('commands.configure.run.channel.pre_check.text') }); + await interaction.editReply({ content: t('commands.configure.run.channel.pre_check.text') }); return; } await updateOrCreateGuildChannel(interaction.guildId, channel.id); - await interaction.reply({ content: t('commands.configure.run.channel.success.text', { channel: channel.toString() }) }); + await interaction.editReply({ content: t('commands.configure.run.channel.success.text', { channel: channel.toString() }) }); } private async runStorefronts(interaction: GuildChatInputCommandInteraction): Promise { @@ -100,11 +102,11 @@ export default class ConfigureCommand extends Command { const storefronts = await getStorefronts(); if (!storefronts.length) { - await interaction.reply({ content: t('commands.configure.run.storefronts.empty.text') }); + await interaction.editReply({ content: t('commands.configure.run.storefronts.empty.text') }); return; } - await interaction.reply({ content: t('commands.configure.run.storefronts.start.text') }); + await interaction.editReply({ content: t('commands.configure.run.storefronts.start.text') }); const buttonIds = { enable: 'configure-storefronts-enable', @@ -151,19 +153,19 @@ export default class ConfigureCommand extends Command { const locale = interaction.options.getString('language') as Locale | null; if (!locale) { - await interaction.reply({ content: t('commands.configure.run.language.pre_check.text') }); + await interaction.editReply({ content: t('commands.configure.run.language.pre_check.text') }); return; } const language = t(AVAILABLE_LOCALES[locale]); await updateOrCreateGuildLocale(interaction.guildId, locale); - await interaction.reply({ content: t('commands.configure.run.language.success.text', { language }) }); + await interaction.editReply({ content: t('commands.configure.run.language.success.text', { language }) }); } private async runDefault(interaction: ChatInputCommandInteraction): Promise { const t = getInteractionTranslator(interaction); - await interaction.reply({ content: t('commands.configure.run.default.response.text') }); + await interaction.editReply({ content: t('commands.configure.run.default.response.text') }); } } diff --git a/src/commands/HelpCommand.spec.ts b/src/commands/HelpCommand.spec.ts index 911384a..588179e 100644 --- a/src/commands/HelpCommand.spec.ts +++ b/src/commands/HelpCommand.spec.ts @@ -24,10 +24,16 @@ describe('Commands > HelpCommand', () => { describe('run()', () => { const command = new HelpCommand(client); const interaction = { - reply: jest.fn(), + deferReply: jest.fn(), + editReply: jest.fn(), locale: 'en-US' } as unknown as ChatInputCommandInteraction; + it('should defer the reply.', async () => { + await command.run(interaction); + expect(interaction.deferReply).toHaveBeenCalled(); + }); + it('should reply with the embed.', async () => { await command.run(interaction); @@ -54,7 +60,7 @@ describe('Commands > HelpCommand', () => { ) ]; - expect(interaction.reply).toHaveBeenCalledWith({ embeds: expectedEmbeds, components: expectedComponents }); + expect(interaction.editReply).toHaveBeenCalledWith({ embeds: expectedEmbeds, components: expectedComponents }); }); }); }); diff --git a/src/commands/HelpCommand.ts b/src/commands/HelpCommand.ts index 64e57b8..75a7dd1 100644 --- a/src/commands/HelpCommand.ts +++ b/src/commands/HelpCommand.ts @@ -17,6 +17,7 @@ export default class HelpCommand extends Command { } public override async run(interaction: ChatInputCommandInteraction): Promise { + await interaction.deferReply(); const t = getInteractionTranslator(interaction); const embed = new EmbedBuilder() @@ -39,6 +40,6 @@ export default class HelpCommand extends Command { new ButtonBuilder().setEmoji('🌎').setStyle(ButtonStyle.Link).setURL(BOT_WEBSITE_URL).setLabel(t('commands.help.run.buttons.bot_website.label')), ); - await interaction.reply({ embeds: [embed], components: [row1, row2] }); + await interaction.editReply({ embeds: [embed], components: [row1, row2] }); } } diff --git a/src/commands/InfoCommand.spec.ts b/src/commands/InfoCommand.spec.ts index a6d6768..ec02601 100644 --- a/src/commands/InfoCommand.spec.ts +++ b/src/commands/InfoCommand.spec.ts @@ -53,16 +53,22 @@ describe('Commands > InfoCommand', () => { describe('run()', () => { const command = new InfoCommand(client); const interaction = { - reply: jest.fn(), + deferReply: jest.fn(), + editReply: jest.fn(), locale: 'en-US', guildId: '1267881983548063785' } as unknown as GuildChatInputCommandInteraction; + it('should defer the reply.', async () => { + await command.run(interaction); + expect(interaction.deferReply).toHaveBeenCalled(); + }); + it('should reply with configure message if no settings are found.', async () => { (getGuild as jest.Mock).mockResolvedValueOnce(null); await command.run(interaction); - expect(interaction.reply).toHaveBeenCalledWith({ content: 'No settings have been found for this server. Please use **/configure channel** command to set up the subscription channel.' }); + expect(interaction.editReply).toHaveBeenCalledWith({ content: 'No settings have been found for this server. Please use **/configure channel** command to set up the subscription channel.' }); }); it('should reply with the correct embed if channel exists.', async () => { @@ -85,7 +91,7 @@ describe('Commands > InfoCommand', () => { }) ]; - expect(interaction.reply).toHaveBeenCalledWith({ embeds: expectedEmbeds }); + expect(interaction.editReply).toHaveBeenCalledWith({ embeds: expectedEmbeds }); }); it('should reply with the correct embed if no channel is set.', async () => { @@ -122,7 +128,7 @@ describe('Commands > InfoCommand', () => { }) ]; - expect(interaction.reply).toHaveBeenCalledWith({ embeds: expectedEmbeds }); + expect(interaction.editReply).toHaveBeenCalledWith({ embeds: expectedEmbeds }); }); it('should reply with the correct embed if channel set does not exist.', async () => { @@ -146,7 +152,7 @@ describe('Commands > InfoCommand', () => { }) ]; - expect(interaction.reply).toHaveBeenCalledWith({ embeds: expectedEmbeds }); + expect(interaction.editReply).toHaveBeenCalledWith({ embeds: expectedEmbeds }); }); }); }); diff --git a/src/commands/InfoCommand.ts b/src/commands/InfoCommand.ts index 40f2c7d..20185a9 100644 --- a/src/commands/InfoCommand.ts +++ b/src/commands/InfoCommand.ts @@ -20,11 +20,12 @@ export default class InfoCommand extends Command { } public override async run(interaction: GuildChatInputCommandInteraction): Promise { + await interaction.deferReply(); const t = getInteractionTranslator(interaction); const guildInfo = await getGuild(interaction.guildId); if (!guildInfo) { - await interaction.reply({ content: t('commands.info.run.pre_check.text') }); + await interaction.editReply({ content: t('commands.info.run.pre_check.text') }); return; } @@ -51,7 +52,7 @@ export default class InfoCommand extends Command { text: t('commands.info.run.embed.footer', { createdAt, updatedAt }) }); - await interaction.reply({ embeds: [embed] }); + await interaction.editReply({ embeds: [embed] }); } private async getChannel(channelId: string | null): Promise { diff --git a/src/commands/OffersCommand.spec.ts b/src/commands/OffersCommand.spec.ts index f61db9b..65524fa 100644 --- a/src/commands/OffersCommand.spec.ts +++ b/src/commands/OffersCommand.spec.ts @@ -44,21 +44,27 @@ describe('Commands > OffersCommand', () => { describe('run()', () => { const command = new OffersCommand(client); const interaction = { - reply: jest.fn(), + deferReply: jest.fn(), + editReply: jest.fn(), followUp: jest.fn(), locale: 'en-US' } as unknown as ChatInputCommandInteraction; + it('should defer the reply.', async () => { + await command.run(interaction); + expect(interaction.deferReply).toHaveBeenCalled(); + }); + it('should reply with the empty message if no offers are available.', async () => { (getCurrentGameOffers as jest.Mock).mockResolvedValueOnce([]); await command.run(interaction); - expect(interaction.reply).toHaveBeenCalledWith({ content: 'There are currently no offers in any of the following storefronts: Steam, EpicGames.' }); + expect(interaction.editReply).toHaveBeenCalledWith({ content: 'There are currently no offers in any of the following storefronts: Steam, EpicGames.' }); }); it('should reply with start message.', async () => { await command.run(interaction); - expect(interaction.reply).toHaveBeenCalledWith({ content: "Here's a list of the currently available offers." }); + expect(interaction.editReply).toHaveBeenCalledWith({ content: "Here's a list of the currently available offers." }); }); it('should send an embed and component for each game.', async () => { diff --git a/src/commands/OffersCommand.ts b/src/commands/OffersCommand.ts index b6e1b53..12def7e 100644 --- a/src/commands/OffersCommand.ts +++ b/src/commands/OffersCommand.ts @@ -19,16 +19,17 @@ export default class OffersCommand extends Command { } public override async run(interaction: ChatInputCommandInteraction): Promise { + await interaction.deferReply(); const t = getInteractionTranslator(interaction); const offers = await getCurrentGameOffers(); if (!offers.length) { const storefronts = await getStorefronts(); - await interaction.reply({ content: t('commands.offers.run.empty.text', { list: storefronts.join(', ') }) }); + await interaction.editReply({ content: t('commands.offers.run.empty.text', { list: storefronts.join(', ') }) }); return; } - await interaction.reply({ content: t('commands.offers.run.start.text') }); + await interaction.editReply({ content: t('commands.offers.run.start.text') }); for (const offer of offers) { const { embed, component } = offerToMessage(offer, t);