diff --git a/bind-commands.ts b/bind-commands.ts new file mode 100644 index 0000000..6b7e567 --- /dev/null +++ b/bind-commands.ts @@ -0,0 +1,26 @@ +import { Collection } from "discord.js"; +import { client } from "./bot"; +import path from 'node:path'; +import fs from "fs" + +export const bindCommands = async () => { + client.commands = new Collection(); + const foldersPath = path.join(__dirname, 'commands'); + const commandFolders = fs.readdirSync(foldersPath); + + for (const folder of commandFolders) { + const commandsPath = path.join(foldersPath, folder); + const commandFiles = fs.readdirSync(commandsPath).filter(file => file.endsWith('.ts')); + for (const file of commandFiles) { + const filePath = path.join(commandsPath, file); + const command = require(filePath); + // Set a new item in the Collection with the key as the command name and the value as the exported module + if ('data' in command && 'execute' in command) { + client.commands.set(command.data.name, command); + console.log(`Bound to ${filePath} command`) + } else { + console.log(`[WARNING] The command at ${filePath} is missing a required "data" or "execute" property.`); + } + } + } +} \ No newline at end of file diff --git a/bot.ts b/bot.ts index 07d9489..1d9b504 100644 --- a/bot.ts +++ b/bot.ts @@ -1,7 +1,9 @@ -import { Client, GatewayIntentBits, GuildMember, Channel, TextBasedChannel, Events, Message, SlashCommandBuilder, Collection } from 'discord.js'; +import { Client, GatewayIntentBits, Collection, ContextMenuCommandBuilder, ApplicationCommandType } from 'discord.js'; import * as dotenv from 'dotenv'; -import path from 'node:path'; -import fs from 'node:fs' +import { deployCommands } from './deploy-commands'; +import { bindCommands } from './bind-commands'; +import { bindToEvents } from './utils/bind-events'; +import { loadMessages } from './model/messages'; dotenv.config(); @@ -11,92 +13,16 @@ declare module "discord.js" { } } -import config from "./config"; +export const client = new Client({ intents: [GatewayIntentBits.Guilds, GatewayIntentBits.GuildMessages, GatewayIntentBits.MessageContent, GatewayIntentBits.GuildMembers] }); -export const client = new Client({ intents: [GatewayIntentBits.Guilds, GatewayIntentBits.GuildMessages, GatewayIntentBits.MessageContent] }); +const init = async () => { + await deployCommands(); + await bindCommands(); -import handleMessage from './messageHandler'; -import search from './model/jira'; + client.login(process.env.token); -client.on("ready", () => { - console.log("successfully logged in"); -}); + bindToEvents(); + loadMessages(); +} -client.on("guildMemberAdd", async (member: GuildMember) => { - const msg = `Welcome, <@${member.id}>! If you joined for any specific support questions ` - + `please check out <#863171642905591830> first to see if your issue is known, ` - + `and make sure that your app is up-to-date before posting.`; - - const channel: TextBasedChannel | null = await client.channels.fetch(config.channels.joins) as TextBasedChannel; - channel?.send(msg); -}); - - -client.on(Events.MessageCreate, async (msg: Message) => { - - if (msg.content.startsWith(".cl ")) { - const version = msg.content.substring(4) - try { - // Test if a number - const versionNumber = Number(version) - const issues = await search(version); - - const issueDataList: { title: string, key: string }[] = [] - - issues.forEach((issue) => { - issueDataList.push({ title: issue.fields.summary, key: issue.key }) - }) - - const issueTextList: string[] = [] - - issueDataList.forEach((issue) => { - issueTextList.push(`[${issue.key}] ${issue.title}`) - }) - - let issueIndex = 0 - - let messagesToSend: string[] = [] - - let parsedAll = false - while (!parsedAll) { - let formattedMsg = "" - - let foundLength = 6 - for (let i = issueIndex; i < issueTextList.length + 1; ++i) { - if (i == issueTextList.length) { - parsedAll = true; - break; - } - - const line = `${issueTextList[i]}\n` - if (foundLength + line.length > 1999) { - break; - } - - formattedMsg += line - foundLength += line.length - issueIndex = i; - } - - messagesToSend.push(`\`\`\`${formattedMsg}\`\`\``) - } - - let index = 0 - messagesToSend.forEach((messageToSend) => { - let waitMultiplier = index + 1 - let waitTime = 1000 * waitMultiplier - setTimeout(() => { - msg.channel.send(messageToSend) - }, waitTime); - index++; - }) - } - catch (e) { - } - } - else { - await handleMessage(msg).catch(console.error); - } -}); - -client.login(process.env.token); +init(); \ No newline at end of file diff --git a/commands/jira/create-bug.ts b/commands/jira/create-bug.ts new file mode 100644 index 0000000..4b29931 --- /dev/null +++ b/commands/jira/create-bug.ts @@ -0,0 +1,134 @@ +import axios from "axios"; +import { ApplicationCommandType, CommandInteraction, ContextMenuCommandBuilder, ModalBuilder, ModalSubmitInteraction, StringSelectMenuBuilder, StringSelectMenuOptionBuilder, TextInputBuilder, TextInputStyle } from "discord.js"; +import config from "../../config"; +import { frequencies, getJiraToken, severites } from "../../utils/jira"; + +const { ActionRowBuilder, } = require('discord.js'); + +const getPlaceholderFromList = (list: { label: string, value: string }[]) => { + let placeholder: string = '' + + for (let i = 0; i < list.length; ++i) { + placeholder += `${i + 1}=${list[i].label}\n` + } + + return placeholder +} + +module.exports = { + data: new ContextMenuCommandBuilder() + .setName('Create Bug') + .setDefaultMemberPermissions(0) + .setType(ApplicationCommandType.Message), + async execute(interaction: CommandInteraction) { + + const modal = new ModalBuilder() + .setCustomId('createBugModal') + .setTitle('Create a new bug'); + + const titleInput = new TextInputBuilder() + .setCustomId('title') + .setLabel("What should we call the bug?") + .setRequired(true) + .setMaxLength(200) + .setStyle(TextInputStyle.Short); + + const descInput = new TextInputBuilder() + .setCustomId('desc') + .setLabel("Description/More info") + .setRequired(false) + .setMaxLength(1000) + .setStyle(TextInputStyle.Paragraph); + + const frequencyInput = new TextInputBuilder() + .setCustomId('frequency') + .setLabel("How often does it happen?") + .setMaxLength(1) + .setMinLength(1) + .setRequired(true) + .setPlaceholder(getPlaceholderFromList(frequencies)) + .setStyle(TextInputStyle.Paragraph); + + const severityInput = new TextInputBuilder() + .setCustomId('severity') + .setLabel("How severe is the bug?") + .setMaxLength(1) + .setMinLength(1) + .setRequired(true) + .setPlaceholder(getPlaceholderFromList(severites)) + .setStyle(TextInputStyle.Paragraph); + + const firstActionRow = new ActionRowBuilder().addComponents(titleInput); + const secondActionRow = new ActionRowBuilder().addComponents(descInput); + const thirdActionRow = new ActionRowBuilder().addComponents(severityInput); + const fourthActionRow = new ActionRowBuilder().addComponents(frequencyInput); + + modal.addComponents(firstActionRow, secondActionRow, thirdActionRow, fourthActionRow); + + await interaction.showModal(modal); + + const result = await interaction.awaitModalSubmit({ + time: 60000, + filter: i => i.user.id === interaction.user.id, + }).catch((e) => undefined) + + if (result == undefined) { + return; + } + + const title = result.fields.getTextInputValue('title') + const frequency = result.fields.getTextInputValue('frequency') + const severity = result.fields.getTextInputValue('severity') + const descrption = result.fields.getTextInputValue('desc') + + if (Number.isNaN(Number.parseInt(severity))) { + result.reply({ ephemeral: true, content: `You entered a non-number for severity. Please only submit a number as listed in the placeholder!` }) + return; + } + + if (Number.isNaN(Number.parseInt(frequency))) { + result.reply({ ephemeral: true, content: `You entered a non-number for frequency. Please only submit a number as listed in the placeholder!` }) + return; + } + + if (interaction.isMessageContextMenuCommand()) { + const messageUrl = interaction.targetMessage.url + + const url = `${config.jira.baseUrl}/issue`; + const issuePayload = { + fields: { + project: { + key: "SP" + }, + summary: title, + description: `Issue submitted from Spot in reaction to: ${messageUrl}\n\n${descrption}`, + issuetype: { + id: "10005" + }, + customfield_10057: { + id: severites[parseInt(severity) - 1].value + }, + customfield_10056: { + id: frequencies[parseInt(frequency) - 1].value + } + } + } + + console.log(issuePayload); + + + const serverResponse = await new axios.Axios({}).post(url, JSON.stringify(issuePayload), { headers: { "Authorization": getJiraToken(), "Content-Type": 'application/json' }, },).catch((e) => console.error(e)) + if (serverResponse && serverResponse.status == 201) { + const responseData = JSON.parse(serverResponse.data) + result.reply({ ephemeral: false, content: `A bug report has been created for your issue under issue-ID ${responseData.key}!`, target: interaction.targetMessage, options: { flags: "SuppressNotifications" } }) + } + else { + console.log(serverResponse) + result.reply({ ephemeral: true, content: 'Your submission failed. Something went wrong' }) + } + } + else { + result.reply({ ephemeral: true, content: 'Unable to find message this bug belongs to' }) + } + }, +}; diff --git a/commands/utility/ping.ts b/commands/utility/ping.ts new file mode 100644 index 0000000..2ce148b --- /dev/null +++ b/commands/utility/ping.ts @@ -0,0 +1,12 @@ +import { CommandInteraction } from "discord.js"; + +const { SlashCommandBuilder } = require('discord.js'); + +module.exports = { + data: new SlashCommandBuilder() + .setName('ping') + .setDescription('Replies with Pong!'), + async execute(interaction: CommandInteraction) { + await interaction.reply('Pong!'); + }, +}; diff --git a/deploy-commands.ts b/deploy-commands.ts new file mode 100644 index 0000000..782a245 --- /dev/null +++ b/deploy-commands.ts @@ -0,0 +1,44 @@ +import config from "./config"; + +const { REST, Routes } = require('discord.js'); +const fs = require('node:fs'); +const path = require('node:path'); +const dotenv = require('dotenv'); + +export const deployCommands = async () => { + dotenv.config(); + + const commands: any[] = []; + const foldersPath = path.join(__dirname, 'commands'); + const commandFolders = fs.readdirSync(foldersPath); + + for (const folder of commandFolders) { + const commandsPath = path.join(foldersPath, folder); + const commandFiles = fs.readdirSync(commandsPath).filter((file: string) => file.endsWith('.ts')); + for (const file of commandFiles) { + const filePath = path.join(commandsPath, file); + const command = require(filePath); + if ('data' in command && 'execute' in command) { + commands.push(command.data.toJSON()); + } else { + console.log(`[WARNING] The command at ${filePath} is missing a required "data" or "execute" property.`); + } + } + } + + const rest = new REST().setToken(process.env.token); + + try { + console.log(`Started refreshing ${commands.length} application (/) commands.`); + + const data = await rest.put( + Routes.applicationGuildCommands(process.env.appid, config.guild), + { body: commands }, + ); + + console.log(`Successfully reloaded ${data.length} application (/) commands.`); + } catch (error) { + console.error(error); + } +} + diff --git a/events/guild-member-add.ts b/events/guild-member-add.ts new file mode 100644 index 0000000..8368613 --- /dev/null +++ b/events/guild-member-add.ts @@ -0,0 +1,12 @@ +import { GuildMember, TextBasedChannel } from "discord.js"; +import { client } from "../bot"; +import config from "../config"; + +export const onGuildMemberAdd = async (member: GuildMember) => { + const msg = `Welcome, <@${member.id}>! If you joined for any specific support questions ` + + `please check out <#863171642905591830> first to see if your issue is known, ` + + `and make sure that your app is up-to-date before posting.`; + + const channel: TextBasedChannel | null = await client.channels.fetch(config.channels.joins) as TextBasedChannel; + channel?.send(msg); +} \ No newline at end of file diff --git a/events/interaction-create.ts b/events/interaction-create.ts new file mode 100644 index 0000000..edd20fe --- /dev/null +++ b/events/interaction-create.ts @@ -0,0 +1,32 @@ + +import axios from "axios"; +import { CacheType, ChatInputCommandInteraction, Interaction, MessageContextMenuCommandInteraction, ModalSubmitInteraction, UserContextMenuCommandInteraction } from "discord.js"; +import { client } from "../bot"; +import config from "../config"; +import { frequencies, getJiraToken, severites } from "../utils/jira"; + +export const onInteractionCreate = async (interaction: Interaction) => { + if (interaction.isChatInputCommand() || interaction.isContextMenuCommand()) { + onCommand(interaction); + } +} + +const onCommand = async (interaction: ChatInputCommandInteraction | MessageContextMenuCommandInteraction | UserContextMenuCommandInteraction) => { + const command = client.commands.get(interaction.commandName); + + if (!command) { + console.error(`No command matching ${interaction.commandName} was found.`); + return; + } + + try { + await command.execute(interaction); + } catch (error) { + console.error(error); + if (interaction.replied || interaction.deferred) { + await interaction.followUp({ content: 'There was an error while executing this command!', ephemeral: true }); + } else { + await interaction.reply({ content: 'There was an error while executing this command!', ephemeral: true }); + } + } +} diff --git a/events/message-create.ts b/events/message-create.ts new file mode 100644 index 0000000..a8573c8 --- /dev/null +++ b/events/message-create.ts @@ -0,0 +1,70 @@ +import { Message } from "discord.js"; +import handleMessage from "../messageHandler"; +import search from "../model/jira"; + +export const onMessageCreate = async (msg: Message) => { + + if (msg.content.startsWith(".cl ")) { + const version = msg.content.substring(4) + try { + // Test if a number + const versionNumber = Number(version) + const issues = await search(version); + + const issueDataList: { title: string, key: string }[] = [] + + issues.forEach((issue) => { + issueDataList.push({ title: issue.fields.summary, key: issue.key }) + }) + + const issueTextList: string[] = [] + + issueDataList.forEach((issue) => { + issueTextList.push(`[${issue.key}] ${issue.title}`) + }) + + let issueIndex = 0 + + let messagesToSend: string[] = [] + + let parsedAll = false + while (!parsedAll) { + let formattedMsg = "" + + let foundLength = 6 + for (let i = issueIndex; i < issueTextList.length + 1; ++i) { + if (i == issueTextList.length) { + parsedAll = true; + break; + } + + const line = `${issueTextList[i]}\n` + if (foundLength + line.length > 1999) { + break; + } + + formattedMsg += line + foundLength += line.length + issueIndex = i; + } + + messagesToSend.push(`\`\`\`${formattedMsg}\`\`\``) + } + + let index = 0 + messagesToSend.forEach((messageToSend) => { + let waitMultiplier = index + 1 + let waitTime = 1000 * waitMultiplier + setTimeout(() => { + msg.channel.send(messageToSend) + }, waitTime); + index++; + }) + } + catch (e) { + } + } + else { + await handleMessage(msg).catch(console.error); + } +} \ No newline at end of file diff --git a/events/ready.ts b/events/ready.ts new file mode 100644 index 0000000..de07072 --- /dev/null +++ b/events/ready.ts @@ -0,0 +1,3 @@ +export const onReady = () => { + console.log("Bot ready!") +} \ No newline at end of file diff --git a/model/jira.ts b/model/jira.ts index fbf3e9f..cb48466 100644 --- a/model/jira.ts +++ b/model/jira.ts @@ -1,5 +1,6 @@ import * as axios from 'axios'; import config from '../config'; +import { getJiraToken } from '../utils/jira'; export default async function search(version?: string): Promise { const params = { @@ -17,14 +18,19 @@ export default async function search(version?: string): Promise { do { // @ts-expect-error - const httpParams = Object.keys(params).map(key => `${key}=${params[key]}`).join('&'); + const httpParams = Object.keys(params).map((key: string) => `${key}=${params[key]}`).join('&'); const url = `${config.jira.baseUrl}/search?${httpParams}`; - const serverResponse = await new axios.Axios({}).get(url); - jsonResponse = JSON.parse(serverResponse.data); + const serverResponse = await new axios.Axios({}).get(url, { headers: { "Authorization": getJiraToken() } }).catch((e) => console.error(e)) + if (serverResponse) { + jsonResponse = JSON.parse(serverResponse.data); + issues.push(...jsonResponse!.issues); + params.startAt += 100; + } + else { + return [] + } - issues.push(...jsonResponse!.issues); - params.startAt += 100; } while (jsonResponse!.issues.length > 99 && jsonResponse!.total > 100); return issues; diff --git a/model/messages.ts b/model/messages.ts index 98f2d94..afab521 100644 --- a/model/messages.ts +++ b/model/messages.ts @@ -12,7 +12,7 @@ interface Message { export const messages: Record = {}; let avatarUrl = ""; -const load = async () => { +export const loadMessages = async () => { const messagesList = require('../messages.json')["faq"]; messagesList.forEach((msg: any) => { messages[msg["title"]] = msg @@ -26,9 +26,6 @@ const load = async () => { console.log(avatarUrl); } -load(); - - export const get = (name: string) => messages[name]; export const getList = () => Object.keys(messages).map(msg => ({ name: messages[msg].names[0], value: messages[msg].description })); diff --git a/utils/bind-events.ts b/utils/bind-events.ts new file mode 100644 index 0000000..ff5bbfb --- /dev/null +++ b/utils/bind-events.ts @@ -0,0 +1,13 @@ +import { Events } from "discord.js"; +import { client } from "../bot"; +import { onGuildMemberAdd } from "../events/guild-member-add"; +import { onInteractionCreate } from "../events/interaction-create"; +import { onMessageCreate } from "../events/message-create"; +import { onReady } from "../events/ready"; + +export const bindToEvents = () => { + client.on(Events.GuildMemberAdd, onGuildMemberAdd); + client.on(Events.MessageCreate, onMessageCreate); + client.on(Events.ClientReady, onReady); + client.on(Events.InteractionCreate, onInteractionCreate) +} diff --git a/utils/jira.ts b/utils/jira.ts new file mode 100644 index 0000000..f70febc --- /dev/null +++ b/utils/jira.ts @@ -0,0 +1,46 @@ +export const getJiraToken = () => { + const token = Buffer.from(`${process.env.jiramail}:${process.env.jiratoken}`).toString('base64') + return `Basic ${token}` +} + +export const frequencies = [ + { + label: "Everytime (everyone)", + value: "10023" + }, + { + label: "Sometimes (everyone)", + value: "10024" + }, + { + label: "Everytime (some)", + value: "10025" + }, + { + label: "Everyime/Always (one user)", + value: "10027" + } +] + +export const severites = [ + { + label: "Unusable", + value: "10028" + }, + { + label: "Gray screen", + value: "10030" + } + , { + label: "Data", + value: "10032" + }, + { + label: "Malfunctioning", + value: "10033" + }, + { + label: "Layout Issue", + value: "10034" + } +] \ No newline at end of file