From c8d3f6fdcf382171757fc72cdf870c4389d70549 Mon Sep 17 00:00:00 2001 From: Carter Himmel Date: Tue, 21 May 2024 18:25:47 -0600 Subject: [PATCH] feat: feedback and dismissable alert decorators --- .github/workflows/cd_commands.yml | 35 +- README.md | 8 +- apps/bot/src/commands/general/adjective.ts | 34 +- apps/bot/src/commands/general/close-rhyme.ts | 29 +- apps/bot/src/commands/general/definition.ts | 36 +-- apps/bot/src/commands/general/holonyms.ts | 34 +- apps/bot/src/commands/general/homophones.ts | 34 +- apps/bot/src/commands/general/hyponyms.ts | 34 +- apps/bot/src/commands/general/match-word.ts | 34 +- apps/bot/src/commands/general/noun.ts | 34 +- apps/bot/src/commands/general/rhyme.ts | 34 +- .../src/commands/general/similar-meaning.ts | 34 +- .../src/commands/general/similar-spelling.ts | 34 +- apps/bot/src/commands/general/sounds-like.ts | 34 +- apps/bot/src/commands/general/that-follow.ts | 34 +- apps/bot/src/commands/general/triggers.ts | 34 +- .../src/commands/general/word-of-the-day.ts | 17 +- apps/bot/src/commands/util/feedback.ts | 51 +++ apps/bot/src/events/interactionCreate.ts | 301 +++++++++++++++++- apps/bot/src/events/ready.ts | 1 + apps/bot/src/functions/feedback.ts | 36 +++ apps/bot/src/hooks/contentModeration.ts | 36 ++- apps/bot/src/hooks/dismissableAlert.ts | 97 ++++++ ...yDefaultAlerter.ts => DismissableAlert.ts} | 10 +- apps/bot/src/structures/index.ts | 2 +- commands.lock.json | 28 ++ docker/postgres/init.sql | 29 +- eslint.config.js | 1 + .../src/commands/util/feedback.ts | 13 + packages/locales/en-US/translation.json | 26 +- 30 files changed, 739 insertions(+), 425 deletions(-) create mode 100644 apps/bot/src/commands/util/feedback.ts create mode 100644 apps/bot/src/functions/feedback.ts create mode 100644 apps/bot/src/hooks/dismissableAlert.ts rename apps/bot/src/structures/{ShowByDefaultAlerter.ts => DismissableAlert.ts} (53%) create mode 100644 packages/interactions/src/commands/util/feedback.ts diff --git a/.github/workflows/cd_commands.yml b/.github/workflows/cd_commands.yml index fd47a590..c5077a98 100644 --- a/.github/workflows/cd_commands.yml +++ b/.github/workflows/cd_commands.yml @@ -1,24 +1,25 @@ name: Continuous Deployment (commands) +# todo: move this to a kube pre-deploy step on: - push: - branches: [main] - paths: - - 'commands.lock.json' - - '.github/workflows/cd_commands.yml' - workflow_dispatch: + # push: + # branches: [main] + # paths: + # - 'commands.lock.json' + # - '.github/workflows/cd_commands.yml' + workflow_dispatch: jobs: - deploy: - name: Deploy Updated Global Commands - runs-on: ubuntu-latest + deploy: + name: Deploy Updated Global Commands + runs-on: ubuntu-latest - steps: - - uses: actions/checkout@v3 + steps: + - uses: actions/checkout@v3 - - name: PUT Global Commands - run: | - curl -X PUT https://discord.com/api/v10/applications/${{ secrets.DISCORD_APPLICATION_ID }}/commands \ - -H "Authorization: Bot ${{ secrets.DISCORD_TOKEN }}" \ - -H "Content-Type: application/json" \ - -d @./commands.lock.json | jq + - name: PUT Global Commands + run: | + curl -X PUT https://discord.com/api/v10/applications/${{ secrets.DISCORD_APPLICATION_ID }}/commands \ + -H "Authorization: Bot ${{ secrets.DISCORD_TOKEN }}" \ + -H "Content-Type: application/json" \ + -d @./commands.lock.json | jq diff --git a/README.md b/README.md index 2b18b39c..45b4a28c 100644 --- a/README.md +++ b/README.md @@ -12,10 +12,6 @@ Help us translate Thoth in the top 30 languages over at our [Crowdin Project](https://crowdin.com/project/thoth) -## Terms of Service +## Self Hosting -Moved to [TERMS_OF_SERVICE.md](./legal/TERMS_OF_SERVICE.md) - -## Privacy Policy - -Moved to [PRIVACY_POLICY.md](./legal/PRIVACY_POLICY.md) +Self hosting is currently unsupported. diff --git a/apps/bot/src/commands/general/adjective.ts b/apps/bot/src/commands/general/adjective.ts index 78574a86..2166bdb5 100644 --- a/apps/bot/src/commands/general/adjective.ts +++ b/apps/bot/src/commands/general/adjective.ts @@ -3,42 +3,31 @@ import { Command } from '@yuudachi/framework'; import type { ArgsParam, InteractionParam, LocaleParam } from '@yuudachi/framework/types'; import i18n from 'i18next'; import { inject, injectable } from 'tsyringe'; -import { BlockedUserModule, BlockedWordModule, ShowByDefaultAlerterModule } from '#structures'; +import { BlockedUserModule, BlockedWordModule, DismissableAlertModule } from '#structures'; import { parseLimit } from '#util/args.js'; import { DatamuseQuery, fetchDatamuse } from '#util/datamuse.js'; import { CommandError } from '#util/error.js'; -import { firstUpperCase, pickRandom, trimArray } from '#util/index.js'; +import { firstUpperCase, trimArray } from '#util/index.js'; +import { UseModeration } from '../../hooks/contentModeration.js'; +import { UseFeedbackAlert } from '../../hooks/dismissableAlert.js'; @injectable() export default class extends Command { public constructor( @inject(BlockedWordModule) public readonly blockedWord: BlockedWordModule, @inject(BlockedUserModule) public readonly blockedUser: BlockedUserModule, - @inject(ShowByDefaultAlerterModule) public readonly showByDefaultAlerter: ShowByDefaultAlerterModule, + @inject(DismissableAlertModule) public readonly dismissableAlertService: DismissableAlertModule, ) { super(); } - private async moderation(interaction: InteractionParam, args: ArgsParam, lng: LocaleParam): Promise { - if (this.blockedWord.check(args.word)) { - throw new CommandError( - pickRandom(i18n.t('common.errors.blocked_word', { lng, returnObjects: true }) as string[]), - ); - } - - const ban = this.blockedUser.check(interaction.user.id); - if (ban) { - throw new CommandError(i18n.t('common.errors.banned', { lng, reason: ban })); - } - } - + @UseModeration() + @UseFeedbackAlert() public override async chatInput( interaction: InteractionParam, args: ArgsParam, lng: LocaleParam, ): Promise { - await this.moderation(interaction, args, lng); - await interaction.deferReply({ ephemeral: args.hide ?? false }); const limit = parseLimit(args.limit, lng); @@ -55,14 +44,5 @@ export default class extends Command { lng, }), ); - - if (!(await this.showByDefaultAlerter.beenAlerted(interaction.user.id))) { - await this.showByDefaultAlerter.add(interaction.user.id); - await interaction.followUp({ - content: - 'As of , various Thoth commands will no longer automatically hide their response. Set the `hide` option to `True` to hide command responses from other users.', - ephemeral: true, - }); - } } } diff --git a/apps/bot/src/commands/general/close-rhyme.ts b/apps/bot/src/commands/general/close-rhyme.ts index 0488c6cc..f38966ec 100644 --- a/apps/bot/src/commands/general/close-rhyme.ts +++ b/apps/bot/src/commands/general/close-rhyme.ts @@ -3,11 +3,13 @@ import { Command } from '@yuudachi/framework'; import type { ArgsParam, InteractionParam, LocaleParam } from '@yuudachi/framework/types'; import i18n from 'i18next'; import { inject, injectable } from 'tsyringe'; -import { BlockedUserModule, BlockedWordModule, ShowByDefaultAlerterModule } from '#structures'; +import { BlockedUserModule, BlockedWordModule, DismissableAlertModule } from '#structures'; import { parseLimit } from '#util/args.js'; import { DatamuseQuery, fetchDatamuse } from '#util/datamuse.js'; import { CommandError } from '#util/error.js'; -import { firstUpperCase, pickRandom, trimArray } from '#util/index.js'; +import { firstUpperCase, trimArray } from '#util/index.js'; +import { UseModeration } from '../../hooks/contentModeration.js'; +import { UseFeedbackAlert } from '../../hooks/dismissableAlert.js'; @injectable() export default class extends Command { @@ -16,31 +18,18 @@ export default class extends Command public constructor( @inject(BlockedWordModule) public readonly blockedWord: BlockedWordModule, @inject(BlockedUserModule) public readonly blockedUser: BlockedUserModule, - @inject(ShowByDefaultAlerterModule) public readonly showByDefaultAlerter: ShowByDefaultAlerterModule, + @inject(DismissableAlertModule) public readonly dismissableAlertService: DismissableAlertModule, ) { super(); } - private async moderation(interaction: InteractionParam, args: ArgsParam, lng: LocaleParam): Promise { - if (this.blockedWord.check(args.word)) { - throw new CommandError( - pickRandom(i18n.t('common.errors.blocked_word', { lng, returnObjects: true }) as string[]), - ); - } - - const ban = this.blockedUser.check(interaction.user.id); - if (ban) { - throw new CommandError(i18n.t('common.errors.banned', { lng, reason: ban })); - } - } - + @UseModeration() + @UseFeedbackAlert() public override async chatInput( interaction: InteractionParam, args: ArgsParam, lng: LocaleParam, ): Promise { - await this.moderation(interaction, args, lng); - await interaction.deferReply({ ephemeral: args.hide ?? false }); const limit = parseLimit(args.limit, lng); @@ -60,8 +49,8 @@ export default class extends Command .slice(0, 2_000), ); - if (!(await this.showByDefaultAlerter.beenAlerted(interaction.user.id))) { - await this.showByDefaultAlerter.add(interaction.user.id); + if (!(await this.dismissableAlertService.beenAlerted(interaction.user.id, 'show_by_default_alert'))) { + await this.dismissableAlertService.add(interaction.user.id, 'show_by_default_alert'); await interaction.followUp({ content: 'As of , various Thoth commands will no longer automatically hide their response. Set the `hide` option to `True` to hide command responses from other users.', diff --git a/apps/bot/src/commands/general/definition.ts b/apps/bot/src/commands/general/definition.ts index c328c2ec..948fbda1 100644 --- a/apps/bot/src/commands/general/definition.ts +++ b/apps/bot/src/commands/general/definition.ts @@ -5,16 +5,18 @@ import type { ArgsParam, InteractionParam, LocaleParam } from '@yuudachi/framewo import { stripIndents } from 'common-tags'; import { ButtonStyle } from 'discord-api-types/v10'; import { AttachmentBuilder, ComponentType } from 'discord.js'; -import i18n, { t } from 'i18next'; +import { t } from 'i18next'; import type { Entry, Sense, Senses, VerbalIllustration } from 'mw-collegiate'; import { inject, injectable } from 'tsyringe'; import { logger } from '#logger'; import { createPronunciationURL, fetchDefinition } from '#mw'; import { formatText } from '#mw/format.js'; -import { BlockedUserModule, BlockedWordModule, RedisManager, ShowByDefaultAlerterModule } from '#structures'; +import { BlockedUserModule, BlockedWordModule, RedisManager, DismissableAlertModule } from '#structures'; import { Characters, Emojis } from '#util/constants.js'; import { CommandError } from '#util/error.js'; -import { kRedis, pickRandom, trimArray } from '#util/index.js'; +import { kRedis, trimArray } from '#util/index.js'; +import { UseModeration } from '../../hooks/contentModeration.js'; +import { UseFeedbackAlert } from '../../hooks/dismissableAlert.js'; @injectable() export default class extends Command { @@ -22,31 +24,18 @@ export default class extends Command @inject(kRedis) public readonly redis: RedisManager, @inject(BlockedWordModule) public readonly blockedWord: BlockedWordModule, @inject(BlockedUserModule) public readonly blockedUser: BlockedUserModule, - @inject(ShowByDefaultAlerterModule) public readonly showByDefaultAlerter: ShowByDefaultAlerterModule, + @inject(DismissableAlertModule) public readonly dismissableAlertService: DismissableAlertModule, ) { super(); } - private async moderation(interaction: InteractionParam, args: ArgsParam, lng: LocaleParam): Promise { - if (this.blockedWord.check(args.word)) { - throw new CommandError( - pickRandom(i18n.t('common.errors.blocked_word', { lng, returnObjects: true }) as string[]), - ); - } - - const ban = this.blockedUser.check(interaction.user.id); - if (ban) { - throw new CommandError(i18n.t('common.errors.banned', { lng, reason: ban })); - } - } - + @UseModeration() + @UseFeedbackAlert() public override async chatInput( interaction: InteractionParam, args: ArgsParam, lng: LocaleParam, ): Promise { - await this.moderation(interaction, args, lng); - const reply = await interaction.deferReply({ ephemeral: args.hide ?? false, }); @@ -194,14 +183,5 @@ export default class extends Command files: soundAttachment ? [soundAttachment] : [], components: [], }); - - if (!(await this.showByDefaultAlerter.beenAlerted(interaction.user.id))) { - await this.showByDefaultAlerter.add(interaction.user.id); - await interaction.followUp({ - content: - 'As of , various Thoth commands will no longer automatically hide their response. Set the `hide` option to `True` to hide command responses from other users.', - ephemeral: true, - }); - } } } diff --git a/apps/bot/src/commands/general/holonyms.ts b/apps/bot/src/commands/general/holonyms.ts index 8471de2a..aec54b18 100644 --- a/apps/bot/src/commands/general/holonyms.ts +++ b/apps/bot/src/commands/general/holonyms.ts @@ -3,42 +3,31 @@ import { Command } from '@yuudachi/framework'; import type { ArgsParam, InteractionParam, LocaleParam } from '@yuudachi/framework/types'; import i18n from 'i18next'; import { inject, injectable } from 'tsyringe'; -import { BlockedUserModule, BlockedWordModule, ShowByDefaultAlerterModule } from '#structures'; +import { BlockedUserModule, BlockedWordModule, DismissableAlertModule } from '#structures'; import { parseLimit } from '#util/args.js'; import { DatamuseQuery, fetchDatamuse } from '#util/datamuse.js'; import { CommandError } from '#util/error.js'; -import { firstUpperCase, pickRandom, trimArray } from '#util/index.js'; +import { firstUpperCase, trimArray } from '#util/index.js'; +import { UseModeration } from '../../hooks/contentModeration.js'; +import { UseFeedbackAlert } from '../../hooks/dismissableAlert.js'; @injectable() export default class extends Command { public constructor( @inject(BlockedWordModule) public readonly blockedWord: BlockedWordModule, @inject(BlockedUserModule) public readonly blockedUser: BlockedUserModule, - @inject(ShowByDefaultAlerterModule) public readonly showByDefaultAlerter: ShowByDefaultAlerterModule, + @inject(DismissableAlertModule) public readonly dismissableAlertService: DismissableAlertModule, ) { super(); } - private async moderation(interaction: InteractionParam, args: ArgsParam, lng: LocaleParam): Promise { - if (this.blockedWord.check(args.word)) { - throw new CommandError( - pickRandom(i18n.t('common.errors.blocked_word', { lng, returnObjects: true }) as string[]), - ); - } - - const ban = this.blockedUser.check(interaction.user.id); - if (ban) { - throw new CommandError(i18n.t('common.errors.banned', { lng, reason: ban })); - } - } - + @UseModeration() + @UseFeedbackAlert() public override async chatInput( interaction: InteractionParam, args: ArgsParam, lng: LocaleParam, ): Promise { - await this.moderation(interaction, args, lng); - await interaction.deferReply({ ephemeral: args.hide ?? false }); const limit = parseLimit(args.limit, lng); @@ -55,14 +44,5 @@ export default class extends Command { lng, }), ); - - if (!(await this.showByDefaultAlerter.beenAlerted(interaction.user.id))) { - await this.showByDefaultAlerter.add(interaction.user.id); - await interaction.followUp({ - content: - 'As of , various Thoth commands will no longer hide their response. To hide the response from other users as previous, set the `hide` option to `True`.', - ephemeral: true, - }); - } } } diff --git a/apps/bot/src/commands/general/homophones.ts b/apps/bot/src/commands/general/homophones.ts index b618407f..9ca72af0 100644 --- a/apps/bot/src/commands/general/homophones.ts +++ b/apps/bot/src/commands/general/homophones.ts @@ -3,42 +3,31 @@ import { Command } from '@yuudachi/framework'; import type { ArgsParam, InteractionParam, LocaleParam } from '@yuudachi/framework/types'; import i18n from 'i18next'; import { inject, injectable } from 'tsyringe'; -import { BlockedUserModule, BlockedWordModule, ShowByDefaultAlerterModule } from '#structures'; +import { BlockedUserModule, BlockedWordModule, DismissableAlertModule } from '#structures'; import { parseLimit } from '#util/args.js'; import { DatamuseQuery, fetchDatamuse } from '#util/datamuse.js'; import { CommandError } from '#util/error.js'; -import { firstUpperCase, pickRandom, trimArray } from '#util/index.js'; +import { firstUpperCase, trimArray } from '#util/index.js'; +import { UseModeration } from '../../hooks/contentModeration.js'; +import { UseFeedbackAlert } from '../../hooks/dismissableAlert.js'; @injectable() export default class extends Command { public constructor( @inject(BlockedWordModule) public readonly blockedWord: BlockedWordModule, @inject(BlockedUserModule) public readonly blockedUser: BlockedUserModule, - @inject(ShowByDefaultAlerterModule) public readonly showByDefaultAlerter: ShowByDefaultAlerterModule, + @inject(DismissableAlertModule) public readonly dismissableAlertService: DismissableAlertModule, ) { super(); } - private async moderation(interaction: InteractionParam, args: ArgsParam, lng: LocaleParam): Promise { - if (this.blockedWord.check(args.word)) { - throw new CommandError( - pickRandom(i18n.t('common.errors.blocked_word', { lng, returnObjects: true }) as string[]), - ); - } - - const ban = this.blockedUser.check(interaction.user.id); - if (ban) { - throw new CommandError(i18n.t('common.errors.banned', { lng, reason: ban })); - } - } - + @UseModeration() + @UseFeedbackAlert() public override async chatInput( interaction: InteractionParam, args: ArgsParam, lng: LocaleParam, ): Promise { - await this.moderation(interaction, args, lng); - await interaction.deferReply({ ephemeral: args.hide ?? false }); const limit = parseLimit(args.limit, lng); @@ -55,14 +44,5 @@ export default class extends Command lng, }), ); - - if (!(await this.showByDefaultAlerter.beenAlerted(interaction.user.id))) { - await this.showByDefaultAlerter.add(interaction.user.id); - await interaction.followUp({ - content: - 'As of , various Thoth commands will no longer automatically hide their response. Set the `hide` option to `True` to hide command responses from other users.', - ephemeral: true, - }); - } } } diff --git a/apps/bot/src/commands/general/hyponyms.ts b/apps/bot/src/commands/general/hyponyms.ts index 591861ee..2f43a6c0 100644 --- a/apps/bot/src/commands/general/hyponyms.ts +++ b/apps/bot/src/commands/general/hyponyms.ts @@ -3,42 +3,31 @@ import { Command } from '@yuudachi/framework'; import type { ArgsParam, InteractionParam, LocaleParam } from '@yuudachi/framework/types'; import i18n from 'i18next'; import { inject, injectable } from 'tsyringe'; -import { BlockedUserModule, BlockedWordModule, ShowByDefaultAlerterModule } from '#structures'; +import { BlockedUserModule, BlockedWordModule, DismissableAlertModule } from '#structures'; import { parseLimit } from '#util/args.js'; import { DatamuseQuery, fetchDatamuse } from '#util/datamuse.js'; import { CommandError } from '#util/error.js'; -import { firstUpperCase, pickRandom, trimArray } from '#util/index.js'; +import { firstUpperCase, trimArray } from '#util/index.js'; +import { UseModeration } from '../../hooks/contentModeration.js'; +import { UseFeedbackAlert } from '../../hooks/dismissableAlert.js'; @injectable() export default class extends Command { public constructor( @inject(BlockedWordModule) public readonly blockedWord: BlockedWordModule, @inject(BlockedUserModule) public readonly blockedUser: BlockedUserModule, - @inject(ShowByDefaultAlerterModule) public readonly showByDefaultAlerter: ShowByDefaultAlerterModule, + @inject(DismissableAlertModule) public readonly dismissableAlertService: DismissableAlertModule, ) { super(); } - private async moderation(interaction: InteractionParam, args: ArgsParam, lng: LocaleParam): Promise { - if (this.blockedWord.check(args.word)) { - throw new CommandError( - pickRandom(i18n.t('common.errors.blocked_word', { lng, returnObjects: true }) as string[]), - ); - } - - const ban = this.blockedUser.check(interaction.user.id); - if (ban) { - throw new CommandError(i18n.t('common.errors.banned', { lng, reason: ban })); - } - } - + @UseModeration() + @UseFeedbackAlert() public override async chatInput( interaction: InteractionParam, args: ArgsParam, lng: LocaleParam, ): Promise { - await this.moderation(interaction, args, lng); - await interaction.deferReply({ ephemeral: args.hide ?? false }); const limit = parseLimit(args.limit, lng); @@ -55,14 +44,5 @@ export default class extends Command { lng, }), ); - - if (!(await this.showByDefaultAlerter.beenAlerted(interaction.user.id))) { - await this.showByDefaultAlerter.add(interaction.user.id); - await interaction.followUp({ - content: - 'As of , various Thoth commands will no longer automatically hide their response. Set the `hide` option to `True` to hide command responses from other users.', - ephemeral: true, - }); - } } } diff --git a/apps/bot/src/commands/general/match-word.ts b/apps/bot/src/commands/general/match-word.ts index 1add66ec..d6b48c85 100644 --- a/apps/bot/src/commands/general/match-word.ts +++ b/apps/bot/src/commands/general/match-word.ts @@ -3,42 +3,31 @@ import { Command } from '@yuudachi/framework'; import type { ArgsParam, InteractionParam, LocaleParam } from '@yuudachi/framework/types'; import i18n from 'i18next'; import { inject, injectable } from 'tsyringe'; -import { BlockedUserModule, BlockedWordModule, ShowByDefaultAlerterModule } from '#structures'; +import { BlockedUserModule, BlockedWordModule, DismissableAlertModule } from '#structures'; import { parseLimit } from '#util/args.js'; import { DatamuseQuery, fetchDatamuse } from '#util/datamuse.js'; import { CommandError } from '#util/error.js'; -import { firstUpperCase, pickRandom, trimArray } from '#util/index.js'; +import { firstUpperCase, trimArray } from '#util/index.js'; +import { UseModeration } from '../../hooks/contentModeration.js'; +import { UseFeedbackAlert } from '../../hooks/dismissableAlert.js'; @injectable() export default class extends Command { public constructor( @inject(BlockedWordModule) public readonly blockedWord: BlockedWordModule, @inject(BlockedUserModule) public readonly blockedUser: BlockedUserModule, - @inject(ShowByDefaultAlerterModule) public readonly showByDefaultAlerter: ShowByDefaultAlerterModule, + @inject(DismissableAlertModule) public readonly dismissableAlertService: DismissableAlertModule, ) { super(); } - private async moderation(interaction: InteractionParam, args: ArgsParam, lng: LocaleParam): Promise { - if (this.blockedWord.check(args.word)) { - throw new CommandError( - pickRandom(i18n.t('common.errors.blocked_word', { lng, returnObjects: true }) as string[]), - ); - } - - const ban = this.blockedUser.check(interaction.user.id); - if (ban) { - throw new CommandError(i18n.t('common.errors.banned', { lng, reason: ban })); - } - } - + @UseModeration() + @UseFeedbackAlert() public override async chatInput( interaction: InteractionParam, args: ArgsParam, lng: LocaleParam, ): Promise { - await this.moderation(interaction, args, lng); - await interaction.deferReply({ ephemeral: args.hide ?? false }); const limit = parseLimit(args.limit, lng); @@ -55,14 +44,5 @@ export default class extends Command { lng, }), ); - - if (!(await this.showByDefaultAlerter.beenAlerted(interaction.user.id))) { - await this.showByDefaultAlerter.add(interaction.user.id); - await interaction.followUp({ - content: - 'As of , various Thoth commands will no longer automatically hide their response. Set the `hide` option to `True` to hide command responses from other users.', - ephemeral: true, - }); - } } } diff --git a/apps/bot/src/commands/general/noun.ts b/apps/bot/src/commands/general/noun.ts index 12d375f7..91c05260 100644 --- a/apps/bot/src/commands/general/noun.ts +++ b/apps/bot/src/commands/general/noun.ts @@ -3,42 +3,31 @@ import { Command } from '@yuudachi/framework'; import type { ArgsParam, InteractionParam, LocaleParam } from '@yuudachi/framework/types'; import i18n from 'i18next'; import { inject, injectable } from 'tsyringe'; -import { BlockedUserModule, BlockedWordModule, ShowByDefaultAlerterModule } from '#structures'; +import { BlockedUserModule, BlockedWordModule, DismissableAlertModule } from '#structures'; import { parseLimit } from '#util/args.js'; import { DatamuseQuery, fetchDatamuse } from '#util/datamuse.js'; import { CommandError } from '#util/error.js'; -import { firstUpperCase, pickRandom, trimArray } from '#util/index.js'; +import { firstUpperCase, trimArray } from '#util/index.js'; +import { UseModeration } from '../../hooks/contentModeration.js'; +import { UseFeedbackAlert } from '../../hooks/dismissableAlert.js'; @injectable() export default class extends Command { public constructor( @inject(BlockedWordModule) public readonly blockedWord: BlockedWordModule, @inject(BlockedUserModule) public readonly blockedUser: BlockedUserModule, - @inject(ShowByDefaultAlerterModule) public readonly showByDefaultAlerter: ShowByDefaultAlerterModule, + @inject(DismissableAlertModule) public readonly dismissableAlertService: DismissableAlertModule, ) { super(); } - private async moderation(interaction: InteractionParam, args: ArgsParam, lng: LocaleParam): Promise { - if (this.blockedWord.check(args.word)) { - throw new CommandError( - pickRandom(i18n.t('common.errors.blocked_word', { lng, returnObjects: true }) as string[]), - ); - } - - const ban = this.blockedUser.check(interaction.user.id); - if (ban) { - throw new CommandError(i18n.t('common.errors.banned', { lng, reason: ban })); - } - } - + @UseModeration() + @UseFeedbackAlert() public override async chatInput( interaction: InteractionParam, args: ArgsParam, lng: LocaleParam, ): Promise { - await this.moderation(interaction, args, lng); - await interaction.deferReply({ ephemeral: args.hide ?? false }); const limit = parseLimit(args.limit, lng); @@ -55,14 +44,5 @@ export default class extends Command { lng, }), ); - - if (!(await this.showByDefaultAlerter.beenAlerted(interaction.user.id))) { - await this.showByDefaultAlerter.add(interaction.user.id); - await interaction.followUp({ - content: - 'As of , various Thoth commands will no longer automatically hide their response. Set the `hide` option to `True` to hide command responses from other users.', - ephemeral: true, - }); - } } } diff --git a/apps/bot/src/commands/general/rhyme.ts b/apps/bot/src/commands/general/rhyme.ts index 018efb16..30cc9340 100644 --- a/apps/bot/src/commands/general/rhyme.ts +++ b/apps/bot/src/commands/general/rhyme.ts @@ -3,42 +3,31 @@ import { Command } from '@yuudachi/framework'; import type { ArgsParam, InteractionParam, LocaleParam } from '@yuudachi/framework/types'; import i18n from 'i18next'; import { inject, injectable } from 'tsyringe'; -import { BlockedUserModule, BlockedWordModule, ShowByDefaultAlerterModule } from '#structures'; +import { BlockedUserModule, BlockedWordModule, DismissableAlertModule } from '#structures'; import { parseLimit } from '#util/args.js'; import { DatamuseQuery, fetchDatamuse } from '#util/datamuse.js'; import { CommandError } from '#util/error.js'; -import { firstUpperCase, pickRandom, trimArray } from '#util/index.js'; +import { firstUpperCase, trimArray } from '#util/index.js'; +import { UseModeration } from '../../hooks/contentModeration.js'; +import { UseFeedbackAlert } from '../../hooks/dismissableAlert.js'; @injectable() export default class extends Command { public constructor( @inject(BlockedWordModule) public readonly blockedWord: BlockedWordModule, @inject(BlockedUserModule) public readonly blockedUser: BlockedUserModule, - @inject(ShowByDefaultAlerterModule) public readonly showByDefaultAlerter: ShowByDefaultAlerterModule, + @inject(DismissableAlertModule) public readonly dismissableAlertService: DismissableAlertModule, ) { super(); } - private async moderation(interaction: InteractionParam, args: ArgsParam, lng: LocaleParam): Promise { - if (this.blockedWord.check(args.word)) { - throw new CommandError( - pickRandom(i18n.t('common.errors.blocked_word', { lng, returnObjects: true }) as string[]), - ); - } - - const ban = this.blockedUser.check(interaction.user.id); - if (ban) { - throw new CommandError(i18n.t('common.errors.banned', { lng, reason: ban })); - } - } - + @UseModeration() + @UseFeedbackAlert() public override async chatInput( interaction: InteractionParam, args: ArgsParam, lng: LocaleParam, ): Promise { - await this.moderation(interaction, args, lng); - await interaction.deferReply({ ephemeral: args.hide ?? false }); const limit = parseLimit(args.limit, lng); @@ -55,14 +44,5 @@ export default class extends Command { lng, }), ); - - if (!(await this.showByDefaultAlerter.beenAlerted(interaction.user.id))) { - await this.showByDefaultAlerter.add(interaction.user.id); - await interaction.followUp({ - content: - 'As of , various Thoth commands will no longer automatically hide their response. Set the `hide` option to `True` to hide command responses from other users.', - ephemeral: true, - }); - } } } diff --git a/apps/bot/src/commands/general/similar-meaning.ts b/apps/bot/src/commands/general/similar-meaning.ts index 6b310dfc..d318750b 100644 --- a/apps/bot/src/commands/general/similar-meaning.ts +++ b/apps/bot/src/commands/general/similar-meaning.ts @@ -4,11 +4,13 @@ import { Command } from '@yuudachi/framework'; import type { ArgsParam, InteractionParam, LocaleParam } from '@yuudachi/framework/types'; import i18n from 'i18next'; import { inject, injectable } from 'tsyringe'; -import { BlockedUserModule, BlockedWordModule, ShowByDefaultAlerterModule } from '#structures'; +import { BlockedUserModule, BlockedWordModule, DismissableAlertModule } from '#structures'; import { parseLimit } from '#util/args.js'; import { DatamuseQuery, fetchDatamuseRaw } from '#util/datamuse.js'; import { CommandError } from '#util/error.js'; -import { firstUpperCase, pickRandom, trimArray } from '#util/index.js'; +import { firstUpperCase, trimArray } from '#util/index.js'; +import { UseModeration } from '../../hooks/contentModeration.js'; +import { UseFeedbackAlert } from '../../hooks/dismissableAlert.js'; @injectable() export default class extends Command { @@ -17,31 +19,18 @@ export default class extends Command, lng: LocaleParam): Promise { - if (this.blockedWord.check(args.word)) { - throw new CommandError( - pickRandom(i18n.t('common.errors.blocked_word', { lng, returnObjects: true }) as string[]), - ); - } - - const ban = this.blockedUser.check(interaction.user.id); - if (ban) { - throw new CommandError(i18n.t('common.errors.banned', { lng, reason: ban })); - } - } - + @UseModeration() + @UseFeedbackAlert() public override async chatInput( interaction: InteractionParam, args: ArgsParam, lng: LocaleParam, ): Promise { - await this.moderation(interaction, args, lng); - await interaction.deferReply({ ephemeral: args.hide ?? false }); const limit = parseLimit(args.limit, lng); @@ -87,14 +76,5 @@ export default class extends Command, various Thoth commands will no longer automatically hide their response. Set the `hide` option to `True` to hide command responses from other users.', - ephemeral: true, - }); - } } } diff --git a/apps/bot/src/commands/general/similar-spelling.ts b/apps/bot/src/commands/general/similar-spelling.ts index f77dca72..36a546d4 100644 --- a/apps/bot/src/commands/general/similar-spelling.ts +++ b/apps/bot/src/commands/general/similar-spelling.ts @@ -3,42 +3,31 @@ import { Command } from '@yuudachi/framework'; import type { ArgsParam, InteractionParam, LocaleParam } from '@yuudachi/framework/types'; import i18n from 'i18next'; import { inject, injectable } from 'tsyringe'; -import { BlockedUserModule, BlockedWordModule, ShowByDefaultAlerterModule } from '#structures'; +import { BlockedUserModule, BlockedWordModule, DismissableAlertModule } from '#structures'; import { parseLimit } from '#util/args.js'; import { DatamuseQuery, fetchDatamuse } from '#util/datamuse.js'; import { CommandError } from '#util/error.js'; -import { firstUpperCase, pickRandom, trimArray } from '#util/index.js'; +import { firstUpperCase, trimArray } from '#util/index.js'; +import { UseModeration } from '../../hooks/contentModeration.js'; +import { UseFeedbackAlert } from '../../hooks/dismissableAlert.js'; @injectable() export default class extends Command { public constructor( @inject(BlockedWordModule) public readonly blockedWord: BlockedWordModule, @inject(BlockedUserModule) public readonly blockedUser: BlockedUserModule, - @inject(ShowByDefaultAlerterModule) public readonly showByDefaultAlerter: ShowByDefaultAlerterModule, + @inject(DismissableAlertModule) public readonly dismissableAlertService: DismissableAlertModule, ) { super(); } - private async moderation(interaction: InteractionParam, args: ArgsParam, lng: LocaleParam): Promise { - if (this.blockedWord.check(args.word)) { - throw new CommandError( - pickRandom(i18n.t('common.errors.blocked_word', { lng, returnObjects: true }) as string[]), - ); - } - - const ban = this.blockedUser.check(interaction.user.id); - if (ban) { - throw new CommandError(i18n.t('common.errors.banned', { lng, reason: ban })); - } - } - + @UseModeration() + @UseFeedbackAlert() public override async chatInput( interaction: InteractionParam, args: ArgsParam, lng: LocaleParam, ): Promise { - await this.moderation(interaction, args, lng); - await interaction.deferReply({ ephemeral: args.hide ?? false }); const limit = parseLimit(args.limit, lng); @@ -55,14 +44,5 @@ export default class extends Command< lng, }), ); - - if (!(await this.showByDefaultAlerter.beenAlerted(interaction.user.id))) { - await this.showByDefaultAlerter.add(interaction.user.id); - await interaction.followUp({ - content: - 'As of , various Thoth commands will no longer automatically hide their response. Set the `hide` option to `True` to hide command responses from other users.', - ephemeral: true, - }); - } } } diff --git a/apps/bot/src/commands/general/sounds-like.ts b/apps/bot/src/commands/general/sounds-like.ts index 64bfa9f4..0403cd3a 100644 --- a/apps/bot/src/commands/general/sounds-like.ts +++ b/apps/bot/src/commands/general/sounds-like.ts @@ -3,42 +3,31 @@ import { Command } from '@yuudachi/framework'; import type { ArgsParam, InteractionParam, LocaleParam } from '@yuudachi/framework/types'; import i18n from 'i18next'; import { inject, injectable } from 'tsyringe'; -import { BlockedUserModule, BlockedWordModule, ShowByDefaultAlerterModule } from '#structures'; +import { BlockedUserModule, BlockedWordModule, DismissableAlertModule } from '#structures'; import { parseLimit } from '#util/args.js'; import { DatamuseQuery, fetchDatamuse } from '#util/datamuse.js'; import { CommandError } from '#util/error.js'; -import { firstUpperCase, pickRandom, trimArray } from '#util/index.js'; +import { firstUpperCase, trimArray } from '#util/index.js'; +import { UseModeration } from '../../hooks/contentModeration.js'; +import { UseFeedbackAlert } from '../../hooks/dismissableAlert.js'; @injectable() export default class extends Command { public constructor( @inject(BlockedWordModule) public readonly blockedWord: BlockedWordModule, @inject(BlockedUserModule) public readonly blockedUser: BlockedUserModule, - @inject(ShowByDefaultAlerterModule) public readonly showByDefaultAlerter: ShowByDefaultAlerterModule, + @inject(DismissableAlertModule) public readonly dismissableAlertService: DismissableAlertModule, ) { super(); } - private async moderation(interaction: InteractionParam, args: ArgsParam, lng: LocaleParam): Promise { - if (this.blockedWord.check(args.word)) { - throw new CommandError( - pickRandom(i18n.t('common.errors.blocked_word', { lng, returnObjects: true }) as string[]), - ); - } - - const ban = this.blockedUser.check(interaction.user.id); - if (ban) { - throw new CommandError(i18n.t('common.errors.banned', { lng, reason: ban })); - } - } - + @UseModeration() + @UseFeedbackAlert() public override async chatInput( interaction: InteractionParam, args: ArgsParam, lng: LocaleParam, ): Promise { - await this.moderation(interaction, args, lng); - await interaction.deferReply({ ephemeral: args.hide ?? false }); const limit = parseLimit(args.limit, lng); @@ -55,14 +44,5 @@ export default class extends Command lng, }), ); - - if (!(await this.showByDefaultAlerter.beenAlerted(interaction.user.id))) { - await this.showByDefaultAlerter.add(interaction.user.id); - await interaction.followUp({ - content: - 'As of , various Thoth commands will no longer automatically hide their response. Set the `hide` option to `True` to hide command responses from other users.', - ephemeral: true, - }); - } } } diff --git a/apps/bot/src/commands/general/that-follow.ts b/apps/bot/src/commands/general/that-follow.ts index 240acb7e..fa089f71 100644 --- a/apps/bot/src/commands/general/that-follow.ts +++ b/apps/bot/src/commands/general/that-follow.ts @@ -4,42 +4,31 @@ import { Command } from '@yuudachi/framework'; import type { ArgsParam, InteractionParam, LocaleParam } from '@yuudachi/framework/types'; import i18n from 'i18next'; import { inject, injectable } from 'tsyringe'; -import { BlockedUserModule, BlockedWordModule, ShowByDefaultAlerterModule } from '#structures'; +import { BlockedUserModule, BlockedWordModule, DismissableAlertModule } from '#structures'; import { parseLimit } from '#util/args.js'; import { DatamuseQuery, fetchDatamuseRaw } from '#util/datamuse.js'; import { CommandError } from '#util/error.js'; -import { firstUpperCase, pickRandom, trimArray } from '#util/index.js'; +import { firstUpperCase, trimArray } from '#util/index.js'; +import { UseModeration } from '../../hooks/contentModeration.js'; +import { UseFeedbackAlert } from '../../hooks/dismissableAlert.js'; @injectable() export default class extends Command { public constructor( @inject(BlockedWordModule) public readonly blockedWord: BlockedWordModule, @inject(BlockedUserModule) public readonly blockedUser: BlockedUserModule, - @inject(ShowByDefaultAlerterModule) public readonly showByDefaultAlerter: ShowByDefaultAlerterModule, + @inject(DismissableAlertModule) public readonly dismissableAlertService: DismissableAlertModule, ) { super(); } - private async moderation(interaction: InteractionParam, args: ArgsParam, lng: LocaleParam): Promise { - if (this.blockedWord.check(args.word)) { - throw new CommandError( - pickRandom(i18n.t('common.errors.blocked_word', { lng, returnObjects: true }) as string[]), - ); - } - - const ban = this.blockedUser.check(interaction.user.id); - if (ban) { - throw new CommandError(i18n.t('common.errors.banned', { lng, reason: ban })); - } - } - + @UseModeration() + @UseFeedbackAlert() public override async chatInput( interaction: InteractionParam, args: ArgsParam, lng: LocaleParam, ): Promise { - await this.moderation(interaction, args, lng); - await interaction.deferReply({ ephemeral: args.hide ?? false }); const limit = parseLimit(args.limit, lng); @@ -85,14 +74,5 @@ export default class extends Command }); await interaction.editReply(content.slice(0, 2_000)); - - if (!(await this.showByDefaultAlerter.beenAlerted(interaction.user.id))) { - await this.showByDefaultAlerter.add(interaction.user.id); - await interaction.followUp({ - content: - 'As of , various Thoth commands will no longer automatically hide their response. Set the `hide` option to `True` to hide command responses from other users.', - ephemeral: true, - }); - } } } diff --git a/apps/bot/src/commands/general/triggers.ts b/apps/bot/src/commands/general/triggers.ts index a25f7b9d..954c5b8f 100644 --- a/apps/bot/src/commands/general/triggers.ts +++ b/apps/bot/src/commands/general/triggers.ts @@ -3,42 +3,31 @@ import { Command } from '@yuudachi/framework'; import type { ArgsParam, InteractionParam, LocaleParam } from '@yuudachi/framework/types'; import i18n from 'i18next'; import { inject, injectable } from 'tsyringe'; -import { BlockedUserModule, BlockedWordModule, ShowByDefaultAlerterModule } from '#structures'; +import { BlockedUserModule, BlockedWordModule, DismissableAlertModule } from '#structures'; import { parseLimit } from '#util/args.js'; import { DatamuseQuery, fetchDatamuse } from '#util/datamuse.js'; import { CommandError } from '#util/error.js'; -import { firstUpperCase, pickRandom, trimArray } from '#util/index.js'; +import { firstUpperCase, trimArray } from '#util/index.js'; +import { UseModeration } from '../../hooks/contentModeration.js'; +import { UseFeedbackAlert } from '../../hooks/dismissableAlert.js'; @injectable() export default class extends Command { public constructor( @inject(BlockedWordModule) public readonly blockedWord: BlockedWordModule, @inject(BlockedUserModule) public readonly blockedUser: BlockedUserModule, - @inject(ShowByDefaultAlerterModule) public readonly showByDefaultAlerter: ShowByDefaultAlerterModule, + @inject(DismissableAlertModule) public readonly dismissableAlertService: DismissableAlertModule, ) { super(); } - private async moderation(interaction: InteractionParam, args: ArgsParam, lng: LocaleParam): Promise { - if (this.blockedWord.check(args.word)) { - throw new CommandError( - pickRandom(i18n.t('common.errors.blocked_word', { lng, returnObjects: true }) as string[]), - ); - } - - const ban = this.blockedUser.check(interaction.user.id); - if (ban) { - throw new CommandError(i18n.t('common.errors.banned', { lng, reason: ban })); - } - } - + @UseModeration() + @UseFeedbackAlert() public override async chatInput( interaction: InteractionParam, args: ArgsParam, lng: LocaleParam, ): Promise { - await this.moderation(interaction, args, lng); - await interaction.deferReply({ ephemeral: args.hide ?? false }); const limit = parseLimit(args.limit, lng); @@ -55,14 +44,5 @@ export default class extends Command { lng, }), ); - - if (!(await this.showByDefaultAlerter.beenAlerted(interaction.user.id))) { - await this.showByDefaultAlerter.add(interaction.user.id); - await interaction.followUp({ - content: - 'As of , various Thoth commands will no longer automatically hide their response. Set the `hide` option to `True` to hide command responses from other users.', - ephemeral: true, - }); - } } } diff --git a/apps/bot/src/commands/general/word-of-the-day.ts b/apps/bot/src/commands/general/word-of-the-day.ts index 18414fbf..5f172fdd 100644 --- a/apps/bot/src/commands/general/word-of-the-day.ts +++ b/apps/bot/src/commands/general/word-of-the-day.ts @@ -6,18 +6,22 @@ import type { Entry } from 'mw-collegiate'; import { inject, injectable } from 'tsyringe'; import { fetchDefinition } from '#mw'; import { createWOTDContent, fetchWordOfTheDay } from '#mw/wotd.js'; -import { RedisManager, ShowByDefaultAlerterModule } from '#structures'; +import { RedisManager, DismissableAlertModule } from '#structures'; import { kRedis } from '#util/index.js'; +import { UseModeration } from '../../hooks/contentModeration.js'; +import { UseFeedbackAlert } from '../../hooks/dismissableAlert.js'; @injectable() export default class extends Command { public constructor( @inject(kRedis) public readonly redis: RedisManager, - @inject(ShowByDefaultAlerterModule) public readonly showByDefaultAlerter: ShowByDefaultAlerterModule, + @inject(DismissableAlertModule) public readonly dismissableAlertService: DismissableAlertModule, ) { super(); } + @UseModeration() + @UseFeedbackAlert() public override async chatInput( interaction: InteractionParam, args: ArgsParam, @@ -34,14 +38,5 @@ export default class extends Command, various Thoth commands will no longer automatically hide their response. Set the `hide` option to `True` to hide command responses from other users.', - ephemeral: true, - }); - } } } diff --git a/apps/bot/src/commands/util/feedback.ts b/apps/bot/src/commands/util/feedback.ts new file mode 100644 index 00000000..b3b91be7 --- /dev/null +++ b/apps/bot/src/commands/util/feedback.ts @@ -0,0 +1,51 @@ +import type FeedbackCommand from '@thoth/interactions/commands/util/feedback'; +import { Command, createButton, createMessageActionRow } from '@yuudachi/framework'; +import type { ArgsParam, InteractionParam, LocaleParam } from '@yuudachi/framework/types'; +import { ButtonStyle, Client } from 'discord.js'; +import { t } from 'i18next'; +import { type Sql } from 'postgres'; +import { inject, injectable } from 'tsyringe'; +import { BlockedUserModule, BlockedWordModule } from '#structures'; +import { kSQL } from '#util/symbols.js'; + +@injectable() +export default class extends Command { + public constructor( + @inject(Client) public readonly client: Client, + @inject(kSQL) public readonly sql: Sql, + @inject(BlockedUserModule) public readonly blockedUser: BlockedUserModule, + @inject(BlockedWordModule) public readonly blockedWord: BlockedWordModule, + ) { + super(); + } + + public override async chatInput( + interaction: InteractionParam, + _args: ArgsParam, + lng: LocaleParam, + ): Promise { + await interaction.reply({ + content: 'What type of feedback would you like to provide?', + ephemeral: true, + components: [ + createMessageActionRow([ + createButton({ + label: t('commands.feedback.meta.args.category.choices.bug', { lng }), + style: ButtonStyle.Danger, + customId: 'feedback:bug', + }), + createButton({ + label: t('commands.feedback.meta.args.category.choices.feature', { lng }), + style: ButtonStyle.Success, + customId: 'feedback:feature', + }), + createButton({ + label: t('commands.feedback.meta.args.category.choices.general', { lng }), + style: ButtonStyle.Secondary, + customId: 'feedback:general', + }), + ]), + ], + }); + } +} diff --git a/apps/bot/src/events/interactionCreate.ts b/apps/bot/src/events/interactionCreate.ts index 5063ed2c..09404391 100644 --- a/apps/bot/src/events/interactionCreate.ts +++ b/apps/bot/src/events/interactionCreate.ts @@ -1,12 +1,29 @@ +import { randomBytes } from 'node:crypto'; import process from 'node:process'; import type { Command } from '@yuudachi/framework'; -import { transformApplicationInteraction, kCommands } from '@yuudachi/framework'; +import { + transformApplicationInteraction, + kCommands, + createModalActionRow, + createTextComponent, + createButton, + createMessageActionRow, +} from '@yuudachi/framework'; import type { Event } from '@yuudachi/framework/types'; import { stripIndents } from 'common-tags'; -import type { AutocompleteInteraction, ChatInputCommandInteraction, Interaction } from 'discord.js'; +import type { + AutocompleteInteraction, + ButtonInteraction, + ChatInputCommandInteraction, + DiscordAPIError, + ForumChannel, + Interaction, + ModalSubmitInteraction, +} from 'discord.js'; import { ApplicationCommandType, bold, + ButtonStyle, channelMention, ChannelType, Client, @@ -15,15 +32,19 @@ import { CommandInteraction, Events, inlineCode, + TextInputStyle, WebhookClient, } from 'discord.js'; +import { t } from 'i18next'; +import type { Sql } from 'postgres'; import { Counter, Registry } from 'prom-client'; import { container, inject, injectable } from 'tsyringe'; import { logger } from '#logger'; import { RedisManager } from '#structures'; import { CommandError } from '#util/error.js'; -import { kRedis } from '#util/symbols.js'; +import { kRedis, kSQL } from '#util/symbols.js'; import { definitionAutoComplete } from '../autocomplete/definition.js'; +import { fetchFeedbackRow } from '../functions/feedback.js'; const registry = container.resolve>(Registry); const commandsMetrics = new Counter({ @@ -41,12 +62,21 @@ export default class implements Event { public constructor( public readonly client: Client, + @inject(kSQL) public readonly sql: Sql, @inject(kCommands) public readonly commands: Map, @inject(kRedis) public readonly redis: RedisManager, ) {} public execute(): void { this.client.on(this.event, async (interaction) => { + if (interaction.isButton()) { + return void this.handleButton(interaction as ButtonInteraction<'cached'>); + } + + if (interaction.isModalSubmit()) { + return void this.handleModalSubmit(interaction as ModalSubmitInteraction<'cached'>); + } + if (!interaction.isCommand() && !interaction.isAutocomplete()) { return; } @@ -143,6 +173,271 @@ export default class implements Event { } }); } + + private async handleButton(interaction: ButtonInteraction<'cached'>): Promise { + const lng = interaction.locale; + + // match to the regex feedback:bug | feedback:feature | feedback:general + const feedbackSubmitRes = /^feedback:(?bug|feature|general)$/.exec(interaction.customId); + if (feedbackSubmitRes) { + const type = feedbackSubmitRes.groups!.type!; + + return interaction.showModal({ + title: t(`commands.feedback.meta.args.category.choices.${type}`, { lng }), + custom_id: `feedback:${type}:submit`, + components: [ + createModalActionRow([ + createTextComponent({ + customId: 'subject', + style: TextInputStyle.Short, + label: t('common.titles.subject', { lng }), + required: false, + }), + ]), + createModalActionRow([ + createTextComponent({ + customId: 'description', + style: TextInputStyle.Paragraph, + label: t('common.titles.description', { lng }), + required: true, + }), + ]), + ], + }); + } + + // initial dm from owner to user + const feedbackDmRes = /^feedback:dm:(?[^:]+)$/.exec(interaction.customId); + if (feedbackDmRes) { + const submissionId = feedbackDmRes.groups!.submissionId!; + const row = await fetchFeedbackRow(submissionId); + if (!row) { + return void interaction.editReply({ + content: 'Row not found', + }); + } + + const user = await this.client.users.fetch(row.user_id); + const randomId = randomBytes(8).toString('hex'); + + await interaction.showModal({ + title: `DM to ${user.tag}`, + custom_id: randomId, + components: [ + createModalActionRow([ + createTextComponent({ + label: 'DM Content (auto signature)', + placeholder: 'Hello!', + style: TextInputStyle.Paragraph, + customId: 'content', + }), + ]), + ], + }); + + const collected = await interaction + .awaitModalSubmit({ + filter: (collected) => collected.user.id === interaction.user.id && collected.customId === randomId, + time: 120_000, // 2 minutes + }) + .catch(async () => { + try { + await interaction.editReply({ + content: 'You took too long to respond', + components: [], + }); + } catch (error_) { + const error = error_ as Error; + logger.error(error, error.message); + } + + return undefined; + }); + + if (!collected) return; + + const content = collected.fields.getTextInputValue('content'); + if (!content.length) + return void collected.editReply({ content: 'Content cannot be empty', components: [] }); + try { + await user.send({ + content: stripIndents` + Your feedback submission titled "${row.subject ?? 'No Subject'}" received a message! + + ${content} + - ${interaction.user.globalName ?? interaction.user.tag} + `, + components: [ + createMessageActionRow([ + createButton({ + label: 'Reply', + style: ButtonStyle.Secondary, + customId: `feedback:dm:reply:${submissionId}`, + }), + ]), + ], + }); + + await interaction.channel?.send({ + content: `📤 ${content}`, + }); + + return void collected.editReply({ + content: 'OK!', + }); + } catch (error_) { + const error = error_ as DiscordAPIError; + logger.warn(error, 'Error while sending DM'); + + return void collected.editReply({ + content: `Error while sending DM to user: ${error.message}`, + components: [], + }); + } + } + + // `feedback:dm:reply:${submissionId}`, + const feedbackDmReplyRes = /^feedback:dm:reply:(?.+)$/.exec(interaction.customId); + if (feedbackDmReplyRes) { + const submissionId = feedbackDmReplyRes.groups!.submissionId!; + const row = await fetchFeedbackRow(submissionId); + if (!row) { + return void interaction.editReply({ + content: 'Row not found', + }); + } + + const randomId = randomBytes(8).toString('hex'); + + await interaction.showModal({ + title: 'Reply to Thoth Developers', + custom_id: randomId, + components: [ + createModalActionRow([ + createTextComponent({ + label: 'Content', + placeholder: `Hello! Yes, that is what happened.`, + style: TextInputStyle.Paragraph, + customId: 'content', + }), + ]), + ], + }); + + const collected = await interaction + .awaitModalSubmit({ + filter: (collected) => collected.user.id === interaction.user.id && collected.customId === randomId, + time: 120_000, // 2 minutes + }) + .catch(async () => { + try { + await interaction.editReply({ + content: 'You took too long to respond', + components: [], + }); + } catch (error_) { + const error = error_ as Error; + logger.error(error, error.message); + } + + return undefined; + }); + + if (!collected) return; + + const content = collected.fields.getTextInputValue('content'); + if (!content.length) + return void collected.editReply({ content: 'Content cannot be empty', components: [] }); + try { + const post = await ((await this.client.channels.fetch(row.channel_id!)!) as ForumChannel).threads.fetch( + row.thread_id!, + ); + + await post?.send({ + content: `📥 ${content}`, + }); + + return void collected.editReply({ + content: 'Sent!', + }); + } catch (error_) { + const error = error_ as DiscordAPIError; + logger.warn(error, 'Error while sending DM'); + + return void collected.editReply({ + content: `Error while sending response to Thoth Developers: ${error.message}`, + components: [], + }); + } + } + } + + private async handleModalSubmit(interaction: ModalSubmitInteraction<'cached'>): Promise { + const lng = interaction.locale; + await interaction.deferReply({ ephemeral: true }); + + const feedbackSubmitRes = /^feedback:(?bug|feature|general):submit$/.exec(interaction.customId); + if (feedbackSubmitRes) { + const type = feedbackSubmitRes.groups!.type!; + const subject = interaction.fields.getTextInputValue('subject'); + const description = interaction.fields.getTextInputValue('description'); + + const feedbackForumChannel = (await this.client.channels.fetch( + process.env.FEEDBACK_FORUM_CHANNEL_ID!, + ))! as ForumChannel; + + // create table if not exists feedback_submission ( + // id uuid primary key default gen_random_uuid(), + // type text not null default 'general', + // user_id text not null, + // subject text, + // description text not null, + // channel_id text not null, + // thread_id text not null, + // created_at timestamptz not null default now() + // ); + const [{ id }] = await this.sql<[{ id: string }]>` + insert into feedback_submission (type, user_id, subject, description) + values (${type}, ${interaction.user.id}, ${subject ?? null}, ${description}) + returning id; + `; + + const post = await feedbackForumChannel.threads.create({ + name: `${interaction.user.tag}: ${subject ?? `${type} Feedback (No Subject)`}`, + appliedTags: feedbackForumChannel.availableTags.filter((tag) => tag.name === type).map((tag) => tag.id), + message: { + content: stripIndents` + Id: ${inlineCode(id)} + Type: ${inlineCode(type)} + User: ${interaction.user.toString()} (${interaction.user.id}) + ## ${subject ?? '(no subject)'} + ${description} + `, + components: [ + createMessageActionRow([ + createButton({ + label: 'Send DM to User', + style: ButtonStyle.Primary, + customId: `feedback:dm:${id}`, + }), + ]), + ], + }, + }); + + // set channel_id and thread_id + await this.sql` + update feedback_submission + set thread_id = ${post.id}, channel_id = ${post.parentId} + where id = ${id} + `; + + return void interaction.editReply({ + content: t('commands.feedback.received', { lng }), + components: [], + }); + } + } } const hook = new WebhookClient({ diff --git a/apps/bot/src/events/ready.ts b/apps/bot/src/events/ready.ts index dccf4457..dd7c4119 100644 --- a/apps/bot/src/events/ready.ts +++ b/apps/bot/src/events/ready.ts @@ -31,6 +31,7 @@ export default class implements Event { public execute(): void { this.client.on(this.event, async () => { logger.info(`Client is ready! Logged in as ${this.client.user!.tag}`); + await this.client.application.commands.fetch(); const guilds = this.client.guilds.cache.size; this.guildCount.set(guilds); diff --git a/apps/bot/src/functions/feedback.ts b/apps/bot/src/functions/feedback.ts new file mode 100644 index 00000000..92de2e07 --- /dev/null +++ b/apps/bot/src/functions/feedback.ts @@ -0,0 +1,36 @@ +import type { Sql } from 'postgres'; +import { container } from 'tsyringe'; +import { kSQL } from '#util/symbols.js'; + +export interface FeedbackRow { + channel_id?: string; + created_at: Date; + description: string; + id: string; + subject: string; + thread_id?: string; + type: 'bug' | 'feature' | 'general'; + user_id: string; +} + +export async function fetchFeedbackRow(id: string): Promise { + const sql = container.resolve>(kSQL); + + const rows = await sql<[FeedbackRow]>` + select + id, + type, + subject, + description, + user_id, + channel_id, + thread_id, + created_at + from + feedback_submission + where + id = ${id} + `; + + return rows[0] ?? null; +} diff --git a/apps/bot/src/hooks/contentModeration.ts b/apps/bot/src/hooks/contentModeration.ts index 200f723b..2fa47955 100644 --- a/apps/bot/src/hooks/contentModeration.ts +++ b/apps/bot/src/hooks/contentModeration.ts @@ -1,9 +1,35 @@ -import type { Command } from '@yuudachi/framework'; +/* eslint-disable func-names */ +import type { CommandPayload, ArgsParam, InteractionParam, LocaleParam } from '@yuudachi/framework/types'; +import i18n from 'i18next'; +import type { BlockedUserModule, BlockedWordModule } from '#structures'; +import { CommandError } from '#util/error.js'; +import { pickRandom } from '#util/index.js'; -export function useContentModeration(cmd: Command) { - cmd.chatInput = async (interaction, args, lng) => { - // todo: custom logic +/** + * Applies common content moderation checks to a chatInput command + */ +export function UseModeration() { + return function (_target: any, _propertyKey: string, descriptor: PropertyDescriptor) { + const originalMethod = descriptor.value; - await cmd.chatInput(interaction, args, lng); + descriptor.value = async function ( + this: { blockedUser: BlockedUserModule; blockedWord: BlockedWordModule }, + interaction: InteractionParam, + args: ArgsParam & { word?: string }, + lng: LocaleParam, + ) { + if ('word' in args && this.blockedWord.check(args.word)) { + throw new CommandError( + pickRandom(i18n.t('common.errors.blocked_word', { lng, returnObjects: true }) as string[]), + ); + } + + const ban = this.blockedUser.check(interaction.user.id); + if (ban) { + throw new CommandError(i18n.t('common.errors.banned', { lng, reason: ban })); + } + + return Reflect.apply(originalMethod, this, [interaction, args, lng]); + }; }; } diff --git a/apps/bot/src/hooks/dismissableAlert.ts b/apps/bot/src/hooks/dismissableAlert.ts new file mode 100644 index 00000000..883295cb --- /dev/null +++ b/apps/bot/src/hooks/dismissableAlert.ts @@ -0,0 +1,97 @@ +/* eslint-disable func-names */ +import { createButton, createMessageActionRow } from '@yuudachi/framework'; +import type { InteractionParam, LocaleParam } from '@yuudachi/framework/types'; +import { stripIndents } from 'common-tags'; +import { ButtonStyle } from 'discord.js'; +import { t } from 'i18next'; +import type { DismissableAlertModule } from '#structures'; + +const CAMPAIGN_MESSAGES = { + show_by_default_alert: + 'As of , various Thoth commands will no longer automatically hide their response. Set the `hide` option to `True` to hide command responses from other users.', +}; + +export function UseGenericTextAlert(campaign: keyof typeof CAMPAIGN_MESSAGES) { + const content = CAMPAIGN_MESSAGES[campaign]; + + return function (_target: any, _propertyKey: string, descriptor: PropertyDescriptor) { + const originalMethod = descriptor.value; + + descriptor.value = async function ( + this: { dismissableAlertService: DismissableAlertModule }, + interaction: InteractionParam, + ...args: any[] + ) { + const result = await Reflect.apply(originalMethod, this, [interaction, ...args]); + + if (!(await this.dismissableAlertService.beenAlerted(interaction.user.id, 'show_by_default_alert'))) { + await this.dismissableAlertService.add(interaction.user.id, 'show_by_default_alert'); + await interaction.followUp({ + content, + ephemeral: true, + }); + } + + return result; + }; + }; +} + +export function UseFeedbackAlert() { + return function (_target: any, _propertyKey: string, descriptor: PropertyDescriptor) { + const originalMethod = descriptor.value; + + descriptor.value = async function ( + this: { dismissableAlertService: DismissableAlertModule }, + interaction: InteractionParam, + args: unknown, + lng: LocaleParam, + ) { + const result = await Reflect.apply(originalMethod, this, [interaction, args, lng]); + + const feedbackCommand = interaction.client.application.commands.cache.find( + (command) => command.name === 'feedback', + ); + + if (!(await this.dismissableAlertService.beenAlerted(interaction.user.id, 'feedback'))) { + await this.dismissableAlertService.add(interaction.user.id, 'feedback'); + await interaction.followUp({ + content: stripIndents` + Hey there! 👋 + + I'd really appreciate it if you could take a moment or two out of your busy day to provide some feedback on Thoth. + Let it be a bug report, a feature request, or just general feedback, I'd love to hear it! + Your feedback helps me improve Thoth and provide a better experience for you and everyone who depends on it. + + Thank you, + Carter, Thoth Developer + + *This alert will not be shown again. To provide feedback in the future, use the command.* + `, + ephemeral: true, + components: [ + createMessageActionRow([ + createButton({ + label: t('commands.feedback.meta.args.category.choices.bug', { lng }), + style: ButtonStyle.Danger, + customId: 'feedback:bug', + }), + createButton({ + label: t('commands.feedback.meta.args.category.choices.feature', { lng }), + style: ButtonStyle.Success, + customId: 'feedback:feature', + }), + createButton({ + label: t('commands.feedback.meta.args.category.choices.general', { lng }), + style: ButtonStyle.Secondary, + customId: 'feedback:general', + }), + ]), + ], + }); + } + + return result; + }; + }; +} diff --git a/apps/bot/src/structures/ShowByDefaultAlerter.ts b/apps/bot/src/structures/DismissableAlert.ts similarity index 53% rename from apps/bot/src/structures/ShowByDefaultAlerter.ts rename to apps/bot/src/structures/DismissableAlert.ts index 57090967..39917fcf 100644 --- a/apps/bot/src/structures/ShowByDefaultAlerter.ts +++ b/apps/bot/src/structures/DismissableAlert.ts @@ -3,7 +3,7 @@ import { inject, injectable } from 'tsyringe'; import { kSQL } from '#util/symbols.js'; @injectable() -export class ShowByDefaultAlerterModule { +export class DismissableAlertModule { public constructor(@inject(kSQL) protected readonly sql: Sql) {} /** @@ -12,19 +12,19 @@ export class ShowByDefaultAlerterModule { * @param userId - The user to check * @returns boolean */ - public async beenAlerted(userId: string): Promise { + public async beenAlerted(userId: string, campaign: string): Promise { const [row] = await this.sql< [ { user_id: string; }?, ] - >`select user_id from public.show_by_default_alert where user_id = ${userId} limit 1`; + >`select user_id from public.dismissable_alert where user_id = ${userId} and campaign = ${campaign} limit 1`; return Boolean(row); } - public async add(userId: string) { - await this.sql`insert into public.show_by_default_alert (user_id) values (${userId})`; + public async add(userId: string, campaign: string): Promise { + await this.sql`insert into public.dismissable_alert (user_id, campaign) values (${userId}, ${campaign})`; } } diff --git a/apps/bot/src/structures/index.ts b/apps/bot/src/structures/index.ts index e4bf997e..071542c0 100644 --- a/apps/bot/src/structures/index.ts +++ b/apps/bot/src/structures/index.ts @@ -1,4 +1,4 @@ export * from './BlockedUser.js'; export * from './RedisManager.js'; export * from './BlockedWord.js'; -export * from './ShowByDefaultAlerter.js'; +export * from './DismissableAlert.js'; diff --git a/commands.lock.json b/commands.lock.json index cf7f931c..694900d4 100644 --- a/commands.lock.json +++ b/commands.lock.json @@ -142,6 +142,34 @@ "contexts": [0, 1, 2], "integration_types": [0, 1] }, + { + "name": "feedback", + "name_localizations": { + "en-US": "feedback", + "en-GB": "feedback", + "de": "feedback", + "es-ES": "feedback", + "ja": "feedback", + "ko": "feedback", + "pl": "feedback", + "zh-CN": "feedback", + "zh-TW": "feedback" + }, + "description": "Submit feedback for the Thoth developers.", + "description_localizations": { + "en-US": "Submit feedback for the Thoth developers.", + "en-GB": "Submit feedback for the Thoth developers.", + "de": "Submit feedback for the Thoth developers.", + "es-ES": "Submit feedback for the Thoth developers.", + "ja": "Submit feedback for the Thoth developers.", + "ko": "Submit feedback for the Thoth developers.", + "pl": "Submit feedback for the Thoth developers.", + "zh-CN": "Submit feedback for the Thoth developers.", + "zh-TW": "Submit feedback for the Thoth developers." + }, + "contexts": [0, 1, 2], + "integration_types": [0, 1] + }, { "name": "holonyms", "name_localizations": { diff --git a/docker/postgres/init.sql b/docker/postgres/init.sql index 110f5e84..b71d2717 100644 --- a/docker/postgres/init.sql +++ b/docker/postgres/init.sql @@ -1,6 +1,6 @@ -CREATE EXTENSION IF NOT EXISTS pgcrypto; +create extension if not exists pgcrypto; -CREATE TABLE IF NOT EXISTS wotd ( +create table if not exists wotd ( id UUID PRIMARY KEY DEFAULT gen_random_uuid(), guild_id BIGINT NOT NULL UNIQUE, created_by BIGINT NOT NULL, @@ -15,14 +15,14 @@ COMMENT ON COLUMN wotd.webhook_id IS 'The ID of the webhook used to post the wor COMMENT ON COLUMN wotd.webhook_token IS 'The token of the webhook used to post the word of the day'; COMMENT ON COLUMN wotd.created_at IS 'The date and time this config was created'; -CREATE TABLE IF NOT EXISTS wotd_history ( +create table if not exists wotd_history ( id UUID PRIMARY KEY DEFAULT gen_random_uuid(), word TEXT UNIQUE NOT NULL, created_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT NOW(), updated_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT NOW() ); -CREATE TABLE IF NOT EXISTS thoth_bans ( +create table if not exists thoth_bans ( id UUID PRIMARY KEY DEFAULT gen_random_uuid(), user_id BIGINT NOT NULL, reason TEXT NOT NULL, @@ -42,3 +42,24 @@ create table if not exists show_by_default_alert ( user_id text primary key not null, created_at timestamptz not null default now() ); + +create table if not exists dismissable_alert ( + id uuid primary key default gen_random_uuid(), + user_id text not null, + campaign text not null, + created_at timestamptz not null default now() +); +create index campaign on dismissable_alert (user_id, campaign); + +insert into dismissable_alert (user_id, campaign, created_at) select user_id, 'show_by_default_alert', created_at from show_by_default_alert; + +create table if not exists feedback_submission ( + id uuid primary key default gen_random_uuid(), + type text not null default 'general', + user_id text not null, + subject text, + description text not null, + channel_id text, + thread_id text, + created_at timestamptz not null default now() +); diff --git a/eslint.config.js b/eslint.config.js index dadf4f65..5a6b35c0 100644 --- a/eslint.config.js +++ b/eslint.config.js @@ -38,6 +38,7 @@ const typeScriptRuleset = merge(...typescript, { }, }, ], + '@typescript-eslint/consistent-type-imports': 0, }, settings: { 'import/resolver': { diff --git a/packages/interactions/src/commands/util/feedback.ts b/packages/interactions/src/commands/util/feedback.ts new file mode 100644 index 00000000..ec20a6e5 --- /dev/null +++ b/packages/interactions/src/commands/util/feedback.ts @@ -0,0 +1,13 @@ +import i18n from 'i18next'; +import { fetchDataLocalizations } from '../../index.js'; + +const FeedbackCommand = { + name: i18n.t('commands.feedback.meta.name'), + name_localizations: fetchDataLocalizations('commands.feedback.meta.name'), + description: i18n.t('commands.feedback.meta.description'), + description_localizations: fetchDataLocalizations('commands.feedback.meta.description'), + contexts: [0, 1, 2], + integration_types: [0, 1], +} as const; + +export default FeedbackCommand; diff --git a/packages/locales/en-US/translation.json b/packages/locales/en-US/translation.json index 8b7b4b91..55cd7426 100644 --- a/packages/locales/en-US/translation.json +++ b/packages/locales/en-US/translation.json @@ -44,7 +44,9 @@ ] }, "titles": { - "definitions": "Definitions" + "definitions": "Definitions", + "subject": "Subject", + "description": "Description" } }, "commands": { @@ -77,6 +79,28 @@ } } }, + "feedback": { + "meta": { + "name": "feedback", + "description": "Submit feedback for the Thoth developers.", + "args": { + "category": { + "name": "category", + "description": "The subcategory of your feedback.", + "choices": { + "bug": "Report a bug.", + "feature": "Request a feature.", + "general": "General feedback." + } + }, + "content": { + "name": "content", + "description": "The content of your feedback." + } + } + }, + "received": "We've received your feedback! If necessary, we'll reach out. Thank you for helping us improve Thoth. :)" + }, "homophones": { "meta": { "name": "homophones",