Support tickets modals (#22)

* Update index.d.ts

* Create staff_controls.ts

* Don't allow closing of ticket if not manager

* Support locking ticket

* Update to use ticket staff controls in new tickets

* Add externalChannelId for tickets

* Add Full Private Ticket

opens a new channel related to the ticket

* Create private_ticket_staff_controls.ts

* Squashed commit of the following:

commit 185a93ff78
Author: Daniel <46201432+TechGeekGamer@users.noreply.github.com>
Date:   Sun Feb 6 18:25:19 2022 -0800

    Update meme eta messages

* Add support for modal interactions

* Move check if ticket already exists to open_ticket_prompt

* Add modal to open ticket

* Update open_ticket_prompt.ts

fix message on opening new ticket while already having one open

* Update close_ticket_.ts

* Add Config.incomingFeedbackChannel

* Receive feedback on ticket close

* Update open_ticket.ts

Update modal text

* fix incorrect linking for incoming ticket feedback

* Update close_ticket_.ts

* Open modal on tickets create command

* remove debug console logs
This commit is contained in:
Daniel 2022-02-13 18:18:19 -08:00 committed by GitHub
parent 60ddc5354b
commit 38e283f2e0
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
14 changed files with 893 additions and 213 deletions

View File

@ -26,35 +26,65 @@ export default new ButtonCommand({
content:`Sorry, this ticket has to remain open for **${remainingTime > 60? Math.floor(remainingTime / 60) : Math.floor(remainingTime)}** more ${remainingTime > 60 ? `minute${Math.floor(remainingTime / 60) == 1 ? `` : `s`}` : `second${Math.floor(remainingTime) <= 1 ? `` : `s`}`} before it can be closed.`,
ephemeral:true
})
interaction.reply({
content:`<#${threadChannelId}> will be locked soon.`,
ephemeral:true
})
.then(() => {
let embeds:MessageEmbed[] = [
new MessageEmbed( {
"description":`🔒 Ticket has been closed by <@${currentUserId}>`,
"color":16711680
})
];
if(config.closingTicketsSettings?.closeMessage)
embeds.push(new MessageEmbed({
"description":config.closingTicketsSettings.closeMessage.replace(userPingReplaceRegExp, `<@${ticketUserId}>`),
"footer":{
text:"The message above is set by the server"
}
}));
let threadChannel = interaction.client.channels.cache.get(threadChannelId) as ThreadChannel;
threadChannel?.send({
embeds
if(supportThread.locked && supportThread.locked != interaction.user.id && !(currentUserId == supportThread.userId))
return interaction.reply({
content:`This ticket's management has been restricted to <@${supportThread.locked}>. Please contact them to perform this action.`,
ephemeral:true
})
return interaction.client.closeSupportThread({
userId:ticketUserId,
channelId:threadChannelId,
noApi:threadChannel?false:true
}).catch(console.error)
})
if(config.closingTicketsSettings?.incomingFeedbackChannel && currentUserId === supportThread.userId){
// @ts-ignore
interaction.client.api.interactions(interaction.id)(interaction.token).callback.post({
data:{
type: 9,
data: {
components: [
{
type: 1,
components:[
{
type: 4,
custom_id: 'feedback_content',
style: 2,
label: 'How was your experience in the ticket?',
placeholder: 'This is shared with the server admin(s)',
min_length:10,
required:false
}
]
}
],
title: 'Ticket Feedback',
custom_id: 'closed_ticket_feedback'
}
}
})
.then(() => {
let embeds:MessageEmbed[] = [
new MessageEmbed( {
"description":`🔒 Ticket has been closed by <@${currentUserId}>`,
"color":16711680
})
];
if(config.closingTicketsSettings?.closeMessage)
embeds.push(new MessageEmbed({
"description":config.closingTicketsSettings.closeMessage.replace(userPingReplaceRegExp, `<@${ticketUserId}>`),
"footer":{
text:"The message above is set by the server"
}
}));
let threadChannel = interaction.client.channels.cache.get(threadChannelId) as ThreadChannel;
threadChannel?.send({
embeds
})
return interaction.client.closeSupportThread({
userId:ticketUserId,
channelId:threadChannelId,
noApi:threadChannel?false:true
}).catch(console.error)
})
}
}
})

View File

@ -61,13 +61,20 @@ export default new ButtonCommand({
{
"type":1,
"components":[
{
"type":2,
"style":2,
"customId":`close_ticket_${threadStarter}`,
"label":"Close Ticket",
"emoji":"🔒"
}
{
"type":2,
"style":2,
"customId":`close_ticket_${threadStarter}`,
"label":"Close Ticket",
"emoji":"🔒"
},
{
"type":"BUTTON",
"style":"SECONDARY",
"customId":`staff_controls_${threadStarter}`,
"label":"Staff Controls",
"emoji":"🛠"
}
]
}
]

View File

@ -17,87 +17,85 @@ export default new ButtonCommand({
content:`Topic must be 1-90 characters`,
ephemeral:true
})
interaction.deferReply({ephemeral:true}).then(() => {
let currentThread = interaction.client.getSupportThreadData(threadStarter);
if(currentThread?.active || false)
return interaction.followUp({
content:`You already have a ticket opened. Please use your current ticket or close your current ticket to open a new one.`,
components:[
// @ts-ignore
interaction.client.api.interactions(interaction.id)(interaction.token).callback.post({
data:{
type: 9,
data: {
components: [
{
type:"ACTION_ROW",
type: 1,
components: [
{
type: 4,
custom_id: 'question1',
style: 1,
label: 'Firmware + Atmosphere / DeepSea version',
placeholder: 'FW X.X.X, Atmosphere X.X.X, DeepSea X.X.X',
max_length:50
}
]
},
{
type: 1,
components:[
{
type:"BUTTON",
label:"View Current Ticket",
style:"LINK",
url:`https://discord.com/channels/${interaction.guildId}/${currentThread?.threadChannelId}`
},
{
type:"BUTTON",
label:"Close Current Ticket",
style:"DANGER",
customId:`close_ticket_${threadStarter}`
type: 4,
custom_id: 'question2',
style: 1,
label: 'Do you use Hekate or Fusee?',
placeholder: 'Hekate / Fusee',
min_length:5,
max_length:50
}
]
}
]
})
interaction.client.createSupportThread({
shortDesc:topic.value.toString(),
userId:threadStarter,
privateTicket: supportRoleOnly
})
.then(channel => {
const questions = [
`- Firmware and CFW / Atmosphere / DeepSea version`,
`- Do you use hekate or fusee-primary?`,
`- If you have an error screen with ID or code, what does it say? A screenshot/picture could be helpful.`,
`- What, if anything, have you tried to fix the issue?`,
`- Are you coming for support with SDSetup or DeepSea?`
];
(interaction.client.channels.cache.get(channel.id) as ThreadChannel).send({
"content":`${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..*"}\n\nHey <@${threadStarter}>, 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${questions.join("\n")}`,
"components":[
},
{
"type":1,
"components":[
type: 1,
components:[
{
"type":2,
"style":2,
"customId":`close_ticket_${threadStarter}`,
"label":"Close Ticket",
"emoji":"🔒"
},
type: 4,
custom_id: 'question3',
style: 1,
label: 'Do you have an error code and screen?',
placeholder: 'Error Code 0000-0000 / Screenshot link',
required:false
}
]
},
{
type: 1,
components:[
{
"type":"BUTTON",
"style":"SECONDARY",
"customId":`switch_ticket_type_${threadStarter}`,
"label":"Switch to Private Ticket"
type: 4,
custom_id: 'question4',
style: 1,
label: 'Coming for support with SDSetup or DeepSea?',
placeholder: 'SDSetup / DeepSea / Something else',
max_length:50
}
]
},
{
type: 1,
components:[
{
type: 4,
custom_id: 'question5',
style: 2,
label: 'Describe your issue and what led up to it',
placeholder: 'Well, I was playing Splatoon 2 until...',
min_length:20
}
]
}
]
}).then(r => {
r.channel.awaitMessages({
max:1,
filter(message){
return message.author.id == threadStarter;
}
}).then(async () => {
await interaction.client.updateSupportThread({
threadId:channel.id,
userId:threadStarter
});
(interaction.client.channels.cache.get(channel.id) as ThreadChannel).send({
"content":`Thanks! <@&${config.supportRoleId}> will be here to support you shortly.\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.)*`
})
})
interaction.followUp({
content:`Ticket is ready in <#${channel.id}>`,
ephemeral:true
})
})
})
})
],
title: 'Open Support Ticket',
custom_id: 'open_ticket_modal'
}
}
}).then((err) => {
console.log(err)
});
}
})

View File

@ -5,6 +5,32 @@ export default new ButtonCommand({
customId:"open_ticket_prompt",
checkType:"EQUALS",
execute(interaction){
let threadStarter = interaction.member.user.id;
let currentThread = interaction.client.getSupportThreadData(threadStarter);
if(currentThread?.active || false)
return interaction.reply({
content:`You already have a ticket opened. Please use your current ticket or close your current ticket to open a new one.`,
components:[
{
type:"ACTION_ROW",
components:[
{
type:"BUTTON",
label:"View Current Ticket",
style:"LINK",
url:`https://discord.com/channels/${interaction.guildId}/${currentThread?.threadChannelId}`
},
{
type:"BUTTON",
label:"Close Current Ticket",
style:"DANGER",
customId:`close_ticket_${threadStarter}`
}
]
}
],
ephemeral:true
})
if(config.openingTicketPrompt?.enabled){
return interaction.reply({
content:config.openingTicketPrompt.message,

View File

@ -0,0 +1,129 @@
import { BaseMessageComponentOptions, MessageActionRow, MessageActionRowOptions, MessageEmbed, OverwriteResolvable, TextChannel } from "discord.js";
import { config } from "../../config";
import ButtonCommand from "../classes/ButtonCommand";
const userPingReplaceRegExp = new RegExp(/\!{(USER_PING)\}!/, "g");
export default new ButtonCommand({
customId:"private_ticket_staff_controls",
checkType:"STARTS_WITH",
staffOnly:true,
async execute(interaction){
if(!interaction.channel.isText())
return interaction.reply({
content:`This is only for text channels`,
ephemeral:true
})
let thisChannel:TextChannel = interaction.guild.channels.cache.get(interaction.channelId) as TextChannel;
let threadStarter = interaction.customId.split("private_ticket_staff_controls_")[1];
let randomChars = (Math.random() + 1).toString(36).substring(7).toString();
let components:(MessageActionRow | (Required<BaseMessageComponentOptions> & MessageActionRowOptions))[] = [
{
type:"ACTION_ROW",
components:[
{
"type":"BUTTON",
"style":"SECONDARY",
"customId":`collecter_${randomChars}_lock_channel-${interaction.message?.id}`,
"label":`Archive Channel`,
"disabled":thisChannel.permissionOverwrites.cache.find(po => po.id == thisChannel.guildId && po.deny.has("SEND_MESSAGES"))?true:false
}
]
}
];
interaction.reply({
content:`**Staff Private Ticket Controls**`,
components,
ephemeral:true
})
let collector = interaction.channel.createMessageComponentCollector({
componentType:"BUTTON",
message:await interaction.fetchReply(),
time:300000,
filter(button){
return button.user.id == interaction.user.id && button.customId.startsWith(`collecter_${randomChars}`)
}
})
collector.on("collect", async (collected) => {
if(!collected) return;
let starterMessageId = collected.customId.split("-")[1];
// Archive
if(collected.customId.includes("lock_channel")){
let thisChannel:TextChannel = interaction.guild.channels.cache.get(collected.channelId) as TextChannel;
if(thisChannel.isText()){
let staffOverrides:OverwriteResolvable[] = config.staffRoles.map((roleId) => {
return {
type:"role",
allow:["VIEW_CHANNEL", "READ_MESSAGE_HISTORY"],
deny:["SEND_MESSAGES", "ADD_REACTIONS"],
id:roleId
}
})
let embeds:MessageEmbed[] = [
new MessageEmbed( {
"description":`🔒 Private Ticket Channel has been closed by <@${interaction.user.id}>`,
"color":16711680
})
];
if(config.closingTicketsSettings?.closeMessage)
embeds.push(new MessageEmbed({
"description":config.closingTicketsSettings.closeMessage.replace(userPingReplaceRegExp, `<@${threadStarter}>`),
"footer":{
text:"The message above is set by the server"
}
}));
await interaction.channel.send({embeds})
let res = await thisChannel.permissionOverwrites.set([
...staffOverrides,
{
type:"role",
id:interaction.guildId,
deny:["VIEW_CHANNEL", "SEND_MESSAGES", "ADD_REACTIONS"]
},
{
type:"member",
id:interaction.client.user.id,
allow:["VIEW_CHANNEL", "SEND_MESSAGES", "READ_MESSAGE_HISTORY", "ATTACH_FILES", "EMBED_LINKS", "MANAGE_MESSAGES", "MANAGE_CHANNELS"]
}
]);
if(!res){
collected.update({
embeds:[
{
description:`Failed to archive channel.`,
color:"RED"
}
]
})
}
if(res){
components[0].components[0].disabled = true;
await interaction.channel.messages.cache.get(starterMessageId)
collected.update({
components,
embeds:[
{
description:`Successfully archived channel.`,
color:"GREEN"
}
]
})
}
}
return;
}
})
}
})

View File

@ -0,0 +1,287 @@
import { BaseMessageComponentOptions, MessageActionRow, MessageActionRowOptions, OverwriteResolvable } from "discord.js";
import { config } from "../../config";
import { TicketType } from "../../typings";
import ButtonCommand from "../classes/ButtonCommand";
export default new ButtonCommand({
customId:"staff_controls",
checkType:"STARTS_WITH",
staffOnly:true,
async execute(interaction){
let threadStarter = interaction.customId.split("staff_controls_")[1];
let threadData = interaction.client.getSupportThreadData(threadStarter);
let randomChars = (Math.random() + 1).toString(36).substring(7).toString();
let components:(MessageActionRow | (Required<BaseMessageComponentOptions> & MessageActionRowOptions))[] = [
{
type:"ACTION_ROW",
components:[
{
"type":"BUTTON",
"style":"SECONDARY",
"customId":`collecter_${randomChars}_switch_ticket_type-${interaction.message?.id}`,
"label":`Toggle Ticket Type`
},
{
"type":"BUTTON",
"style":"SECONDARY",
"customId":`collecter_${randomChars}_toggle_restricted_ticket-${interaction.message?.id}`,
"label":`${!threadData.locked?'Restrict':'Unrestrict'} Management ${!threadData.locked?'to':'from'} ${(interaction.client.users.cache.get(threadData.locked)?.tag || threadData.locked) || interaction.user.tag}`
}
]
},
{
type:"ACTION_ROW",
components:[
{
"type":"BUTTON",
"style":"SECONDARY",
"customId":`collecter_${randomChars}_full_private-${interaction.message?.id}`,
"label":`Full Private Ticket`,
"disabled":threadData.externalChannelId?true:false
}
]
}
];
interaction.reply({
content:`**Staff Ticket Controls**`,
components,
ephemeral:true
})
let collector = interaction.channel.createMessageComponentCollector({
componentType:"BUTTON",
message:await interaction.fetchReply(),
time:300000,
filter(button){
return button.user.id == interaction.user.id && button.customId.startsWith(`collecter_${randomChars}`)
}
})
collector.on("collect", async (collected) => {
if(!collected) return;
let staffControlsMessageInteraction = collected;
let starterMessageId = staffControlsMessageInteraction.customId.split("-")[1];
let threadData = interaction.client.getSupportThreadData(threadStarter);
// Switch to Public/Private ticket
if(staffControlsMessageInteraction.customId.includes("switch_ticket_type")){
let newType:TicketType;
if(threadData.type == "PUBLIC")
newType = "PRIVATE";
if(threadData.type == "PRIVATE")
newType = "PUBLIC";
let res = await interaction.client.updateSupportThread({
newType,
userId:threadData.userId,
threadId:threadData.threadChannelId
})
if(res === false){
staffControlsMessageInteraction.update({
embeds:[
{
description:`Failed to update support thread data. Failed to change ticket type to ${newType == 'PUBLIC' ? "Public" : "Private"}.`,
color:"RED"
}
]
})
}
if(res == true){
staffControlsMessageInteraction.update({
components,
embeds:[
{
description:`Successfully changed ticket type to ${newType == 'PUBLIC' ? "Public" : "Private"}.`,
color:"GREEN"
}
]
})
interaction.channel.messages.cache.get(starterMessageId)?.edit({
content:interaction.message.content.replace(newType == "PUBLIC"?":lock: *This is a private ticket, so only staff may reply.*":":unlock: *This is a public ticket, everyone may view and reply to it..*", newType == "PRIVATE"?":lock: *This is a private ticket, so only staff may reply.*":":unlock: *This is a public ticket, everyone may view and reply to it..*")
})
interaction.channel?.send({
embeds:[
{
description:`${newType == "PUBLIC" ? ":unlock:" : ":lock:"} Ticket has been switched to a ${newType == 'PUBLIC' ? "Public" : "Private"} Ticket.`,
color:newType == "PUBLIC" ? "GREEN" : "RED",
footer:{
iconURL:`https://cdn.discordapp.com/avatars/${interaction.member?.user.id}/${interaction.member?.user.avatar}${interaction.member?.user.avatar.startsWith("a_")?".gif":".png"}`,
text:`${interaction.member?.user.username}#${interaction.member?.user.discriminator}`
}
}
]
})
}
return;
}
// Lock Ticket Management
if(staffControlsMessageInteraction.customId.includes("toggle_restricted_ticket")){
let locked: string | undefined;
if(typeof threadData.locked == 'undefined')
locked = staffControlsMessageInteraction.user?.id;
if(typeof threadData.locked == 'string')
locked = undefined;
let res = await interaction.client.updateSupportThread({
userId:threadData.userId,
threadId:threadData.threadChannelId,
locked
})
components[0].components = components[0].components.map(c => {
if(c.type == "BUTTON" && c.customId.includes("toggle_restricted_ticket")){
c.label = `${!locked?'Restrict':'Unrestrict'} Management ${!locked?'to':'from'} ${(interaction.client.users.cache.get(locked)?.tag || locked) || interaction.user.tag}`;
}
return c;
})
if(res === false){
staffControlsMessageInteraction.update({
embeds:[
{
description:`Failed to update support thread data. Failed to ${typeof locked == 'string'?'restrict':'unrestrict'} ticket management ${typeof locked == 'string'?'to':'from'} ${interaction.user?.tag}.`,
color:"RED"
}
]
})
}
if(res == true){
staffControlsMessageInteraction.update({
components,
embeds:[
{
description:`Successfully ${typeof locked == 'string'?'restricted':'unrestricted'} ticket management ${typeof locked == 'string'?'to':'from'} ${interaction.user?.tag}.`,
color:"GREEN"
}
]
})
}
return;
}
// Open new channel
if(staffControlsMessageInteraction.customId.includes("full_private")){
let supportChannelId = staffControlsMessageInteraction.guild.channels.cache.get(config.supportChannelId);
let threadChannel = staffControlsMessageInteraction.guild.channels.cache.get(threadData.threadChannelId);
if(threadChannel.isText()){
let staffOverrides:OverwriteResolvable[] = config.staffRoles.map((roleId) => {
return {
type:"role",
allow:["VIEW_CHANNEL", "SEND_MESSAGES", "READ_MESSAGE_HISTORY", "ATTACH_FILES", "EMBED_LINKS"],
id:roleId
}
})
await staffControlsMessageInteraction.deferUpdate()
let newChannel = await supportChannelId.guild.channels.create(`Private-${threadChannel.name}`, {
type:"GUILD_TEXT",
parent:supportChannelId.parentId,
topic:`This channel was created by <@${interaction.user.id}> from the ticket: <#${threadData.threadChannelId}>.`,
reason:`${interaction.user?.tag}> from the ticket: ${threadData.threadChannelId}`
});
await newChannel.lockPermissions()
await newChannel.permissionOverwrites.set([
...newChannel.permissionOverwrites.cache.map(po => {
return {
id:po.id,
allow:po.allow,
deny:po.deny
}
}),
...staffOverrides,
{
type:"role",
id:config.supportRoleId,
allow:["VIEW_CHANNEL", "SEND_MESSAGES", "READ_MESSAGE_HISTORY", "ATTACH_FILES", "EMBED_LINKS"]
},
{
type:"member",
id:threadStarter,
allow:["VIEW_CHANNEL", "SEND_MESSAGES", "READ_MESSAGE_HISTORY", "ATTACH_FILES", "EMBED_LINKS"]
},
{
type:"member",
id:interaction.client.user.id,
allow:["VIEW_CHANNEL", "SEND_MESSAGES", "READ_MESSAGE_HISTORY", "ATTACH_FILES", "EMBED_LINKS", "MANAGE_MESSAGES", "MANAGE_CHANNELS"]
},
{
type:"role",
id:staffControlsMessageInteraction.guildId,
deny:["VIEW_CHANNEL"]
}
])
let res = await interaction.client.updateSupportThread({
externalChannelId:newChannel.id,
userId:threadData.userId,
threadId:threadData.threadChannelId
})
components[1].components[0].disabled = true;
if(res == true){
await staffControlsMessageInteraction.editReply({
embeds:[
{
description:`Successfully opened external channel.`,
color:"GREEN"
}
],
components
})
await newChannel.send({
content:`This channel was created by <@${interaction.user.id}> from the ticket: <#${threadData.threadChannelId}>.`,
components:[
{
type:"ACTION_ROW",
components:[
{
"type":"BUTTON",
"style":"SECONDARY",
"customId":`private_ticket_staff_controls_${threadStarter}`,
"label":"Staff Controls",
"emoji":"🛠"
}
]
}
]
})
await interaction.channel?.send({
embeds:[
{
description:`A new channel has been opened related to this ticket, [${newChannel.name}](<https://discord.com/channels/${newChannel.guildId}/${newChannel.id}>).`,
color:"BLUE",
footer:{
iconURL:`https://cdn.discordapp.com/avatars/${interaction.member?.user.id}/${interaction.member?.user.avatar}${interaction.member?.user.avatar.startsWith("a_")?".gif":".png"}`,
text:`${interaction.member?.user.username}#${interaction.member?.user.discriminator}`
}
}
]
})
}
if(res == false){
staffControlsMessageInteraction.editReply({
embeds:[
{
description:`Failed to open external channel.`,
color:"RED"
}
],
components
})
}
}
}
})
}
})

View File

@ -0,0 +1,15 @@
import { Client } from "discord.js";
class ModalCommand {
customId:string
staffOnly?:boolean
constructor(options:ModalCommand){
this.customId = options.customId;
this.staffOnly = options.staffOnly;
/** Until d.js properly implements Modals, raw payload is used + client second arg */
this.execute = options.execute;
};
/** Until d.js properly implements Modals, raw payload is used + client second arg */
execute(interaction:any, client:Client){}
}
export default ModalCommand;

View File

@ -24,6 +24,11 @@ export default new Command({
content:`This command must be sent in the ticket channel`,
ephemeral:true
})
if(ticket.locked && ticket.locked != interaction.user.id)
return interaction.reply({
content:`This ticket's management has been restricted to <@${ticket.locked}>. Please contact them to perform this action.`,
ephemeral:true
})
interaction.reply({
content:`Hey <@${user.id}>,\n\n<@${interaction.user.id}> has requested that this ticket be closed. If you're done with your question, you can close it with the button below. If you are not finished, please let us know so we don't close your ticket for inactivity.`,
components:[

View File

@ -28,6 +28,11 @@ export default new Command({
content:`You can't close a ticket that isn't yours.`,
ephemeral:true
})
if(supportThread.locked && supportThread.locked != interaction.user.id)
return interaction.reply({
content:`This ticket's management has been restricted to <@${supportThread.locked}>. Please contact them to perform this action.`,
ephemeral:true
})
if(config.closingTicketsSettings?.ticketsMinimumAge > secondsSinceCreation && !isStaff)
return interaction.reply({
content:`Sorry, this ticket has to remain open for **${remainingTime > 60? Math.floor(remainingTime / 60) : Math.floor(remainingTime)}** more ${remainingTime > 60 ? `minute${Math.floor(remainingTime / 60) == 1 ? `` : `s`}` : `second${Math.floor(remainingTime) <= 1 ? `` : `s`}`} before it can be closed.`,

View File

@ -6,98 +6,97 @@ export default new Command({
commandName:"create",
subCommandGroup:"tickets",
execute(interaction){
// 1-90 char only
let supportRoleOnly = interaction.options.data.find(o => o.name == "private")?.value == true || false;
let threadStarter = interaction.member.user.id;
let topic = {
value:interaction.options.getString("topic")
}
if(topic.value.length > 90 || topic.value.length < 1)
return interaction.reply({
content:`Topic must be 1-90 characters`,
ephemeral:true
})
interaction.deferReply({ephemeral:true}).then(() => {
let currentThread = interaction.client.getSupportThreadData(threadStarter);
if(currentThread?.active || false)
return interaction.followUp({
content:`You already have a ticket opened. Please use your current ticket or close your current ticket to open a new one.`,
components:[
{
type:"ACTION_ROW",
components:[
{
type:"BUTTON",
label:"View Current Ticket",
style:"LINK",
url:`https://discord.com/channels/${interaction.guildId}/${currentThread?.threadChannelId}`
},
{
type:"BUTTON",
label:"Close Current Ticket",
style:"DANGER",
customId:`close_ticket_${threadStarter}`
}
]
}
]
})
interaction.client.createSupportThread({
shortDesc:topic.value.toString(),
userId:threadStarter,
privateTicket: supportRoleOnly
})
.then(channel => {
const questions = [
`- Firmware and CFW / Atmosphere / DeepSea version`,
`- Do you use hekate or fusee-primary?`,
`- If you have an error screen with ID or code, what does it say? A screenshot/picture could be helpful.`,
`- What, if anything, have you tried to fix the issue?`,
`- Are you coming for support with SDSetup or DeepSea?`
];
(interaction.client.channels.cache.get(channel.id) as ThreadChannel).send({
"content":`${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..*"}\n\nHey <@${threadStarter}>, 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${questions.join("\n")}`,
"components":[
{
"type":1,
"components":[
{
"type":2,
"style":2,
"customId":`close_ticket_${threadStarter}`,
"label":"Close Ticket",
"emoji":"🔒"
},
{
"type":"BUTTON",
"style":"SECONDARY",
"customId":`switch_ticket_type_${threadStarter}`,
"label":`Switch to ${supportRoleOnly?"Public":"Private"} Ticket`
}
]
}
]
}).then(r => {
r.channel.awaitMessages({
max:1,
filter(message){
return message.author.id == threadStarter;
}
}).then(async () => {
await interaction.client.updateSupportThread({
threadId:channel.id,
userId:threadStarter
});
(interaction.client.channels.cache.get(channel.id) as ThreadChannel).send({
"content":`Thanks! <@&${config.supportRoleId}> will be here to support you shortly.\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.)*`
})
})
interaction.followUp({
content:`Ticket is ready in <#${channel.id}>`,
ephemeral:true
})
})
})
// 1-90 char only
let supportRoleOnly = interaction.options.data.find(o => o.name == "private")?.value == true || false;
let threadStarter = interaction.member.user.id;
let topic = {
value:interaction.options.getString("topic")
}
if(topic.value.length > 90 || topic.value.length < 1)
return interaction.reply({
content:`Topic must be 1-90 characters`,
ephemeral:true
})
// @ts-ignore
interaction.client.api.interactions(interaction.id)(interaction.token).callback.post({
data:{
type: 9,
data: {
components: [
{
type: 1,
components: [
{
type: 4,
custom_id: 'question1',
style: 1,
label: 'Firmware + Atmosphere / DeepSea version',
placeholder: 'FW X.X.X, Atmosphere X.X.X, DeepSea X.X.X',
max_length:50
}
]
},
{
type: 1,
components:[
{
type: 4,
custom_id: 'question2',
style: 1,
label: 'Do you use Hekate or Fusee?',
placeholder: 'Hekate / Fusee',
min_length:5,
max_length:50
}
]
},
{
type: 1,
components:[
{
type: 4,
custom_id: 'question3',
style: 1,
label: 'Do you have an error code and screen?',
placeholder: 'Error Code 0000-0000 / Screenshot link',
required:false
}
]
},
{
type: 1,
components:[
{
type: 4,
custom_id: 'question4',
style: 1,
label: 'Coming for support with SDSetup or DeepSea?',
placeholder: 'SDSetup / DeepSea / Something else',
max_length:50
}
]
},
{
type: 1,
components:[
{
type: 4,
custom_id: 'question5',
style: 2,
label: 'Describe your issue and what led up to it',
placeholder: 'Well, I was playing Splatoon 2 until...',
value:topic.value,
min_length:20
}
]
}
],
title: 'Open Support Ticket',
custom_id: 'open_ticket_modal'
}
}
}).then((err) => {
console.log(err)
});
}
})

View File

@ -12,6 +12,7 @@ client.commands = new Collection();
client.messageCommands = new Collection();
client.buttonCommands = new Collection();
client.ctxCommands = new Collection();
client.modalCommands = new Collection();
let activeTickets:ActiveTickets = {};
if(existsSync("./activeTickets.json"))
@ -72,11 +73,21 @@ client.createSupportThread = async (options:{shortDesc:string, userId:string, pr
return createdChannel;
}
client.updateSupportThread = async (options:{userId:string, threadId:string, newType?:TicketType, newName?:string}) => {
client.updateSupportThread = async (options:{userId:string, threadId:string, newType?:TicketType, newName?:string, locked?:string, externalChannelId?:string}) => {
if(!publicThreads[options.threadId] && !privateThreads[options.threadId])
return false;
let threadChannel = client.channels.cache.get(options.threadId) as ThreadChannel;
let newThreadChannelName:string = threadChannel.name;
if(typeof options.locked == 'undefined' && options.locked === undefined || typeof options.locked == 'string'){
activeTickets[options.userId].locked = options.locked;
saveActiveTicketsData()
}
if(typeof options.externalChannelId == 'undefined' && options.externalChannelId === undefined || typeof options.externalChannelId == 'string'){
activeTickets[options.userId].externalChannelId = options.externalChannelId;
saveActiveTicketsData()
}
if(options.newType){
activeTickets[options.userId].type = options.newType;
@ -145,6 +156,7 @@ client.getSupportThreadData = (userId:string) => {
import ContextMenuCommand from './classes/ContextMenuCommand';
import commands from './commands';
import ModalCommand from './classes/ModalCommand';
async function setupApplicationCommands(guildId?:string):Promise<Collection<string, ApplicationCommand>> {
if(guildId)
@ -197,6 +209,21 @@ async function loadCtxCommands(){
}
}
async function loadModalCommands(){
let commandFiles = readdirSync(`./src/modals`)
.filter(file => file.endsWith('.ts'));
for (var commandFileName of commandFiles) {
try {
var commandImport = await import(`./modals/${commandFileName.split(".")[0].toString()}`);
var command:ModalCommand = commandImport.default;
await client.modalCommands.set(command.customId, command)
} catch (err) {
console.error(err)
}
}
}
// Required files
let requiredFiles = ["warnings.json", "userNotes.json"]
for (let index = 0; index < requiredFiles.length; index++) {
@ -224,13 +251,39 @@ process.on('unhandledRejection', error => {
(client.channels.cache.get(config.botLog) as TextChannel).send(`**Uncaught Promise Rejection**\n\`\`\`console\n${error}\`\`\``)
});
// TEMP: Until d.js properly implements Modals
client.ws.on("INTERACTION_CREATE", (payload) => {
if(payload.type === 5){
let commandName = payload.data?.custom_id;
const command = client.modalCommands.find(modal => modal.customId == commandName);
if (command) {
try {
// TEMP: Implement later
// if(command.staffOnly && !isStaff)
// return interaction.reply({
// content:`This is a staff only command.`,
// ephemeral:true
// })
// if(command.staffOnly){
// logStaffCommands(interaction, command)
// }
command.execute(payload, client);
} catch (error) {
console.error(error);
}
}
}
})
//Code for interactions (Slash Commands, Buttons, CTX comamnds)
client.on("interactionCreate", interaction => {
if(!interaction.channel){
console.log(`MISSING INTERACTION.CHANNEL`, `Channel ID: ${interaction.channelId}`, `ID: ${interaction.id}`, `Guild ID: ${interaction.guildId}`, `User ID: ${interaction.member?.user.id}`);
return;
};
console.log(interaction.member.user.id, interaction.member.roles)
let isStaff = (interaction.member?.roles as GuildMemberRoleManager)?.cache?.find(role => config.staffRoles.includes(role.id));
if(interaction.isMessageComponent() && interaction.customId.startsWith("collecter")) return;
@ -274,7 +327,6 @@ client.on("interactionCreate", interaction => {
// Normal Slash Command
if(interaction.isCommand() && !(interaction.options.getSubcommandGroup(false) || interaction.options.getSubcommand(false))){
console.log(interaction)
const command = client.commands.get(interaction.commandName);
if (command) {
@ -294,10 +346,10 @@ client.on("interactionCreate", interaction => {
interaction.reply({content:'Uh oh, something went wrong while running that command. Please open an issue on [GitHub](https://github.com/Team-Neptune/Korral-JS) if the issue persists.'});
}
} else {
interaction.reply({
content:`That command was not found.`,
ephemeral:true
})
// interaction.reply({
// content:`That command was not found.`,
// ephemeral:true
// })
}
}
@ -309,7 +361,6 @@ client.on("interactionCreate", interaction => {
const command = client.commands.find(command => command.subCommandGroup == subCommandGroup
&& command.commandName == commandName);
console.log("cmd", command)
if (command) {
try {
@ -496,9 +547,7 @@ client.on("messageCreate", (message) => {
}
if(message.content.includes("--") && (thisTicketAllowed.authorizedUsers.includes(message.author.id) || message.member.roles.cache.find(r => thisTicketAllowed.authorizedRoles.includes(r.id))) && message.mentions.users.size > 0){
console.log(message.mentions.users, "BEFORE")
message.mentions.users = message.mentions.users.filter(u => privateThreads[message.channel.id].authorizedUsers.includes(u.id));
console.log(message.mentions.users, "AFTER")
if(message.mentions.users.size == 0) return;
privateThreads[message.channel.id].authorizedUsers = privateThreads[message.channel.id].authorizedUsers.filter(authorizedUserId => !message.mentions.users.has(authorizedUserId));
saveThreadsData()
@ -577,6 +626,7 @@ async function startBot(){
await loadButtonCommands();
await loadSlashCommands();
await loadCtxCommands();
await loadModalCommands();
await setupDeepsea();
await client.login(config.token);
if(!existsSync("./commands_setup.flag")){

View File

@ -0,0 +1,43 @@
import { config } from "../../config";
import ModalCommand from "../classes/ModalCommand";
export default new ModalCommand({
customId:"closed_ticket_feedback",
async execute(interaction, client){
let feedbackContent:string = interaction.data.components[0]?.components[0]?.value;
let supportThreadData = client.getSupportThreadData(interaction.member?.user.id || interaction.user.id);
console.log(interaction.data.components)
// @ts-ignore
client.api.interactions(interaction.id)(interaction.token).callback.post({
data:{
type:6
}
})
if(!config.closingTicketsSettings?.incomingFeedbackChannel) return;
if(!feedbackContent || feedbackContent?.trim() === '') return;
let feedbackChannel = client.channels.cache.get(config.closingTicketsSettings?.incomingFeedbackChannel);
let ticketChannel = await client.channels.fetch(supportThreadData.threadChannelId);
if(feedbackChannel?.isText() && ticketChannel?.isThread()){
feedbackChannel.send({
embeds:[
{
title:"Ticket Feedback",
description:feedbackContent || "*No response given*",
fields:[
{
name:`Ticket`,
value:`[${ticketChannel.name}](https://discord.com/channels/${ticketChannel.guildId}/${ticketChannel.id}) (${supportThreadData.threadChannelId})`,
inline:true
},
{
name:`User`,
value:`<@${supportThreadData.userId}> (${supportThreadData.userId})`,
inline:true
}
]
}
]
})
}
}
})

78
src/modals/open_ticket.ts Normal file
View File

@ -0,0 +1,78 @@
import { ThreadChannel } from "discord.js";
import { config } from "../../config";
import ModalCommand from "../classes/ModalCommand";
interface PromptedQuestion {
question:string
response:string
}
export default new ModalCommand({
customId:"open_ticket_modal",
staffOnly:false,
async execute(interaction, client){
console.log("INTERACTION_CREATE_open_ticket_modal", interaction)
let threadStarter = interaction.member.user.id;
let supportRoleOnly = false;
let threadStarterMember = await client.guilds.cache.get(interaction.guild_id).members.fetch(threadStarter);
client.createSupportThread({
shortDesc:`${threadStarterMember.nickname || threadStarterMember.user.tag}`,
userId:threadStarter,
privateTicket:supportRoleOnly
})
.then(channel => {
const promptedQuestions = [
`Firmware + Atmosphere / DeepSea version`,
`Do you use Hekate or Fusee-Primary?`,
`Do you have an error code/screen?`,
`Coming for support with SDSetup or DeepSea?`,
`Describe your issue and what led up to it`
];
let questionResponses:PromptedQuestion[] = promptedQuestions.map((question, index) => {
return {
question,
response:interaction.data.components[index]?.components[0]?.value || "Not provided"
}
});
(client.channels.cache.get(channel.id) as ThreadChannel).send({
"content":`${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..*"}\n\nHey <@${threadStarter}>, <@&${config.supportRoleId}> will be with you as soon as they're able to. Here are the responses you gave in the prompt:\n\n${questionResponses.map(q => `**${q.question}:**\n${q.response}`).join("\n\n")}\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.)*`,
"components":[
{
"type":1,
"components":[
{
"type":2,
"style":2,
"customId":`close_ticket_${threadStarter}`,
"label":"Close Ticket",
"emoji":"🔒"
},
{
"type":"BUTTON",
"style":"SECONDARY",
"customId":`staff_controls_${threadStarter}`,
"label":"Staff Controls",
"emoji":"🛠"
}
]
}
]
}).then(async () => {
await client.updateSupportThread({
threadId:channel.id,
userId:threadStarter
});
// @ts-ignore
client.api.interactions(interaction.id)(interaction.token).callback.post({
data:{
type:4,
data:{
content:`Ticket is ready in <#${channel.id}>`,
flags:64
}
}
})
})
})
}
})

16
typings/index.d.ts vendored
View File

@ -2,6 +2,7 @@ import {Message, Collection} from 'discord.js'
import ButtonCommand from '../src/classes/ButtonCommand'
import Command from '../src/classes/Command'
import ContextMenuCommand from '../src/classes/ContextMenuCommand'
import ModalCommand from '../src/classes/ModalCommand'
export interface MessageCommand {
/** Command name */
name:string,
@ -60,9 +61,11 @@ export interface Config {
},
closingTicketsSettings?:{
/** Minimum amount of seconds the ticket has to be open before it can be closed */
ticketsMinimumAge?:number,
ticketsMinimumAge?:number
/** Message to be sent when ticket is closed */
closeMessage?:string
/** The channel to send incoming feedback from closing tickets */
incomingFeedbackChannel?:string
}
/** Location of warnings.json */
warningJsonLocation:string
@ -89,14 +92,15 @@ declare module 'discord.js' {
commands: Collection<string, Command>
messageCommands: Collection<string, MessageCommand>
buttonCommands: Collection<string, ButtonCommand>
ctxCommands: Collection<string, ContextMenuCommand>,
ctxCommands: Collection<string, ContextMenuCommand>
modalCommands:Collection<string, ModalCommand>
createSupportThread(options:{
shortDesc:string,
userId:string,
privateTicket:boolean
}):Promise<ThreadChannel>
getSupportThreadData(userId:string):ActiveTicketsData
updateSupportThread(options:{userId:string, threadId:string, newType?:TicketType, newName?:string}):Promise<boolean>
updateSupportThread(options:{userId:string, threadId:string, newType?:TicketType, newName?:string, locked?:string, externalChannelId?:string}):Promise<boolean>
closeSupportThread(options:{
userId:string,
channelId?:string,
@ -132,7 +136,11 @@ interface ActiveTicketsData {
userId:string,
active:boolean,
createdMs:number,
type:TicketType
type:TicketType,
/** Either not present or User ID */
locked?:string
/** Either not present or Channel ID */
externalChannelId?:string
}
interface ActiveTickets {