Skip to content

Commit f42a3c1

Browse files
committed
feat: add "Add to Product Notes" context menu item
1 parent 7574746 commit f42a3c1

File tree

10 files changed

+3083
-21
lines changed

10 files changed

+3083
-21
lines changed

bun.lockb

1.36 KB
Binary file not shown.

package-lock.json

Lines changed: 2965 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

package.json

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -21,7 +21,8 @@
2121
},
2222
"dependencies": {
2323
"@uwu/configmasher": "latest",
24-
"discord.js": "^14.15.3"
24+
"discord.js": "^14.15.3",
25+
"ofetch": "^1.4.1"
2526
},
2627
"trustedDependencies": [
2728
"@biomejs/biome"

src/commands/index.ts

Lines changed: 24 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -1,23 +1,34 @@
1-
import type {
2-
SlashCommandBuilder,
3-
ChatInputCommandInteraction,
4-
5-
ContextMenuCommandBuilder,
6-
ContextMenuCommandInteraction,
7-
SlashCommandOptionsOnlyBuilder,
1+
import type {
2+
SlashCommandBuilder,
3+
ChatInputCommandInteraction,
4+
ContextMenuCommandBuilder,
5+
ContextMenuCommandInteraction,
6+
SlashCommandOptionsOnlyBuilder,
87
} from "discord.js";
98

9+
import { default as product_notes } from "./product/notes.js";
10+
1011
import { default as close } from "./util/close.js";
1112
import { default as reopen } from "./util/reopen.js";
1213
import { default as walkthrough } from "./util/walkthrough.js";
1314

14-
type AnyCommandBuilder = SlashCommandBuilder | SlashCommandOptionsOnlyBuilder | ContextMenuCommandBuilder;
15-
type AnyInteraction = ChatInputCommandInteraction | ContextMenuCommandInteraction;
15+
type AnyCommandBuilder =
16+
| SlashCommandBuilder
17+
| SlashCommandOptionsOnlyBuilder
18+
| ContextMenuCommandBuilder;
19+
type AnyInteraction =
20+
| ChatInputCommandInteraction
21+
| ContextMenuCommandInteraction;
1622

17-
const commandObject: { [key: string]: { data: AnyCommandBuilder, execute: (interaction: AnyInteraction) => unknown } } = {};
23+
const commandObject: {
24+
[key: string]: {
25+
data: AnyCommandBuilder;
26+
execute: (interaction: AnyInteraction) => unknown;
27+
};
28+
} = {};
1829

19-
for (const command of [close, reopen, walkthrough]) {
20-
commandObject[command.data.name] = command;
30+
for (const command of [product_notes, close, reopen, walkthrough]) {
31+
commandObject[command.data.name] = command;
2132
}
2233

23-
export default commandObject;
34+
export default commandObject;

src/commands/product/notes.ts

Lines changed: 65 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,65 @@
1+
import { config } from "@lib/config.js";
2+
import { makeCodeBlock } from "@lib/discord/messages.js";
3+
4+
import { ofetch } from "ofetch";
5+
6+
import {
7+
ContextMenuCommandBuilder,
8+
ApplicationCommandType,
9+
type MessageContextMenuCommandInteraction,
10+
MessageFlags,
11+
ActionRowBuilder,
12+
ButtonStyle,
13+
ButtonBuilder,
14+
} from "discord.js";
15+
16+
// TODO: try to make the official API package work
17+
async function createNote(body) {
18+
return ofetch("https://api.productboard.com/notes", {
19+
method: "POST",
20+
headers: {
21+
Authorization: `Bearer ${config.productBoard.token}`,
22+
},
23+
body,
24+
});
25+
}
26+
27+
export default {
28+
data: new ContextMenuCommandBuilder()
29+
.setName("Add to product notes")
30+
.setType(ApplicationCommandType.Message),
31+
32+
execute: async (interaction: MessageContextMenuCommandInteraction) => {
33+
const data = await createNote({
34+
title: `Discord message from ${interaction.targetMessage.author.displayName} (in '${interaction.channel.name}')`, // this will only work for threads
35+
display_url: interaction.targetMessage.url,
36+
content: interaction.targetMessage.content,
37+
38+
company: { id: config.productBoard.companyId },
39+
user: { external_id: `discord:${interaction.targetMessage.author.id}` },
40+
41+
source: { origin: "discord", record_id: interaction.targetId },
42+
});
43+
44+
const replyComponents = [];
45+
46+
if (data.links?.html) {
47+
const button = new ButtonBuilder()
48+
.setLabel("Open in ProductBoard")
49+
.setStyle(ButtonStyle.Link)
50+
.setURL(data.links.html);
51+
52+
replyComponents.push(
53+
new ActionRowBuilder<ButtonBuilder>().addComponents(button),
54+
);
55+
}
56+
57+
await interaction.reply({
58+
content: makeCodeBlock(JSON.stringify(data), "json"),
59+
60+
components: replyComponents,
61+
62+
flags: MessageFlags.Ephemeral,
63+
});
64+
},
65+
};

src/deploy-commands.ts

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,9 @@ import { REST, Routes } from "discord.js";
88
// Construct and prepare an instance of the REST module
99
const rest = new REST().setToken(config.token);
1010

11-
const commandData = Object.values(commands).map((command) => command.data.toJSON());
11+
const commandData = Object.values(commands).map((command) =>
12+
command.data.toJSON(),
13+
);
1214

1315
console.log(
1416
`Started refreshing ${commandData.length} application (/) commands.`,
@@ -17,7 +19,10 @@ console.log(
1719
// The put method is used to fully refresh all commands in the guild with the current set
1820
// biome-ignore lint/suspicious/noExplicitAny: TODO: need to figure out the proper type
1921
const data: any = await rest.put(
20-
Routes.applicationGuildCommands(getClientIDFromToken(config.token), config.serverId), // TODO: guess client ID from token
22+
Routes.applicationGuildCommands(
23+
getClientIDFromToken(config.token),
24+
config.serverId,
25+
),
2126
{ body: commandData },
2227
);
2328

src/events/commands.ts

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,10 @@ import { type Client, Events } from "discord.js";
44

55
export default function registerEvents(client: Client) {
66
return client.on(Events.InteractionCreate, async (interaction) => {
7-
if (interaction.isChatInputCommand()) {
7+
if (
8+
interaction.isChatInputCommand() ||
9+
interaction.isMessageContextMenuCommand()
10+
) {
811
const command = commands[interaction.commandName];
912

1013
if (!command) {
@@ -20,6 +23,7 @@ export default function registerEvents(client: Client) {
2023
console.error(error);
2124

2225
// TODO: make generic replyOrFollowUp method
26+
// TODO: log error if the user is admin
2327
if (interaction.replied || interaction.deferred) {
2428
await interaction.followUp({
2529
content: "There was an error while executing this command!",
@@ -32,8 +36,6 @@ export default function registerEvents(client: Client) {
3236
});
3337
}
3438
}
35-
} else if(interaction.isUserContextMenuCommand()) {
36-
3739
}
3840
});
3941
}

src/lib/config.ts

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,11 @@ interface Config {
2020
vscode: string;
2121
};
2222

23+
productBoard: {
24+
token: string;
25+
companyId: string;
26+
};
27+
2328
presenceDelay: number;
2429
}
2530

@@ -50,5 +55,8 @@ export const { config, layers } = await loadConfig<Config>({
5055
["emojis", "macos"],
5156
["emojis", "windows"],
5257
["emojis", "vscode"],
58+
59+
["productBoard", "token"],
60+
["productBoard", "companyId"],
5361
],
5462
});

src/lib/discord/messages.ts

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
export function makeCodeBlock(text, language = "") {
2+
return `\`\`\`${language}
3+
${text}
4+
\`\`\``;
5+
}

src/lib/discord/users.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,3 @@
11
export function getClientIDFromToken(token: string): string {
2-
return atob(token.split(".")[0]);
3-
}
2+
return atob(token.split(".")[0]);
3+
}

0 commit comments

Comments
 (0)