Skip to content

Commit

Permalink
feat: adds captcha verification for users (#83)
Browse files Browse the repository at this point in the history
Changes:
- adds a new verify plugin that is enabled by default.
- on /plugin verify it will ask for the verification role and post a message in the channel.
- clicking on the Verify button will prompt with a modal.
- users get a random emoji representing a mood, they'll need to type that for validation.
  • Loading branch information
en3sis authored Feb 8, 2024
1 parent 5e5c7ff commit ac6dce2
Show file tree
Hide file tree
Showing 8 changed files with 167 additions and 9 deletions.
2 changes: 1 addition & 1 deletion docker-compose.dev.yaml
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
version: '3'
services:
redis:
container_name: redis
container_name: redis-hans
image: redis/redis-stack:6.2.6-v8
ports:
- 8888:8001
Expand Down
11 changes: 11 additions & 0 deletions src/commands/plugins.ts
Original file line number Diff line number Diff line change
Expand Up @@ -57,6 +57,17 @@ module.exports = {
.setRequired(true),
),
)
.addSubcommand((command) =>
command.setName('verify').setDescription('Enables the verification system for the server'),
)
.addSubcommand((command) =>
command.addRoleOption((option) =>
option
.setName('role')
.setDescription('The role to be given to the verified user')
.setRequired(true),
),
)
.addSubcommand((command) =>
command
.setName('threads')
Expand Down
16 changes: 15 additions & 1 deletion src/controllers/bot/plugins.controller.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@ import { GuildPlugin } from './guilds.controller'
* @param {string} guild_id - The ID of the guild to insert the plugins for.
* @returns {Promise<void>} - A Promise that resolves when the plugins have been inserted.
*/
export const insertGuildPlugin = async (guild_id: string) => {
export const insertGuildPlugin = async (guild_id: string): Promise<void> => {
try {
const plugins = await supabase.from('plugins').select('*')

Expand Down Expand Up @@ -142,6 +142,20 @@ export const toggleGuildPlugin = async (
}
}

export const updateMetadataGuildPlugin = async (metadata: any, name: string, guildId: string) => {
try {
const { error } = await supabase
.from('guilds_plugins')
.update({ metadata })
.eq('name', name)
.eq('owner', guildId)

if (error) throw error
} catch (error) {
console.log('❌ ERROR: updateMetadataGuildPlugin(): ', error)
}
}

/**
* Returns an array of plugin names and values for use in a select menu.
* @returns {Array<{name: string, value: string}>} - An array of plugin names and values.
Expand Down
115 changes: 115 additions & 0 deletions src/controllers/plugins/verify.controller.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,115 @@
import {
ActionRowBuilder,
ButtonBuilder,
ButtonStyle,
ChatInputCommandInteraction,
GuildMember,
Interaction,
InteractionType,
ModalBuilder,
TextChannel,
TextInputBuilder,
TextInputStyle,
} from 'discord.js'
import { Hans } from '../..'
import { getFromCache, setToCache } from '../../libs/node-cache'
import { updateMetadataGuildPlugin } from '../bot/plugins.controller'

export const verifyGuildPluginSettings = async (interaction: ChatInputCommandInteraction) => {
const role = interaction.options.get('role')?.value as string

const guildRole = interaction.guild.roles.cache.get(role)

await updateMetadataGuildPlugin({ role }, 'verify', interaction.guildId)

const button = new ButtonBuilder()
.setCustomId('open_verify_modal')
.setLabel('Verify')
.setStyle(ButtonStyle.Primary)

// Send the button to a channel
await (interaction.channel as TextChannel).send({
content: `
## 🤖 Captcha Verification
Please click the button below to verify that you are human.
`,
components: [new ActionRowBuilder<ButtonBuilder>().addComponents(button)],
})

await interaction.editReply({
content: `Verify plugin settings updated. All verified users will receive the ${guildRole} role.`,
})
}

export const verifyModal = async (interaction: Interaction) => {
const emojiMap = [
{ emoji: '😊', name: 'happy' },
{ emoji: '😢', name: 'sad' },
{ emoji: '😡', name: 'angry' },
]

if (interaction.isButton()) {
if (interaction.customId !== 'open_verify_modal') return

const randomEmoji = emojiMap[Math.floor(Math.random() * emojiMap.length)]
setToCache('randomEmoji', randomEmoji)

// Define a modal
const modal = new ModalBuilder()
.setCustomId('verify_modal')
.setTitle('Captcha verification')
.addComponents(
new ActionRowBuilder<TextInputBuilder>().addComponents(
new TextInputBuilder()
.setCustomId('input1')
.setLabel(`Which emotion does this emoji ${randomEmoji.emoji} represent?`)
.setPlaceholder('Type the name of the emotion.')
.setStyle(TextInputStyle.Short),
),
)

// Show the modal to the user
await interaction.showModal(modal)
}
}

export const verifyModalSubmit = async (interaction: Interaction) => {
if (interaction.type === InteractionType.ModalSubmit) {
if (interaction.customId !== 'verify_modal') return
const input = interaction.fields.getTextInputValue('input1').toLocaleLowerCase()
const randomEmoji = getFromCache('randomEmoji') as { emoji: string; name: string }

if (input !== randomEmoji.name) {
await interaction.reply({
content: `❌ Failed to verify that you are human. Please try again.`,
ephemeral: true,
})
} else {
const member = interaction.member

// Ensure the member is indeed a GuildMember object to access the GuildMemberRoleManager
if (member instanceof GuildMember) {
const guildPluginSettings = await Hans.guildPluginSettings(interaction.guildId, 'verify')

const guildRole = interaction.guild.roles.cache.get(guildPluginSettings.metadata.role)

if (guildRole) {
await member.roles
.add(guildRole)
.then(() => interaction.reply({ content: '✅ You are now verified.', ephemeral: true }))
.catch((error) => {
// Handle errors, like missing permissions
console.error(error)
interaction.reply({ content: 'Failed to add the role.', ephemeral: true })
})
} else {
// Role not found
interaction.reply({ content: 'Role not found.', ephemeral: true })
}
} else {
// Member is not a GuildMember object
interaction.reply({ content: 'Could not resolve member details.', ephemeral: true })
}
}
}
}
14 changes: 12 additions & 2 deletions src/events/interactionCreate.ts
Original file line number Diff line number Diff line change
@@ -1,13 +1,23 @@
import { Client, Interaction } from 'discord.js'
import { Client, Interaction, InteractionType } from 'discord.js'
import { verifyModal, verifyModalSubmit } from '../controllers/plugins/verify.controller'
import { ERROR_COLOR } from '../utils/colors'
import { reportErrorToMonitoring } from '../utils/monitoring'

module.exports = {
name: 'interactionCreate',
once: false,
enabled: true,
// eslint-disable-next-line @typescript-eslint/no-explicit-any
async execute(Hans: Client, interaction: Interaction) {
// Handle button interactions
if (interaction.isButton()) {
await verifyModal(interaction)
}

// Handle modal submit interactions
if (interaction.type === InteractionType.ModalSubmit) {
await verifyModalSubmit(interaction)
}

if (!interaction.isCommand()) return

const command = Hans.commands.get(interaction.commandName)
Expand Down
8 changes: 4 additions & 4 deletions src/events/messageCreate.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,16 +17,16 @@ module.exports = {
// Not logged in
if (message.client.user === null) return

// Plugins
// +=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=+
await threadAutoCreate(message, await Hans.guildPluginSettings(message.guildId, 'threads'))

// +=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=+
// ==-=-=-=-=-=-=-=-= DEVELOPMENT =-=-=-=-=-=-=-= +
// +=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=+
// Uncomment for development and do your tests/debug there, !!!DON'T COMMIT!!!
// TODO: Requires refactor, find a way to dynamically load the file if in development
// _messageCreate(Hans, message, command, args)

// Plugins
// +=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=+
await threadAutoCreate(message, await Hans.guildPluginSettings(message.guildId, 'threads'))
} catch (error) {
console.log('❌ messageCreate(): ', error)
}
Expand Down
2 changes: 1 addition & 1 deletion src/libs/node-cache.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@ export const setToCache = (key: string, value: string | object, ttl = 3) => {
}

// Creates a function for getting an item from the cache
export const getFromCache = (key: string): never | null => {
export const getFromCache = (key: string): string | object | null => {
const value = CACHE.get(key)
return value ? JSON.parse(value as string) : null
}
8 changes: 8 additions & 0 deletions src/models/plugins.model.ts
Original file line number Diff line number Diff line change
Expand Up @@ -66,6 +66,11 @@ export const pluginsList: Record<string, GenericPluginParts> = {
'Provides quick Add to calendar links for Google & Outlook for the events you are subscribed to',
category: 'productivity',
},
verify: {
...genericStructure,
description: 'Verifies that the user is human.',
category: 'moderation',
},
// messageReactionAdd: {
// ...genericStructure,
// description: 'Notifies when a reaction is added to a message.',
Expand Down Expand Up @@ -108,5 +113,8 @@ export const initialGuildPluginState = () => {
events: {
default_enabled: true,
},
verify: {
default_enabled: false,
},
}
}

0 comments on commit ac6dce2

Please sign in to comment.