From 6baef73906462a3e9df09b39c0c0276459884d54 Mon Sep 17 00:00:00 2001 From: Daniel <46201432+TechGeekGamer@users.noreply.github.com> Date: Wed, 6 Oct 2021 15:58:32 -0700 Subject: [PATCH] Add tickets feature (#11) * Update typings to reflect new functions, props, and interfaces * Update index.ts * Create ticket.ts * Added `lockThread` function A function to easily lock a thread. * Update launch.json * Add option for authorized users to close ticket * Add command to setup message to open tickets * Disallow creation of multiple tickets * Updated unauthorized ticket closure message * Update Ticket Creation message * Updated to have function dedicated to closing tickets * Update message if ticket already exists * Fix incorrect channel showing for setup_ticket_button * Update setup_ticket_button message to be more specific * Add support for "private" tickets * Update starter message * Fix bug with closing tickets * Add ability to set ticket as "private" * Change emoji representing public/private tickets * Better specify whether a ticket is public/private --- .vscode/launch.json | 2 +- src/buttons/close_ticket_.ts | 32 +++++++++++ src/buttons/open_private_ticket.ts | 56 +++++++++++++++++++ src/buttons/open_ticket.ts | 56 +++++++++++++++++++ src/commands/setup_ticket_button.ts | 39 +++++++++++++ src/commands/ticket.ts | 62 +++++++++++++++++++++ src/index.ts | 85 ++++++++++++++++++++++++++++- typings/index.d.ts | 23 +++++++- 8 files changed, 351 insertions(+), 4 deletions(-) create mode 100644 src/buttons/close_ticket_.ts create mode 100644 src/buttons/open_private_ticket.ts create mode 100644 src/buttons/open_ticket.ts create mode 100644 src/commands/setup_ticket_button.ts create mode 100644 src/commands/ticket.ts diff --git a/.vscode/launch.json b/.vscode/launch.json index 6727a05..de9bb83 100644 --- a/.vscode/launch.json +++ b/.vscode/launch.json @@ -7,7 +7,7 @@ { "type": "pwa-node", "request": "launch", - "name": "Launch Program", + "name": "Start Webserver", "skipFiles": [ "/**" ], diff --git a/src/buttons/close_ticket_.ts b/src/buttons/close_ticket_.ts new file mode 100644 index 0000000..eb2964e --- /dev/null +++ b/src/buttons/close_ticket_.ts @@ -0,0 +1,32 @@ +import ButtonCommand from "../classes/ButtonCommand"; + +export default new ButtonCommand({ + checkType:"STARTS_WITH", + execute(interaction){ + let ticketUserId = interaction.data.custom_id.split("close_ticket_")[1]; + let currentUserId = interaction.member.user.id; + let threadChannelId = interaction.message.channel_id; + if(currentUserId != ticketUserId && !interaction.member.roles.includes(interaction.internalBot.config.supportRoleId)) + return interaction.reply({ + content:`You can't close a ticket that isn't yours.`, + ephemeral:true + }) + interaction.reply({ + content:`<#${threadChannelId}> will be locked soon.`, + ephemeral:true + }) + .then(() => { + interaction.sendMessage(threadChannelId, { + embeds:[ + { + "description":`🔒 Ticket has been closed by <@${currentUserId}>`, + "color":16711680 + } + ] + }) + .then(() => { + return interaction.closeSupportThread(threadChannelId, ticketUserId) + }) + }) + } +}) \ No newline at end of file diff --git a/src/buttons/open_private_ticket.ts b/src/buttons/open_private_ticket.ts new file mode 100644 index 0000000..4b64b63 --- /dev/null +++ b/src/buttons/open_private_ticket.ts @@ -0,0 +1,56 @@ +import ButtonCommand from "../classes/ButtonCommand"; + +export default new ButtonCommand({ + checkType:"EQUALS", + execute(interaction){ + let topic = { + value:`${interaction.member.user.username}#${interaction.member.user.discriminator}` + } + if(topic.value.length > 90 || topic.value.length < 1) + return interaction.reply({ + content:`Topic must be 1-90 characters`, + ephemeral:true + }) + + interaction.ack({ + "ephemeral":true + }).then(() => { + interaction.createSupportThread(topic.value, interaction.member.user.id, true) + .then(channel => { + // Error + if(typeof channel == 'string') + return interaction.reply({ + content:channel, + ephemeral:true + }).catch(console.log) + interaction.joinThread(channel.id).then(res => { + interaction.sendMessage(channel.id, { + "content": + `Hey <@${interaction.member.user.id}>,\n<@&${interaction.internalBot.config.supportRoleId}> will be here to support you shortly. In the meantime, to make it easier for us and others help you with your issue, please tell us a few things about your setup, like:\n\n- Firmware and CFW / Atmosphere / DeepSea version\n- Do you use hekate or fusee-primary?\n- If you have an error screen with ID or code, what does it say? A screenshot/picture could be helpful.\n- What, if anything, have you tried to fix the issue?\n\n*(Disclaimer: You may not receive an answer instantly. Many of us have lives outside of Discord and will respond whenever we're able to, whenever that is.)*\n\n:lock: *This is a private ticket, so only staff may reply.*`, + "components":[ + { + "type":1, + "components":[ + { + "type":2, + "style":2, + "custom_id":`close_ticket_${interaction.member.user.id}`, + "label":"Close Ticket", + "emoji":{ + "name":"🔒" + } + } + ] + } + ] + }).then(r => { + interaction.reply({ + content:`Ticket is ready in <#${channel.id}>`, + ephemeral:true + }) + }) + }) + }) + }) + } +}) \ No newline at end of file diff --git a/src/buttons/open_ticket.ts b/src/buttons/open_ticket.ts new file mode 100644 index 0000000..bb76cd2 --- /dev/null +++ b/src/buttons/open_ticket.ts @@ -0,0 +1,56 @@ +import ButtonCommand from "../classes/ButtonCommand"; + +export default new ButtonCommand({ + checkType:"EQUALS", + execute(interaction){ + let topic = { + value:`${interaction.member.user.username}#${interaction.member.user.discriminator}` + } + if(topic.value.length > 90 || topic.value.length < 1) + return interaction.reply({ + content:`Topic must be 1-90 characters`, + ephemeral:true + }) + + interaction.ack({ + "ephemeral":true + }).then(() => { + interaction.createSupportThread(topic.value, interaction.member.user.id, false) + .then(channel => { + // Error + if(typeof channel == 'string') + return interaction.reply({ + content:channel, + ephemeral:true + }).catch(console.log) + interaction.joinThread(channel.id).then(res => { + interaction.sendMessage(channel.id, { + "content": + `Hey <@${interaction.member.user.id}>,\n<@&${interaction.internalBot.config.supportRoleId}> will be here to support you shortly. In the meantime, to make it easier for us and others help you with your issue, please tell us a few things about your setup, like:\n\n- Firmware and CFW / Atmosphere / DeepSea version\n- Do you use hekate or fusee-primary?\n- If you have an error screen with ID or code, what does it say? A screenshot/picture could be helpful.\n- What, if anything, have you tried to fix the issue?\n\n*(Disclaimer: You may not receive an answer instantly. Many of us have lives outside of Discord and will respond whenever we're able to, whenever that is.)*\n\n:unlock: *This is a public ticket, everyone may view and reply to it..*`, + "components":[ + { + "type":1, + "components":[ + { + "type":2, + "style":2, + "custom_id":`close_ticket_${interaction.member.user.id}`, + "label":"Close Ticket", + "emoji":{ + "name":"🔒" + } + } + ] + } + ] + }).then(r => { + interaction.reply({ + content:`Ticket is ready in <#${channel.id}>`, + ephemeral:true + }) + }) + }) + }) + }) + } +}) \ No newline at end of file diff --git a/src/commands/setup_ticket_button.ts b/src/commands/setup_ticket_button.ts new file mode 100644 index 0000000..fed1b16 --- /dev/null +++ b/src/commands/setup_ticket_button.ts @@ -0,0 +1,39 @@ +import Command from "../classes/Command"; + +export default new Command({ + execute(interaction){ + interaction.reply({ + "content":`Ready to open tickets from <#${interaction.internalBot.config.supportChannelId}>`, + "ephemeral":true + }).then(() => { + interaction.sendMessage(interaction.internalBot.config.supportChannelId, { + content:"**Check for your issue**\nBefore opening a ticket, check the thread menu <:threadmenu:894295426176520283> and search/scroll through the __**Archived**__ tab/button at the top of the thread menu.\n\n**If you can't find your issue**\n\n*Open Public Support Ticket* : Allows everyone to view your ticket and provide support.\n\n*Open Private Support Ticket* : Allows everyone to view your ticket, but only staff members can provide support.\n\n*`/ticket` Slash Command* : Run in any other channel to open a support ticket supplied with a specific topic.\n\n**Want to help others?**\nOpen the thread menu <:threadmenu:894295426176520283> and search/scroll through the __**Active**__ threads for people needing help. Please only join a thread if you intend to provide support.", + components:[ + { + type:1, + components:[ + { + "custom_id":`open_ticket`, + "label":"Open Public Support Ticket", + "type":2, + "style":2, + "emoji":{ + "name":"🔓" + } + }, + { + "custom_id":`open_private_ticket`, + "label":"Open Private Support Ticket", + "type":2, + "style":2, + "emoji":{ + "name":"🔒" + } + } + ] + } + ] + }).then(r => r.json()).then(console.log) + }) + } +}) \ No newline at end of file diff --git a/src/commands/ticket.ts b/src/commands/ticket.ts new file mode 100644 index 0000000..04a0d92 --- /dev/null +++ b/src/commands/ticket.ts @@ -0,0 +1,62 @@ +import Command from "../classes/Command"; + +export default new Command({ + execute(interaction){ + // 1-90 char only + let topic = interaction.data.options.find(o => o.name == "topic"); + let supportRoleOnly = interaction.data.options.find(o => o.name == "private") && interaction.data.options.find(o => o.name == "private").value || false; + let threadStarter = interaction.member.user.id; + if(topic.value.length > 90 || topic.value.length < 1) + return interaction.reply({ + content:`Topic must be 1-90 characters`, + ephemeral:true + }) + interaction.ack({ + "ephemeral":true + }).then(() => { + interaction.createSupportThread(topic.value, threadStarter, supportRoleOnly) + .then(channel => { + // Error + if(typeof channel == 'string') + return interaction.reply({ + content:channel, + ephemeral:true + }).catch(console.log) + interaction.joinThread(channel.id).then(res => { + interaction.sendMessage(channel.id, { + "content": + `Hey <@${threadStarter}>,\n<@&${interaction.internalBot.config.supportRoleId}> will be here to support you shortly. In the meantime, to make it easier for us and others help you with your issue, please tell us a few things about your setup, like:\n\n- Firmware and CFW / Atmosphere / DeepSea version\n- Do you use hekate or fusee-primary?\n- If you have an error screen with ID or code, what does it say? A screenshot/picture could be helpful.\n- What, if anything, have you tried to fix the issue?\n\n*(Disclaimer: You may not receive an answer instantly. Many of us have lives outside of Discord and will respond whenever we're able to, whenever that is.)*\n${supportRoleOnly?"\n:lock: *This is a private ticket, so only staff may reply.*":"\n:unlock: *This is a public ticket, everyone may view and reply to it..*"}`, + "components":[ + { + "type":1, + "components":[ + { + "type":2, + "style":2, + "custom_id":`close_ticket_${threadStarter}`, + "label":"Close Ticket", + "emoji":{ + "name":"🔒" + } + } + ] + } + ] + }).then(r => { + interaction.reply({ + content:`Ticket is ready in <#${channel.id}>`, + ephemeral:true + }) + }) + }) + }) + .catch(err => { + if(typeof err == "string") + interaction.reply({ + content:err, + ephemeral:true + }) + }) + }) + } +}) \ No newline at end of file diff --git a/src/index.ts b/src/index.ts index e8c31d7..64fcb72 100644 --- a/src/index.ts +++ b/src/index.ts @@ -12,6 +12,9 @@ import { BuilderApiModule, SDLayoutOS, InteractionAckOptions, + User, + ThreadCreateOptions, + Message, } from "../typings" import express from "express" import { @@ -68,6 +71,8 @@ checkForLatestBuildApi() app.use("/interactions", verifyKeyMiddleware(public_key)) +let hasActiveTickets = {} + //Set props/methods app.use("/interactions", (req, res, next) => { const interaction: Interaction = req.body @@ -82,6 +87,7 @@ app.use("/interactions", (req, res, next) => { } req.body.ack = (options?:InteractionAckOptions) => { return new Promise((res, rej) => { + interaction.acked = true; fetch(`${discord_api}/interactions/${interaction.id}/${interaction.token}/callback`, { "method":"POST", "headers":{ @@ -96,22 +102,97 @@ app.use("/interactions", (req, res, next) => { .catch(rej) }) } + req.body.sendMessage = (channelId:string, msg:Message) => { + return new Promise((res, rej) => { + fetch(`${discord_api}/channels/${channelId}/messages`, { + "method":"POST", + "headers":{ + "authorization":`Bot ${config.bot_token}`, + "Content-Type":"application/json" + }, + "body":JSON.stringify(msg) + }) + .then(res) + .catch(rej) + }) + } req.body.reply = (options:InteractionResponse) => { if(options.ephemeral) options.flags = 64 return new Promise((res, rej) => { - fetch(`${discord_api}/interactions/${interaction.id}/${interaction.token}/callback`, { + fetch(`${discord_api}${interaction.acked?`/webhooks/${interaction.application_id}/${interaction.token}`:`/interactions/${interaction.id}/${interaction.token}/callback`}`, { "method":"POST", "headers":{ "Content-Type":"application/json" }, - "body":JSON.stringify({ + "body":JSON.stringify(interaction.acked?options:{ type:4, data:options }) }) .then(res) .then(rej) + interaction.acked = true; + }) + } + req.body.lockThread = (channelId:string) => { + return new Promise((resolve, reject) => { + fetch(`${discord_api}/channels/${channelId}`, { + "method":"PATCH", + headers:{ + "authorization":`Bot ${config.bot_token}`, + "Content-Type":"application/json" + }, + body:JSON.stringify({ + archived:true, + locked:true + }) + }) + .then(resolve) + .catch(reject) + }) + } + req.body.createSupportThread = (shortDesc:string, userId:string, privateTicket:boolean) => { + let options:ThreadCreateOptions = { + name:`${privateTicket?"🔒":"🔓"} - ${shortDesc}`, + auto_archive_duration:1440, + type:11 + } + return new Promise((resolve, reject) => { + if(hasActiveTickets[userId]) + return resolve(`You already have a ticket opened. Please close your current ticket to open a new one.`) + hasActiveTickets[userId] = true; + fetch(`${discord_api}/channels/${config.supportChannelId}/threads`, { + "method":"POST", + headers:{ + "authorization":`Bot ${config.bot_token}`, + "Content-Type":"application/json" + }, + body:JSON.stringify(options) + }) + .then(r => r.json()) + .then(resolve) + .catch(reject) + }) + } + req.body.closeSupportThread = (channelId:string, userId:string) => { + hasActiveTickets[userId] = false; + return new Promise((resolve, reject) => { + interaction.lockThread(channelId) + .then(resolve) + .catch(reject) + }) + } + req.body.joinThread = (channelId:string) => { + return new Promise((resolve, reject) => { + fetch(`${discord_api}/channels/${channelId}/thread-members/@me`, { + "method":"PUT", + headers:{ + "authorization":`Bot ${config.bot_token}` + } + }) + .then(resolve) + .catch(reject) }) } if(interaction.type == InteractionType.MESSAGE_COMPONENT){ diff --git a/typings/index.d.ts b/typings/index.d.ts index 7124164..846c510 100644 --- a/typings/index.d.ts +++ b/typings/index.d.ts @@ -1,5 +1,18 @@ import Builder from "../src/builder"; +interface Message { + content?: string, + components?: MessageComponent[], + allowed_mentions?:AllowedMentions, + embeds?:any[] +} + +interface ThreadCreateOptions { + name:string, + auto_archive_duration:60 | 1440, + type:11 +} + interface ApplicationCommandInteractionDataOption { name:string, type:number, @@ -69,9 +82,15 @@ export interface Interaction { token:string, version:number, message?:any, + acked:boolean, update?(msg:InteractionResponse):Promise, reply(msg:InteractionResponse):Promise, ack(msg:InteractionAckOptions):Promise, + createSupportThread(shortDesc:string, userId:string, privateTicket:boolean):Promise, + closeSupportThread(channelId:string, userId:string):Promise, + sendMessage(channelId:string, msg:Message), + joinThread(channelId:string):Promise, + lockThread(channelId:string):Promise, packageBuilder:{ builder:Builder, store:Builder, @@ -120,7 +139,9 @@ export interface Config { public_key:string, port?:number, bot_token:string, - bitly_token?:string + bitly_token?:string, + supportChannelId?:string, + supportRoleId?:string } export interface GitHubRelease {