diff --git a/assets/orbitconnect.gif b/assets/orbitconnect.gif new file mode 100644 index 0000000..434faf3 Binary files /dev/null and b/assets/orbitconnect.gif differ diff --git a/assets/orbitprojectconnect.gif b/assets/orbitprojectconnect.gif new file mode 100644 index 0000000..71e45c9 Binary files /dev/null and b/assets/orbitprojectconnect.gif differ diff --git a/assets/orbitprojectdisconnect.gif b/assets/orbitprojectdisconnect.gif new file mode 100644 index 0000000..201a8f9 Binary files /dev/null and b/assets/orbitprojectdisconnect.gif differ diff --git a/package-lock.json b/package-lock.json index 5d5918e..a94031b 100644 --- a/package-lock.json +++ b/package-lock.json @@ -9,6 +9,7 @@ "version": "0.1.0", "dependencies": { "@aws-sdk/client-dynamodb": "^3.451.0", + "cors": "^2.8.5", "discord-interactions": "^3.2.0", "discord.js": "^14.14.1", "dotenv": "^16.0.3", @@ -1960,6 +1961,18 @@ "resolved": "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.0.6.tgz", "integrity": "sha512-QADzlaHc8icV8I7vbaJXJwod9HWYp8uCqf1xa4OfNu1T7JVxQIrUgOWtHdNDtPiywmFbiS12VjotIXLrKM3orQ==" }, + "node_modules/cors": { + "version": "2.8.5", + "resolved": "https://registry.npmjs.org/cors/-/cors-2.8.5.tgz", + "integrity": "sha512-KIHbLJqu73RGr/hnbrO9uBeixNGuvSQjul/jdFvS/KFSIH1hWVd1ng7zOHx+YrEfInLG7q4n6GHQ9cDtxv/P6g==", + "dependencies": { + "object-assign": "^4", + "vary": "^1" + }, + "engines": { + "node": ">= 0.10" + } + }, "node_modules/cross-spawn": { "version": "7.0.3", "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.3.tgz", @@ -2824,6 +2837,14 @@ "node": ">=0.10.0" } }, + "node_modules/object-assign": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz", + "integrity": "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==", + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/object-inspect": { "version": "1.13.1", "resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.13.1.tgz", diff --git a/package.json b/package.json index 61608fb..29759da 100644 --- a/package.json +++ b/package.json @@ -8,9 +8,9 @@ "node": "^18.x" }, "scripts": { - "start": "node build/app.js", + "start": "node build/server.js", "register": "node build/deploy-commands.js", - "dev": "nodemon build/app.js", + "dev": "npm run build && nodemon build/server.js", "build": "rimraf ./build && tsc", "format:check": "prettier --check .", "format": "prettier --write ." @@ -18,6 +18,7 @@ "author": "Armin T, LP", "dependencies": { "@aws-sdk/client-dynamodb": "^3.451.0", + "cors": "^2.8.5", "discord-interactions": "^3.2.0", "discord.js": "^14.14.1", "dotenv": "^16.0.3", diff --git a/src/app.ts b/src/app.ts index dc36b94..38f1faf 100644 --- a/src/app.ts +++ b/src/app.ts @@ -1,225 +1,16 @@ -//@ts-nocheck -import { Client, Collection, Events, GatewayIntentBits } from "discord.js"; -import fs from "fs"; -import path from "path"; -import dotenv from "dotenv"; -import { ALL_RESPONSES, createResponse } from "./util/responses.js"; -import { DbHandler, userGithubMap } from "./model/dbHandler.js"; -import { isRepoMember } from "./util/github.js"; +import express from "express"; +import cors from "cors"; -dotenv.config(); -const __dirname = new URL(".", import.meta.url).pathname; -const TOKEN = process.env.DISCORD_TOKEN; -const LP_GITHUB_APP_CLIENT_ID = process.env.LP_GITHUB_APP_CLIENT_ID; -const GUILD_ID = process.env.GUILD_ID; +import orbitRouter from "./routers/orbitRouter.js"; -// Create a new client instance -const client = new Client({ - intents: [ - GatewayIntentBits.Guilds, - GatewayIntentBits.DirectMessages, - GatewayIntentBits.GuildMembers, - ], -}); - -client.commands = new Collection(); -const commandsPath = path.join(__dirname, "commands"); -const commandFiles = fs - .readdirSync(commandsPath) - .filter((file) => file.endsWith(".js")); - -for (const file of commandFiles) { - const filePath = path.join(commandsPath, file); - const command = await import(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); - } else { - console.log( - `[WARNING] The command at ${filePath} is missing a required "data" or "execute" property.`, - ); - } -} - -class DiscordServer { - guild; - roles; - - constructor() { - this.client = client; - } - - async init() { - await this.client.login(TOKEN); - console.log("Discord client logged in"); - } - - async getparams() { - const guild = await this.client.guilds.fetch(GUILD_ID); - const roles = await guild.roles.fetch(); - - this.guild = guild; - this.roles = {}; - for (const [id, role] of roles) { - this.roles[role.name.toLowerCase()] = role; - } - } -} - -let server; -const TABLE_NAME = "rocket"; -const dbHandler = new DbHandler(); - -// When the client is ready, run this code (only once) -// We use 'c' for the event parameter to keep it separate from the already defined 'client' -client.once(Events.ClientReady, (c) => { - console.log(`Ready! Logged in as ${c.user.tag}`); -}); - -client.on(Events.InteractionCreate, async (interaction) => { - // console.log("Interaction received"); - if (interaction.isButton()) { - if (interaction.customId.startsWith("verify_button_")) { - // console.log("Verify button pressed"); - - const githubResponse = userGithubMap[interaction.user.id]; +const app = express(); +app.use(cors()); +app.use(express.json()); - if (!githubResponse) { - await interaction.update({ - content: createResponse(ALL_RESPONSES.connectionIssue, [ - interaction.user.id, - ]), - components: [], - }); - return; - } - - let device_code = userGithubMap[interaction.user.id].device_code; - let grant_type = "urn:ietf:params:oauth:grant-type:device_code"; - let resp = await fetch( - `https://github.com/login/oauth/access_token?client_id=${LP_GITHUB_APP_CLIENT_ID}&device_code=${device_code}&grant_type=${grant_type}`, - { - method: "POST", - headers: { - "X-GitHub-Api-Version": "2022-11-28", - Accept: "application/json", - }, - }, - ); - - let data = await resp.json(); - - let access_token = data.access_token; - - if (!access_token) { - await interaction.update({ - content: createResponse(ALL_RESPONSES.connectionIssue, [ - interaction.user.id, - ]), - components: [], - }); - return; - } - - let resp2 = await fetch(`https://api.github.com/user`, { - method: "GET", - headers: { - Authorization: `token ${access_token}`, - Accept: "application/json", - }, - }); - let data2 = await resp2.json(); - let st = await isRepoMember(data2.login); - - if (!st) { - await interaction.update({ - content: createResponse(ALL_RESPONSES.checkMeNotMember, []), - components: [], - }); - } - - await dbHandler.setRecord(TABLE_NAME, interaction.user.id, { - PK: { - S: interaction.user.id, - }, - SK: { - S: interaction.user.id, - }, - integrations: { - M: { - github: { - M: { - username: { - S: data2.login, - }, - id: { - N: String(data2.id), - }, - date: { - S: new Date().toISOString(), - }, - }, - }, - }, - }, - }); - - // get the guild - const member = await server.guild.members.fetch(interaction.user.id); - await member.roles.add(server.roles["member"].id); - await interaction.update({ - content: createResponse(ALL_RESPONSES.checkMeSuccess, [ - interaction.user.id, - ]), - components: [], - }); - } - } else if (interaction.isCommand()) { - const command = interaction.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, - }); - } - } - } -}); - -client.on(Events.MessageCreate, (message) => { - // console.log("Message received"); - if (!message.content.startsWith("!") || message.author.bot) return; - // console.log(message); -}); +app.use("/orbit", orbitRouter); -client.on(Events.GuildMemberAdd, async (member) => { - try { - await client.users.send(member.user.id, { - content: createResponse(ALL_RESPONSES.welcomeMessage, [member.user.id]), - }); - } catch (error) { - console.log("Failed to send DM:", error); - } +app.get("/ping", async (req, res) => { + res.send("pong"); }); -// Log in to Discord with your client's token -server = new DiscordServer(); -await server.init(); -await server.getparams(); +export default app; diff --git a/src/commands/connectOrbit.ts b/src/commands/connectOrbit.ts new file mode 100644 index 0000000..bedb8c2 --- /dev/null +++ b/src/commands/connectOrbit.ts @@ -0,0 +1,36 @@ +import { + SlashCommandBuilder, + ActionRowBuilder, + TextInputBuilder, + TextInputStyle, + ModalBuilder, + ModalActionRowComponentBuilder +} from "discord.js"; + +const data = new SlashCommandBuilder() + .setName("connect-orbit") + .setDescription("Connects your discord to orbit for notifications"); + +const modal = new ModalBuilder() + .setCustomId('connectOrbitModal') + .setTitle('Settings for connecting to Orbit'); + +// Add components to modal +const emailInput = new TextInputBuilder() + .setCustomId('emailInput') + .setLabel("What's email linked to Orbit") + .setStyle(TextInputStyle.Short); + +// An action row only holds one text input, +// so you need one action row per text input. +const firstActionRow = new ActionRowBuilder().addComponents(emailInput); + +// Add inputs to the modal +modal.addComponents(firstActionRow); + +async function execute(interaction) { + // Show the modal to the user + await interaction.showModal(modal); +} + +export { data, execute }; diff --git a/src/commands/connectOrbitProject.ts b/src/commands/connectOrbitProject.ts new file mode 100644 index 0000000..865657a --- /dev/null +++ b/src/commands/connectOrbitProject.ts @@ -0,0 +1,44 @@ +import { + SlashCommandBuilder, + ActionRowBuilder, + StringSelectMenuBuilder, + StringSelectMenuOptionBuilder +} from "discord.js"; + +const data = new SlashCommandBuilder() + .setName("connect-orbit-project") + .setDescription("Connects your discord to orbit for notifications"); + +async function execute(interaction) { + const select = new StringSelectMenuBuilder() + .setCustomId('TeamSelect') + .setPlaceholder('Make a selection!'); + + const row = new ActionRowBuilder().addComponents(select); + + const teams = await fetch("http://localhost:3000/api/teams").then(res => res.json()) as Array; + + if (teams == null || teams.length == 0) { + await interaction.reply({ + content: "No teams to connect to!", + components: [], + }); + return; + } + + teams.forEach(team => { + select.addOptions( + new StringSelectMenuOptionBuilder() + .setLabel(team.name) + .setDescription(team.description || " ") + .setValue(team.id.toString()), + ); + }); + await interaction.reply({ + content: "Select your team:", + components: [row], + }); +} + +export { data, execute }; + \ No newline at end of file diff --git a/src/commands/disconnectOrbitProject.ts b/src/commands/disconnectOrbitProject.ts new file mode 100644 index 0000000..5ae37e3 --- /dev/null +++ b/src/commands/disconnectOrbitProject.ts @@ -0,0 +1,43 @@ +import { + SlashCommandBuilder, + ActionRowBuilder, + StringSelectMenuBuilder, + StringSelectMenuOptionBuilder +} from "discord.js"; + +const data = new SlashCommandBuilder() + .setName("disconnect-orbit-project") + .setDescription("Connects your discord to orbit for notifications"); + +async function execute(interaction) { + const select = new StringSelectMenuBuilder() + .setCustomId('RemoveProject') + .setPlaceholder('Make a selection!'); + + const row = new ActionRowBuilder().addComponents(select); + + const projects = await fetch(`http://localhost:3000/api/discord/channels?channelId=${interaction.channel.id}`).then(res => res.json()) as Array; + + if (projects == null || projects.length == 0) { + await interaction.reply({ + content: "No projects to remove!", + components: [], + }); + return; + } + + projects.forEach(project => { + select.addOptions( + new StringSelectMenuOptionBuilder() + .setLabel(project.project_name) + .setValue(JSON.stringify({rowId: project.id.toString(), title: project.project_name})), + ); + }); + await interaction.reply({ + content: "Remove Project:", + components: [row], + }); +} + +export { data, execute }; + \ No newline at end of file diff --git a/src/routers/orbitRouter.ts b/src/routers/orbitRouter.ts new file mode 100644 index 0000000..078d2be --- /dev/null +++ b/src/routers/orbitRouter.ts @@ -0,0 +1,31 @@ +import express from 'express'; +import { client } from "../util/client.js"; +import { EmbedBuilder, TextChannel } from 'discord.js'; + +const router = express.Router(); + +router.post('/issue-assigned', async (req, res) => { + console.log(req.body); + // TODO: Send message to discord user (https://stackoverflow.com/questions/63160401/how-can-a-discord-bot-create-a-hyperlink-in-a-discord-message-in-an-embed-or-in) + const issueAssignedEmbed = new EmbedBuilder() + .setColor('#3498db') + .setTitle(`📍 Issue ${req.body.issue_id} Assigned: ${req.body.issue_title}`) + .setDescription(`Greetings, ${req.body.discord_username}!\n\nExciting news — you\'ve been handpicked for a special mission, ${req.body.issue_title}, in our cosmic project: ${req.body.project_title}! 🌌\n\nZoom into the action [here](${req.body.issue_url}) and tackle the challenge head-on. Your cosmic expertise is key! ☄️\n\nCheers,\nRocket 🦝\n\n`) + .setTimestamp() + .setFooter({ text: `Issue Id: ${req.body.issue_id}` }); + + await client.users.send(req.body.discord_id, {embeds: [issueAssignedEmbed]}); + res.send('Hello, World!'); +}); + +router.post("/daily-digest", async (req, res) => { + // create embedd + // add info + // to channel + // const guild = await client.guilds.fetch(process.env.GUILD_ID); + // const channels = await guild.channels.fetch(); + // const channel = channels.find(channel => channel.name == "test-aryan"); + // ( channel).send("test"); +}); + +export default router; diff --git a/src/server.ts b/src/server.ts new file mode 100644 index 0000000..97ef9ea --- /dev/null +++ b/src/server.ts @@ -0,0 +1,339 @@ +//@ts-nocheck +import { Collection, Events } from "discord.js"; +import fs from "fs"; +import path from "path"; +import dotenv from "dotenv"; +import { ALL_RESPONSES, createResponse } from "./util/responses.js"; +import { DbHandler, userGithubMap } from "./model/dbHandler.js"; +import { isRepoMember } from "./util/github.js"; +import { client } from "./util/client.js"; +import app from "./app.js"; +import { + ActionRowBuilder, + StringSelectMenuBuilder, + StringSelectMenuOptionBuilder +} from "discord.js"; + +dotenv.config(); +const __dirname = new URL(".", import.meta.url).pathname; +const TOKEN = process.env.DISCORD_TOKEN; +const LP_GITHUB_APP_CLIENT_ID = process.env.LP_GITHUB_APP_CLIENT_ID; +const GUILD_ID = process.env.GUILD_ID; + +client.commands = new Collection(); +const commandsPath = path.join(__dirname, "commands"); +const commandFiles = fs + .readdirSync(commandsPath) + .filter((file) => file.endsWith(".js")); + +for (const file of commandFiles) { + const filePath = path.join(commandsPath, file); + const command = await import(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); + } else { + console.log( + `[WARNING] The command at ${filePath} is missing a required "data" or "execute" property.`, + ); + } +} + +class DiscordServer { + guild; + roles; + + constructor() { + this.client = client; + } + + async init() { + await this.client.login(TOKEN); + console.log("Discord client logged in"); + } + + async getparams() { + const guild = await this.client.guilds.fetch(GUILD_ID); + const roles = await guild.roles.fetch(); + + this.guild = guild; + this.roles = {}; + for (const [id, role] of roles) { + this.roles[role.name.toLowerCase()] = role; + } + } +} + +let server; +const TABLE_NAME = "rocket"; +const dbHandler = new DbHandler(); + +// When the client is ready, run this code (only once) +// We use 'c' for the event parameter to keep it separate from the already defined 'client' +client.once(Events.ClientReady, (c) => { + console.log(`Ready! Logged in as ${c.user.tag}`); +}); + +client.on(Events.InteractionCreate, async (interaction) => { + // console.log("Interaction received"); + if (interaction.isButton()) { + if (interaction.customId.startsWith("verify_button_")) { + // console.log("Verify button pressed"); + + const githubResponse = userGithubMap[interaction.user.id]; + + if (!githubResponse) { + await interaction.update({ + content: createResponse(ALL_RESPONSES.connectionIssue, [ + interaction.user.id, + ]), + components: [], + }); + return; + } + + let device_code = userGithubMap[interaction.user.id].device_code; + let grant_type = "urn:ietf:params:oauth:grant-type:device_code"; + let resp = await fetch( + `https://github.com/login/oauth/access_token?client_id=${LP_GITHUB_APP_CLIENT_ID}&device_code=${device_code}&grant_type=${grant_type}`, + { + method: "POST", + headers: { + "X-GitHub-Api-Version": "2022-11-28", + Accept: "application/json", + }, + }, + ); + + let data = await resp.json(); + + let access_token = data.access_token; + + if (!access_token) { + await interaction.update({ + content: createResponse(ALL_RESPONSES.connectionIssue, [ + interaction.user.id, + ]), + components: [], + }); + return; + } + + let resp2 = await fetch(`https://api.github.com/user`, { + method: "GET", + headers: { + Authorization: `token ${access_token}`, + Accept: "application/json", + }, + }); + let data2 = await resp2.json(); + let st = await isRepoMember(data2.login); + + if (!st) { + await interaction.update({ + content: createResponse(ALL_RESPONSES.checkMeNotMember, []), + components: [], + }); + } + + await dbHandler.setRecord(TABLE_NAME, interaction.user.id, { + PK: { + S: interaction.user.id, + }, + SK: { + S: interaction.user.id, + }, + integrations: { + M: { + github: { + M: { + username: { + S: data2.login, + }, + id: { + N: String(data2.id), + }, + date: { + S: new Date().toISOString(), + }, + }, + }, + }, + }, + }); + + // get the guild + const member = await server.guild.members.fetch(interaction.user.id); + await member.roles.add(server.roles["member"].id); + await interaction.update({ + content: createResponse(ALL_RESPONSES.checkMeSuccess, [ + interaction.user.id, + ]), + components: [], + }); + } + } else if (interaction.isCommand()) { + const command = interaction.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, + }); + } + } + } else if (interaction.isModalSubmit()) { + if (interaction.customId === 'connectOrbitModal') { + const email = interaction.fields.getTextInputValue('emailInput'); + const res = await fetch('http://localhost:3000/api/discord/users', { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ + discord_id: interaction.user.id, + email, + discord_username: interaction.user.username, + }), + }); + if(res.ok) { + await interaction.reply({ + content: + "🚀 Orbit Synchronized! Your communication wave has gracefully surfed through the digital constellations and seamlessly integrated with our orbital nexus. 🚀", + files: ["assets/orbitconnect.gif"], + }); + } else { + const error = (await res.json()).error; + await interaction.reply({ + content: "There was an error while executing this command: " + error, + }); + } + } + } else if (interaction.isAnySelectMenu()) { + if (interaction.customId === "TeamSelect") { + await interaction.deferUpdate(); + + const select = new StringSelectMenuBuilder() + .setCustomId('ProjectSelect') + .setPlaceholder('Make a selection!'); + + const row = new ActionRowBuilder().addComponents(select); + + const projects = await fetch(`http://localhost:3000/api/teams/${interaction.values[0]}/projects`).then(res => res.json()) as Array; + + if (projects == null || projects.length == 0) { + await interaction.channel.send({ + content: "No projects to connect to!", + components: [], + }); + return; + } + + projects.forEach(project => { + select.addOptions( + new StringSelectMenuOptionBuilder() + .setLabel(project.title) + .setDescription(project.description || " ") + .setValue(JSON.stringify({projectId: project.id.toString(), title: project.title})), + ); + }); + + interaction.channel.send({ + content: `Select the project you would like to link with this channel:`, + components: [row], + }); + } else if (interaction.customId === "ProjectSelect") { + const { name, id } = interaction.channel; + const { title, projectId } = JSON.parse(interaction.values[0]); + + const res = await fetch(`http://localhost:3000/api/discord/channels`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ + project_id: projectId, + project_name: title, + channel_id: id, + channel_name: name, + }), + }); + + await interaction.deferUpdate(); + if(res.ok) { + interaction.channel.send({ + content: `🛰️ Your channel has efficiently forged a connection across the cosmic fabric, seamlessly intertwining this conduit with the overarching project: ${title} 🛰️`, + files: ["assets/orbitprojectconnect.gif"], + }); + } else { + const error = (await res.json()).error; + await interaction.reply({ + content: "There was an error while executing this command: " + error, + }); + } + } else if (interaction.customId === "RemoveProject") { + const { rowId, title } = JSON.parse(interaction.values[0]); + + const res = await fetch(`http://localhost:3000/api/discord/channels?id=${rowId}`, { method: 'DELETE' }); + + await interaction.deferUpdate(); + if(res.ok) { + interaction.channel.send({ + content: `☄️ Your directive has skillfully disengaged this channel from the cosmic fabric, smoothly untangling the conduit from the overarching project: ${title} ☄️`, + files: ["assets/orbitprojectdisconnect.gif"], + }); + } else { + const error = (await res.json()).error; + await interaction.reply({ + content: "There was an error while executing this command: " + error, + }); + } + } + } +}); + +client.on(Events.MessageCreate, (message) => { + // console.log("Message received"); + if (!message.content.startsWith("!") || message.author.bot) return; + // console.log(message); +}); + +client.on(Events.GuildMemberAdd, async (member) => { + try { + await client.users.send(member.user.id, { + content: createResponse(ALL_RESPONSES.welcomeMessage, [member.user.id]), + }); + } catch (error) { + console.log("Failed to send DM:", error); + } +}); + +// Log in to Discord with your client's token +server = new DiscordServer(); +await server.init(); +await server.getparams(); + +const PORT = "8080"; +app.listen(PORT, async (error) => { + if(!error) { + console.log("Server is Successfully Running, and App is listening on port "+ PORT) + } else + console.log("Error occurred, server can't start", error); + } +); diff --git a/src/util/client.ts b/src/util/client.ts new file mode 100644 index 0000000..8660906 --- /dev/null +++ b/src/util/client.ts @@ -0,0 +1,10 @@ +import { Client, GatewayIntentBits } from "discord.js"; + +// Create a new client instance +export const client = new Client({ + intents: [ + GatewayIntentBits.Guilds, + GatewayIntentBits.DirectMessages, + GatewayIntentBits.GuildMembers, + ], +}); \ No newline at end of file