diff --git a/bun.lockb b/bun.lockb index a0fb330..276287a 100755 Binary files a/bun.lockb and b/bun.lockb differ diff --git a/package.json b/package.json index d604885..3abecd6 100644 --- a/package.json +++ b/package.json @@ -22,7 +22,7 @@ "dependencies": { "@uwu/configmasher": "latest", "discord.js": "^14.15.3", - "octokit": "^4.0.2" + "ofetch": "^1.4.1" }, "trustedDependencies": [ "@biomejs/biome" diff --git a/src/commands/index.ts b/src/commands/index.ts index caf173f..9077a7b 100644 --- a/src/commands/index.ts +++ b/src/commands/index.ts @@ -1,4 +1,34 @@ -export { default as close } from "./util/close.js"; -export { default as reopen } from "./util/reopen.js"; +import type { + SlashCommandBuilder, + ChatInputCommandInteraction, + ContextMenuCommandBuilder, + ContextMenuCommandInteraction, + SlashCommandOptionsOnlyBuilder, +} from "discord.js"; -export { default as walkthrough } from "./util/walkthrough.js"; +import { default as product_notes } from "./product/notes.js"; + +import { default as close } from "./util/close.js"; +import { default as reopen } from "./util/reopen.js"; +import { default as walkthrough } from "./util/walkthrough.js"; + +type AnyCommandBuilder = + | SlashCommandBuilder + | SlashCommandOptionsOnlyBuilder + | ContextMenuCommandBuilder; +type AnyInteraction = + | ChatInputCommandInteraction + | ContextMenuCommandInteraction; + +const commandObject: { + [key: string]: { + data: AnyCommandBuilder; + execute: (interaction: AnyInteraction) => unknown; + }; +} = {}; + +for (const command of [product_notes, close, reopen, walkthrough]) { + commandObject[command.data.name] = command; +} + +export default commandObject; diff --git a/src/commands/product/notes.ts b/src/commands/product/notes.ts new file mode 100644 index 0000000..fa8aaaf --- /dev/null +++ b/src/commands/product/notes.ts @@ -0,0 +1,65 @@ +import { config } from "@lib/config.js"; +import { makeCodeBlock } from "@lib/discord/messages.js"; + +import { ofetch } from "ofetch"; + +import { + ContextMenuCommandBuilder, + ApplicationCommandType, + type MessageContextMenuCommandInteraction, + MessageFlags, + ActionRowBuilder, + ButtonStyle, + ButtonBuilder, +} from "discord.js"; + +// TODO: try to make the official API package work +async function createNote(body) { + return ofetch("https://api.productboard.com/notes", { + method: "POST", + headers: { + Authorization: `Bearer ${config.productBoard.token}`, + }, + body, + }); +} + +export default { + data: new ContextMenuCommandBuilder() + .setName("Add to product notes") + .setType(ApplicationCommandType.Message), + + execute: async (interaction: MessageContextMenuCommandInteraction) => { + const data = await createNote({ + title: `Discord message from ${interaction.targetMessage.author.displayName} (in '${interaction.channel.name}')`, // this will only work for threads + display_url: interaction.targetMessage.url, + content: interaction.targetMessage.content, + + company: { id: config.productBoard.companyId }, + user: { external_id: `discord:${interaction.targetMessage.author.id}` }, + + source: { origin: "discord", record_id: interaction.targetId }, + }); + + const replyComponents = []; + + if (data.links?.html) { + const button = new ButtonBuilder() + .setLabel("Open in ProductBoard") + .setStyle(ButtonStyle.Link) + .setURL(data.links.html); + + replyComponents.push( + new ActionRowBuilder().addComponents(button), + ); + } + + await interaction.reply({ + content: makeCodeBlock(JSON.stringify(data), "json"), + + components: replyComponents, + + flags: MessageFlags.Ephemeral, + }); + }, +}; diff --git a/src/commands/util/close.ts b/src/commands/util/close.ts index 929b0c2..c29f17f 100644 --- a/src/commands/util/close.ts +++ b/src/commands/util/close.ts @@ -4,7 +4,7 @@ import { canMemberInteractWithThread, getChannelFromInteraction, isHelpPost, -} from "@lib/channels.js"; +} from "@lib/discord/channels.js"; import { type ThreadChannel, diff --git a/src/commands/util/walkthrough.ts b/src/commands/util/walkthrough.ts index 4e0198d..fe16bae 100644 --- a/src/commands/util/walkthrough.ts +++ b/src/commands/util/walkthrough.ts @@ -1,6 +1,6 @@ import { config } from "@lib/config.js"; -import { isHelpPost as isHelpThread } from "@lib/channels.js"; +import { isHelpPost as isHelpThread } from "@lib/discord/channels.js"; import issueCategorySelector from "@components/issueCategorySelector.js"; import { diff --git a/src/deploy-commands.ts b/src/deploy-commands.ts index 4608917..937f7f6 100644 --- a/src/deploy-commands.ts +++ b/src/deploy-commands.ts @@ -1,12 +1,16 @@ import { config } from "@lib/config.js"; -import * as commands from "@commands/index.js"; +import { getClientIDFromToken } from "@lib/discord/users.js"; + +import commands from "@commands/index.js"; import { REST, Routes } from "discord.js"; // Construct and prepare an instance of the REST module const rest = new REST().setToken(config.token); -const commandData = Object.values(commands).map((command) => command.data); +const commandData = Object.values(commands).map((command) => + command.data.toJSON(), +); console.log( `Started refreshing ${commandData.length} application (/) commands.`, @@ -15,7 +19,10 @@ console.log( // The put method is used to fully refresh all commands in the guild with the current set // biome-ignore lint/suspicious/noExplicitAny: TODO: need to figure out the proper type const data: any = await rest.put( - Routes.applicationGuildCommands("1063886601165471814", config.serverId), // TODO: guess client ID from token + Routes.applicationGuildCommands( + getClientIDFromToken(config.token), + config.serverId, + ), { body: commandData }, ); diff --git a/src/events/commands.ts b/src/events/commands.ts index e1e1a22..1a3f9f5 100644 --- a/src/events/commands.ts +++ b/src/events/commands.ts @@ -1,15 +1,18 @@ -import * as commands from "@commands/index.js"; +import commands from "@commands/index.js"; import { type Client, Events } from "discord.js"; export default function registerEvents(client: Client) { return client.on(Events.InteractionCreate, async (interaction) => { - if (interaction.isChatInputCommand()) { + if ( + interaction.isChatInputCommand() || + interaction.isMessageContextMenuCommand() + ) { const command = commands[interaction.commandName]; if (!command) { console.error( - `No command matching ${interaction.commandName} was found.`, + `No command matching "${interaction.commandName}" was found.`, ); return; } @@ -20,6 +23,7 @@ export default function registerEvents(client: Client) { console.error(error); // TODO: make generic replyOrFollowUp method + // TODO: log error if the user is admin if (interaction.replied || interaction.deferred) { await interaction.followUp({ content: "There was an error while executing this command!", diff --git a/src/lib/config.ts b/src/lib/config.ts index fc8be70..1506ee5 100644 --- a/src/lib/config.ts +++ b/src/lib/config.ts @@ -20,6 +20,11 @@ interface Config { vscode: string; }; + productBoard: { + token: string; + companyId: string; + }; + presenceDelay: number; } @@ -50,5 +55,8 @@ export const { config, layers } = await loadConfig({ ["emojis", "macos"], ["emojis", "windows"], ["emojis", "vscode"], + + ["productBoard", "token"], + ["productBoard", "companyId"], ], }); diff --git a/src/lib/channels.ts b/src/lib/discord/channels.ts similarity index 100% rename from src/lib/channels.ts rename to src/lib/discord/channels.ts diff --git a/src/lib/discord/messages.ts b/src/lib/discord/messages.ts new file mode 100644 index 0000000..8531e1f --- /dev/null +++ b/src/lib/discord/messages.ts @@ -0,0 +1,5 @@ +export function makeCodeBlock(text, language = "") { + return `\`\`\`${language} +${text} +\`\`\``; +} diff --git a/src/lib/discord/users.ts b/src/lib/discord/users.ts new file mode 100644 index 0000000..79e0da3 --- /dev/null +++ b/src/lib/discord/users.ts @@ -0,0 +1,3 @@ +export function getClientIDFromToken(token: string): string { + return atob(token.split(".")[0]); +}