From 5e01ba646713fdf196eb37c74c4649bd0ca5be7c Mon Sep 17 00:00:00 2001 From: Erik Zuuring Date: Wed, 24 Apr 2024 14:27:23 +0300 Subject: [PATCH 1/7] Advanced invite flow ### Notes This adds a new feature to the `/create-invite` flow which prompts the inviter to select 3 main questions: 1. Is this a Thesis employee or contractor? 2. Which discipline(s) will this person be working with? 3. Which project(s) will this person be working with? After those interactions are complete, an invite is generated with 7 days expiry and 1 use to the channel with `project-discipline` matching. Still WIP ### To-do --- discord-scripts/invite-management.ts | 196 ++++++++++++++++++++++++--- 1 file changed, 178 insertions(+), 18 deletions(-) diff --git a/discord-scripts/invite-management.ts b/discord-scripts/invite-management.ts index 0354bdf6..b92c8bd6 100644 --- a/discord-scripts/invite-management.ts +++ b/discord-scripts/invite-management.ts @@ -1,9 +1,79 @@ import { Robot } from "hubot" -import { Client, TextChannel } from "discord.js" +import { + Client, + TextChannel, + ButtonBuilder, + ButtonStyle, + ActionRowBuilder, +} from "discord.js" import { DAY, MILLISECOND, WEEK } from "../lib/globals.ts" const EXTERNAL_AUDIT_CHANNEL_REGEXP = /^ext-(?.*)-audit$/ +const employeeQuestion = new ActionRowBuilder().addComponents( + new ButtonBuilder() + .setCustomId("employee-yes") + .setLabel("Yes") + .setStyle(ButtonStyle.Success), + new ButtonBuilder() + .setCustomId("employee-no") + .setLabel("No") + .setStyle(ButtonStyle.Danger), +) + +const disciplineQuestion = new ActionRowBuilder().addComponents( + new ButtonBuilder() + .setCustomId("engineering") + .setLabel("Engineering") + .setStyle(ButtonStyle.Primary), + new ButtonBuilder() + .setCustomId("design") + .setLabel("Design") + .setStyle(ButtonStyle.Primary), + new ButtonBuilder() + .setCustomId("product") + .setLabel("Product") + .setStyle(ButtonStyle.Primary), + new ButtonBuilder() + .setCustomId("marketing") + .setLabel("Marketing") + .setStyle(ButtonStyle.Primary), + new ButtonBuilder() + .setCustomId("business") + .setLabel("Business Development") + .setStyle(ButtonStyle.Primary), +) + +const projectQuestion = new ActionRowBuilder().addComponents( + new ButtonBuilder() + .setCustomId("mezo") + .setLabel("Mezo") + .setStyle(ButtonStyle.Primary), + new ButtonBuilder() + .setCustomId("acre") + .setLabel("Acre") + .setStyle(ButtonStyle.Primary), + new ButtonBuilder() + .setCustomId("embody") + .setLabel("Embody") + .setStyle(ButtonStyle.Primary), + new ButtonBuilder() + .setCustomId("tbtc") + .setLabel("tBTC") + .setStyle(ButtonStyle.Primary), + new ButtonBuilder() + .setCustomId("thesis") + .setLabel("Thesis*") + .setStyle(ButtonStyle.Primary), +) + +interface Invitation { + project?: string + discipline?: string +} + +const invitation: Invitation = {} + async function createInvite( channel: TextChannel, maxAge = (1 * WEEK) / MILLISECOND, @@ -52,26 +122,116 @@ export default async function sendInvite(discordClient: Client, robot: Robot) { return } - try { - const { channel } = interaction - if (channel instanceof TextChannel) { - const invite = await createInvite(channel) - if (invite) { - await interaction.reply( - `Here is your invite link: ${ - invite.url - }\nThis invite expires in ${ - (invite.maxAge / DAY) * MILLISECOND - } days and has a maximum of ${invite.maxUses} uses.`, - ) - } - } else { + // Reply to the interaction asking if this is a thesis employee/contractor + await interaction.reply({ + content: "**Is this a Thesis employee or contractor?**", + components: [employeeQuestion], + ephemeral: true, + }) + }) + + discordClient.on("interactionCreate", async (interaction) => { + if (!interaction.isButton()) return + + if (!interaction.guild) { + await interaction.reply( + "This interaction can only be used in a server.", + ) + return + } + + const { channel } = interaction + if (!(channel instanceof TextChannel)) { + await interaction.reply( + "Cannot create an invite for this type of channel.", + ) + return + } + // generate an invite for base role + if (interaction.customId === "employee-no") { + try { + invitation.project = "Thesis Base" + const invite = await createInvite( + channel, + (1 * WEEK) / MILLISECOND, + 1, + ) + const internalInviteExpiry = Math.floor( + Date.now() / 1000 + invite.maxAge, + ) + await interaction.update({ + content: `**We've generated an invite code for @${invitation.project}} role**, : ${invite.url}\nThis invite expires and has a maximum of ${invite.maxUses} uses.`, + components: [], + }) + } catch (error) { + console.error(error) + await interaction.reply( + "An error occurred while creating the invite.", + ) + } + } + if (interaction.customId === "employee-yes") { + await interaction.update({ + content: + "**For a Thesis employee: which discipline(s) will this person be working with?**", + components: [disciplineQuestion], + }) + invitation.discipline = await interaction.customId + } + + if ( + interaction.customId === "engineering" || + interaction.customId === "design" || + interaction.customId === "product" || + interaction.customId === "marketing" || + interaction.customId === "business" + ) { + invitation.discipline = await interaction.customId + robot.logger.info(invitation.discipline) + await interaction.update({ + content: + "**For a Thesis employee: which projects will this person be working on?**", + components: [projectQuestion], + }) + } + + if ( + interaction.customId === "mezo" || + interaction.customId === "acre" || + interaction.customId === "embody" || + interaction.customId === "tbtc" || + interaction.customId === "thesis" + ) { + invitation.project = await interaction.customId + robot.logger.info(invitation.project) + robot.logger.info(invitation.project, invitation.discipline) + const targetChannelName = `πŸ”’${invitation.project ?? ""}-${ + invitation.discipline ?? "" + }` + robot.logger.info(targetChannelName) + const matchChannel = interaction.guild.channels.cache.find( + (c) => c.name === targetChannelName, + ) as TextChannel + robot.logger.info(matchChannel) + try { + const invite = await createInvite( + matchChannel, + (1 * WEEK) / MILLISECOND, + 1, + ) + const internalInviteExpiry = Math.floor( + Date.now() / 1000 + invite.maxAge, + ) + await interaction.update({ + content: `**We've generated an invite code for <@${interaction.customId}> role**, : ${invite.url}\nThis invite expires and has a maximum of ${invite.maxUses} uses.`, + components: [], + }) + } catch (error) { + console.error(error) await interaction.reply( - "Cannot create an invite for this type of channel.", + "An error occurred while creating the invite.", ) } - } catch (error) { - await interaction.reply("An error occurred while creating the invite.") } }) From 61875b69f394b14b0856351a14e1560836442008 Mon Sep 17 00:00:00 2001 From: Erik Zuuring Date: Wed, 24 Apr 2024 15:53:38 +0300 Subject: [PATCH 2/7] WIP Role map from invite This is still not working as intended, just some exploratory code on getting role assignment to work for project + discpline cases rather than audit channels. Based off invite counter this one is not so clear cut. Also we must have invite uses always be greater than 1, or else invite count gets wiped as soon as member join, preventing us from checking which invite code was used. --- discord-scripts/invite-management.ts | 100 +++++++++++++++++++++++++-- 1 file changed, 96 insertions(+), 4 deletions(-) diff --git a/discord-scripts/invite-management.ts b/discord-scripts/invite-management.ts index b92c8bd6..387a4983 100644 --- a/discord-scripts/invite-management.ts +++ b/discord-scripts/invite-management.ts @@ -1,5 +1,6 @@ import { Robot } from "hubot" import { + GuildMember, Client, TextChannel, ButtonBuilder, @@ -10,6 +11,8 @@ import { DAY, MILLISECOND, WEEK } from "../lib/globals.ts" const EXTERNAL_AUDIT_CHANNEL_REGEXP = /^ext-(?.*)-audit$/ +const guildInvites: { [guildId: string]: { [inviteCode: string]: number } } = {} + const employeeQuestion = new ActionRowBuilder().addComponents( new ButtonBuilder() .setCustomId("employee-yes") @@ -154,7 +157,7 @@ export default async function sendInvite(discordClient: Client, robot: Robot) { const invite = await createInvite( channel, (1 * WEEK) / MILLISECOND, - 1, + 2, ) const internalInviteExpiry = Math.floor( Date.now() / 1000 + invite.maxAge, @@ -164,7 +167,7 @@ export default async function sendInvite(discordClient: Client, robot: Robot) { components: [], }) } catch (error) { - console.error(error) + robot.logger.error(error) await interaction.reply( "An error occurred while creating the invite.", ) @@ -217,7 +220,7 @@ export default async function sendInvite(discordClient: Client, robot: Robot) { const invite = await createInvite( matchChannel, (1 * WEEK) / MILLISECOND, - 1, + 2, ) const internalInviteExpiry = Math.floor( Date.now() / 1000 + invite.maxAge, @@ -227,7 +230,7 @@ export default async function sendInvite(discordClient: Client, robot: Robot) { components: [], }) } catch (error) { - console.error(error) + robot.logger.error(error) await interaction.reply( "An error occurred while creating the invite.", ) @@ -298,5 +301,94 @@ export default async function sendInvite(discordClient: Client, robot: Robot) { } } }) + + // Check list of invites and compare when a new user joins which invite code has been used, then assign role based on channel.name.match TO DO: Modify this to work with potentially all invites + discordClient.on("guildMemberAdd", async (member: GuildMember) => { + const oldInvites = guildInvites[member.guild.id] || {} + const fetchedInvites = await member.guild.invites.fetch() + + const newInvites: { [code: string]: number } = {} + fetchedInvites.forEach((invite) => { + newInvites[invite.code] = invite.uses ?? 0 + }) + + guildInvites[member.guild.id] = newInvites + + const usedInvite = fetchedInvites.find((fetchedInvite) => { + const oldUses = oldInvites[fetchedInvite.code] || 0 + return (fetchedInvite.uses ?? 0) > oldUses + }) + + if (usedInvite && usedInvite.channelId) { + const channel = member.guild.channels.cache.get( + usedInvite.channelId, + ) as TextChannel + if (channel) { + robot.logger.info(channel) + const auditChannelMatch = channel.name.match(/(ext|int)-(.*)-audit/) + if (auditChannelMatch) { + const clientName = auditChannelMatch + ? auditChannelMatch[2] + .replace(/-/g, " ") + .split(" ") + .map( + (word) => + word.charAt(0).toUpperCase() + + word.slice(1).toLowerCase(), + ) + .join(" ") + : "" + const auditType = + auditChannelMatch[1] === "ext" ? "External" : "Internal" + const roleName = `Defense ${auditType}: ${clientName}` + + const role = member.guild.roles.cache.find( + (r) => r.name.toLowerCase() === roleName.toLowerCase(), + ) + if (role) { + await member.roles.add(role) + } + robot.logger.info( + `Invite code used: ${ + usedInvite ? usedInvite.code : "None" + }, Username joined: ${ + member.displayName + }, Role assignments: ${roleName}`, + ) + } + + if (!auditChannelMatch) { + const rolesToAssign = channel.name.split("-") + + if (rolesToAssign.length >= 2) { + const role1Name = rolesToAssign[0].trim() + const role2Name = rolesToAssign[1].trim() + const role1 = member.guild.roles.cache.find( + (r) => r.name.toLowerCase() === role1Name.toLowerCase(), + ) + if (role1) { + await member.roles.add(role1) + } + + const role2 = member.guild.roles.cache.find( + (r) => r.name.toLowerCase() === role2Name.toLowerCase(), + ) + if (role2) { + await member.roles.add(role2) + } + robot.logger.info( + `Invite code used: ${ + usedInvite ? usedInvite.code : "None" + }, Username joined: ${ + member.displayName + }, Role assignments: ${role1} ${role2}`, + ) + } + } + } + } else { + robot.logger.info("Could not find which invite was used.") + } + }) } } From 012aa7684893649babf9ddd526c6778c2f996442 Mon Sep 17 00:00:00 2001 From: Erik Zuuring Date: Wed, 24 Apr 2024 16:23:06 +0300 Subject: [PATCH 3/7] Fix rolemapping Seems like those :lock: emojis need to be removed in order to get correct role mapping, working as it should. --- discord-scripts/invite-management.ts | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/discord-scripts/invite-management.ts b/discord-scripts/invite-management.ts index 387a4983..78cb7f56 100644 --- a/discord-scripts/invite-management.ts +++ b/discord-scripts/invite-management.ts @@ -319,6 +319,8 @@ export default async function sendInvite(discordClient: Client, robot: Robot) { return (fetchedInvite.uses ?? 0) > oldUses }) + robot.logger.info(oldInvites) + robot.logger.info(newInvites) if (usedInvite && usedInvite.channelId) { const channel = member.guild.channels.cache.get( usedInvite.channelId, @@ -358,7 +360,8 @@ export default async function sendInvite(discordClient: Client, robot: Robot) { } if (!auditChannelMatch) { - const rolesToAssign = channel.name.split("-") + const cleanChannelName = channel.name.replace(/πŸ”’/g, "").trim() + const rolesToAssign = cleanChannelName.split("-") if (rolesToAssign.length >= 2) { const role1Name = rolesToAssign[0].trim() From 8409eed9221458707fcb976bec67e047d98dc81a Mon Sep 17 00:00:00 2001 From: Erik Zuuring Date: Mon, 29 Apr 2024 13:45:09 +0300 Subject: [PATCH 4/7] Fix old invites This commit resolves an issue where the old invites were not loading properly, now will be stored on runtime and the list updated anytime the invite command is run, so we can store it in cache before invite is claimed. --- discord-scripts/invite-management.ts | 44 ++++++++++++++++++++++++++-- 1 file changed, 42 insertions(+), 2 deletions(-) diff --git a/discord-scripts/invite-management.ts b/discord-scripts/invite-management.ts index 78cb7f56..5655c782 100644 --- a/discord-scripts/invite-management.ts +++ b/discord-scripts/invite-management.ts @@ -95,10 +95,34 @@ async function createInvite( } } +async function listInvites(discordClient: Client, robot: Robot): Promise { + discordClient.guilds.cache.forEach(async (guild) => { + try { + const fetchInvites = await guild.invites.fetch() + if (fetchInvites) { + guildInvites[guild.id] ??= {} + + fetchInvites.forEach((invite) => { + guildInvites[guild.id][invite.code] = invite.uses ?? 0 + }) + } + } catch (error) { + robot.logger.error( + `Failed to fetch invites for guild ${guild.name}: ${error}`, + ) + } + }) +} + export default async function sendInvite(discordClient: Client, robot: Robot) { const { application } = discordClient if (application) { + // Grab list of guild invites on runtime + setTimeout(async () => { + await listInvites(discordClient, robot) + }, 1000) + // Check if create-invite command already exists, if not create it const existingInviteCommand = (await application.commands.fetch()).find( (command) => command.name === "create-invite", @@ -159,6 +183,8 @@ export default async function sendInvite(discordClient: Client, robot: Robot) { (1 * WEEK) / MILLISECOND, 2, ) + // Update list of invites after new invite is created + await listInvites(discordClient, robot) const internalInviteExpiry = Math.floor( Date.now() / 1000 + invite.maxAge, ) @@ -222,6 +248,8 @@ export default async function sendInvite(discordClient: Client, robot: Robot) { (1 * WEEK) / MILLISECOND, 2, ) + // Update list of invites after new invite is created + await listInvites(discordClient, robot) const internalInviteExpiry = Math.floor( Date.now() / 1000 + invite.maxAge, ) @@ -248,6 +276,8 @@ export default async function sendInvite(discordClient: Client, robot: Robot) { ) { try { const defenseInvite = await createInvite(channel) + // Update list of invites after new invite is created + await listInvites(discordClient, robot) if (defenseInvite) { robot.logger.info( `New invite created for defense audit channel: ${channel.name}, URL: ${defenseInvite.url}`, @@ -319,8 +349,8 @@ export default async function sendInvite(discordClient: Client, robot: Robot) { return (fetchedInvite.uses ?? 0) > oldUses }) - robot.logger.info(oldInvites) - robot.logger.info(newInvites) + robot.logger.info("Old invites:", oldInvites) + robot.logger.info("new invites:", newInvites) if (usedInvite && usedInvite.channelId) { const channel = member.guild.channels.cache.get( usedInvite.channelId, @@ -363,6 +393,16 @@ export default async function sendInvite(discordClient: Client, robot: Robot) { const cleanChannelName = channel.name.replace(/πŸ”’/g, "").trim() const rolesToAssign = cleanChannelName.split("-") + if (rolesToAssign.includes("thesis base")) { + robot.logger.info("Thesis base role detected") + const baseRole = member.guild.roles.cache.find( + (r) => r.name === "thesis-base", + ) + if (baseRole) { + await member.roles.add(baseRole) + } + } + if (rolesToAssign.length >= 2) { const role1Name = rolesToAssign[0].trim() const role2Name = rolesToAssign[1].trim() From 44a7157aa7b5cfbef168363ee566b8192f7df80f Mon Sep 17 00:00:00 2001 From: Erik Zuuring Date: Mon, 29 Apr 2024 14:07:04 +0300 Subject: [PATCH 5/7] thesis base Get working with base role assignment if on questions you select "no" --- discord-scripts/invite-management.ts | 17 ++++++++++++++--- 1 file changed, 14 insertions(+), 3 deletions(-) diff --git a/discord-scripts/invite-management.ts b/discord-scripts/invite-management.ts index 5655c782..8f207bf1 100644 --- a/discord-scripts/invite-management.ts +++ b/discord-scripts/invite-management.ts @@ -178,8 +178,12 @@ export default async function sendInvite(discordClient: Client, robot: Robot) { if (interaction.customId === "employee-no") { try { invitation.project = "Thesis Base" + const matchChannel = interaction.guild.channels.cache.find( + (c) => c.name === "πŸ”’thesis-base", + ) as TextChannel + robot.logger.info(matchChannel) const invite = await createInvite( - channel, + matchChannel, (1 * WEEK) / MILLISECOND, 2, ) @@ -393,14 +397,21 @@ export default async function sendInvite(discordClient: Client, robot: Robot) { const cleanChannelName = channel.name.replace(/πŸ”’/g, "").trim() const rolesToAssign = cleanChannelName.split("-") - if (rolesToAssign.includes("thesis base")) { + if (rolesToAssign.includes("base")) { robot.logger.info("Thesis base role detected") const baseRole = member.guild.roles.cache.find( - (r) => r.name === "thesis-base", + (r) => r.name === "Thesis Base", ) if (baseRole) { await member.roles.add(baseRole) } + robot.logger.info( + `Invite code used: ${ + usedInvite ? usedInvite.code : "None" + }, Username joined: ${ + member.displayName + }, Role assignment: ${baseRole}`, + ) } if (rolesToAssign.length >= 2) { From 7fdd0c209a07642ad0ef111ae27e148f92a2803c Mon Sep 17 00:00:00 2001 From: Erik Zuuring Date: Mon, 29 Apr 2024 15:57:13 +0300 Subject: [PATCH 6/7] Remove base from main --- discord-scripts/invite-management.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/discord-scripts/invite-management.ts b/discord-scripts/invite-management.ts index 8f207bf1..e453ab98 100644 --- a/discord-scripts/invite-management.ts +++ b/discord-scripts/invite-management.ts @@ -414,7 +414,7 @@ export default async function sendInvite(discordClient: Client, robot: Robot) { ) } - if (rolesToAssign.length >= 2) { + if (rolesToAssign.length >= 2 && !rolesToAssign.includes("base")) { const role1Name = rolesToAssign[0].trim() const role2Name = rolesToAssign[1].trim() const role1 = member.guild.roles.cache.find( From 9023debf18aa94bb979e1ffea2b4da2543a02c37 Mon Sep 17 00:00:00 2001 From: Erik Zuuring Date: Tue, 30 Apr 2024 10:28:47 +0300 Subject: [PATCH 7/7] Refactor list invites This commit swaps things around to resolve a bug happening when invite codes are not stored, this now makes sure the invite codes are stored when creating a new invite, so uses can be counted forwards. --- discord-scripts/invite-management.ts | 45 +++++++++++++--------------- 1 file changed, 21 insertions(+), 24 deletions(-) diff --git a/discord-scripts/invite-management.ts b/discord-scripts/invite-management.ts index e453ab98..bcc9afdc 100644 --- a/discord-scripts/invite-management.ts +++ b/discord-scripts/invite-management.ts @@ -77,24 +77,6 @@ interface Invitation { const invitation: Invitation = {} -async function createInvite( - channel: TextChannel, - maxAge = (1 * WEEK) / MILLISECOND, - maxUses = 10, -): Promise<{ url: string; maxAge: number; maxUses: number }> { - const invite = await channel.createInvite({ - maxAge, - maxUses, - unique: true, - }) - - return { - url: invite.url, - maxAge, - maxUses, - } -} - async function listInvites(discordClient: Client, robot: Robot): Promise { discordClient.guilds.cache.forEach(async (guild) => { try { @@ -114,6 +96,27 @@ async function listInvites(discordClient: Client, robot: Robot): Promise { }) } +async function createInvite( + channel: TextChannel, + maxAge = (1 * WEEK) / MILLISECOND, + maxUses = 10, +): Promise<{ url: string; maxAge: number; maxUses: number }> { + const invite = await channel.createInvite({ + maxAge, + maxUses, + unique: true, + }) + + // Update list of invites after new invite is created + await listInvites + + return { + url: invite.url, + maxAge, + maxUses, + } +} + export default async function sendInvite(discordClient: Client, robot: Robot) { const { application } = discordClient @@ -187,8 +190,6 @@ export default async function sendInvite(discordClient: Client, robot: Robot) { (1 * WEEK) / MILLISECOND, 2, ) - // Update list of invites after new invite is created - await listInvites(discordClient, robot) const internalInviteExpiry = Math.floor( Date.now() / 1000 + invite.maxAge, ) @@ -252,8 +253,6 @@ export default async function sendInvite(discordClient: Client, robot: Robot) { (1 * WEEK) / MILLISECOND, 2, ) - // Update list of invites after new invite is created - await listInvites(discordClient, robot) const internalInviteExpiry = Math.floor( Date.now() / 1000 + invite.maxAge, ) @@ -280,8 +279,6 @@ export default async function sendInvite(discordClient: Client, robot: Robot) { ) { try { const defenseInvite = await createInvite(channel) - // Update list of invites after new invite is created - await listInvites(discordClient, robot) if (defenseInvite) { robot.logger.info( `New invite created for defense audit channel: ${channel.name}, URL: ${defenseInvite.url}`,