diff --git a/bun.lockb b/bun.lockb index 276287a..0989ef0 100755 Binary files a/bun.lockb and b/bun.lockb differ diff --git a/package.json b/package.json index 3abecd6..cc3ee23 100644 --- a/package.json +++ b/package.json @@ -22,7 +22,8 @@ "dependencies": { "@uwu/configmasher": "latest", "discord.js": "^14.15.3", - "ofetch": "^1.4.1" + "ofetch": "^1.4.1", + "throttle-debounce": "^5.0.2" }, "trustedDependencies": [ "@biomejs/biome" diff --git a/src/commands/product/notes.ts b/src/commands/product/notes.ts index fa8aaaf..b1dfd9a 100644 --- a/src/commands/product/notes.ts +++ b/src/commands/product/notes.ts @@ -39,6 +39,7 @@ export default { user: { external_id: `discord:${interaction.targetMessage.author.id}` }, source: { origin: "discord", record_id: interaction.targetId }, + tags: ["discord"], }); const replyComponents = []; diff --git a/src/commands/util/close.ts b/src/commands/util/close.ts index c29f17f..89a1257 100644 --- a/src/commands/util/close.ts +++ b/src/commands/util/close.ts @@ -17,6 +17,17 @@ import { const getStateWord = (close) => (close ? "closed" : "reopened"); const getStateVerb = (close) => (close ? "close" : "reopen"); +export function getTagsForCloseState(close: boolean) { + return { + tagToAdd: close + ? config.helpChannel.closedTag + : config.helpChannel.openedTag, + tagToRemove: close + ? config.helpChannel.openedTag + : config.helpChannel.closedTag, + }; +} + export async function handleIssueState( interaction: ChatInputCommandInteraction, close = true, @@ -29,12 +40,7 @@ export async function handleIssueState( const stateWord = getStateWord(close); const stateVerb = getStateVerb(close); - const tagToAdd = close - ? config.helpChannel.closedTag - : config.helpChannel.openedTag; - const tagToRemove = close - ? config.helpChannel.openedTag - : config.helpChannel.closedTag; + const { tagToAdd, tagToRemove } = getTagsForCloseState(close); const postTags = threadChannel.appliedTags; diff --git a/src/commands/util/walkthrough.ts b/src/commands/util/walkthrough.ts index fe16bae..eac94bb 100644 --- a/src/commands/util/walkthrough.ts +++ b/src/commands/util/walkthrough.ts @@ -13,9 +13,64 @@ import { Colors, type PublicThreadChannel, type GuildTextBasedChannel, - FetchMessageOptions, + ButtonBuilder, + ButtonStyle, + ContainerBuilder, + MessageFlags, + SectionBuilder, + SeparatorBuilder, + TextDisplayBuilder, + type MessageCreateOptions, + type InteractionReplyOptions, } from "discord.js"; +const resourcesMessage = { + flags: MessageFlags.IsComponentsV2, + + components: [ + new ContainerBuilder().addSectionComponents([ + new SectionBuilder() + .addTextDisplayComponents( + new TextDisplayBuilder({ content: "Where to find logs" }), + ) + .setButtonAccessory( + new ButtonBuilder() + .setStyle(ButtonStyle.Link) + .setLabel("Docs") + .setURL("https://coder.com/docs/admin/monitoring/logs"), + ), + + new SectionBuilder() + .addTextDisplayComponents( + new TextDisplayBuilder({ + content: "Troubleshooting templates", + }), + ) + .setButtonAccessory( + new ButtonBuilder() + .setStyle(ButtonStyle.Link) + .setLabel("Docs") + .setURL("https://coder.com/docs/admin/templates/troubleshooting"), + ), + + new SectionBuilder() + .addTextDisplayComponents( + new TextDisplayBuilder({ + content: "Troubleshooting networking", + }), + ) + .setButtonAccessory( + new ButtonBuilder() + .setStyle(ButtonStyle.Link) + .setLabel("Docs") + .setURL("https://coder.com/docs/admin/networking/troubleshooting"), + ), + ]), + + new SeparatorBuilder(), + ], +}; + export function generateQuestion( question: string, component: StringSelectMenuBuilder, @@ -40,17 +95,15 @@ export async function doWalkthrough( const threadChannel = channel as PublicThreadChannel; // necessary type cast, isHelpThread does the check already // Check for tags in the forum post - if (!threadChannel.appliedTags || threadChannel.appliedTags.length === 0) { - threadChannel.setAppliedTags([config.helpChannel.openedTag]); + const appliedTags = threadChannel.appliedTags ?? []; + if (!appliedTags.includes(config.helpChannel.openedTag)) { + appliedTags.push(config.helpChannel.openedTag); + threadChannel.setAppliedTags(appliedTags); } - // Generate the message with the action row - const message = generateQuestion( - "What are you creating this issue for?", - issueCategorySelector, - ); - + // Send the resources message (or reply to the user if they're running the command) if (interaction) { + // TODO: also check for components V2, but wait until revamp // If the bot has sent a message that contains an embed in the first 30 messages, then we assume it's the walkthrough message const firstMessage = await threadChannel.fetchStarterMessage(); const walkthroughMessage = await threadChannel.messages @@ -71,11 +124,20 @@ export async function doWalkthrough( ephemeral: true, }); } else { - return interaction.reply(message); + // TODO: fix the fact that it looks weird when the resources message is sent as a reply + await interaction.reply(resourcesMessage as InteractionReplyOptions); } } else { - return channel.send(message); + await channel.send(resourcesMessage as MessageCreateOptions); } + + // Generate the walkthrough message asking the user what they're creating this issue for + const message = generateQuestion( + "What are you creating this issue for?", + issueCategorySelector, + ); + + return channel.send(message); } } diff --git a/src/events/channels.ts b/src/events/channels.ts new file mode 100644 index 0000000..f1e2e1b --- /dev/null +++ b/src/events/channels.ts @@ -0,0 +1,80 @@ +import { config } from "../lib/config.js"; +import { getTagsForCloseState } from "../commands/util/close.js"; +import { isHelpPost } from "../lib/discord/channels.js"; + +import { debounce } from "throttle-debounce"; + +import { type Client, Events, type ThreadChannel } from "discord.js"; + +// Map to store initial thread states +const threadUpdateMap = new Map(); + +// Create a debounced handler for processing thread updates +const handleEvent = debounce( + 1000, + async (threadId: string, newThread: ThreadChannel) => { + const initialThread = threadUpdateMap.get(threadId); + if (!initialThread) return; + + // Remove from map + threadUpdateMap.delete(threadId); + + // Handle tag additions + const addedTags = newThread.appliedTags.filter( + (t) => !initialThread.appliedTags.includes(t), + ); + if (addedTags.length > 0) { + for (const tag of addedTags) { + // If closed/opened tag is added, remove the opposite tag + if ( + tag === config.helpChannel.closedTag || + tag === config.helpChannel.openedTag + ) { + const isClose = tag === config.helpChannel.closedTag; + const { tagToRemove } = getTagsForCloseState(isClose); + if (newThread.appliedTags.includes(tagToRemove)) { + await newThread.setAppliedTags( + newThread.appliedTags.filter((t) => t !== tagToRemove), + ); + } + } + } + } + + // Handle tag removals + const removedTags = initialThread.appliedTags.filter( + (t) => !newThread.appliedTags.includes(t), + ); + if (removedTags.length > 0) { + for (const tag of removedTags) { + // If closed or opened tag is removed, add it back only if its opposite isn't present + if ( + tag === config.helpChannel.closedTag || + tag === config.helpChannel.openedTag + ) { + const isClose = tag === config.helpChannel.closedTag; + const { tagToRemove } = getTagsForCloseState(isClose); + if (!newThread.appliedTags.includes(tagToRemove)) { + await newThread.setAppliedTags([...newThread.appliedTags, tag]); + } + } + } + } + }, +); + +export default function registerEvents(client: Client) { + client.on(Events.ThreadUpdate, async (oldThread, newThread) => { + if (!(await isHelpPost(newThread))) { + return; + } + + // Store the initial state if this is the first update + if (!threadUpdateMap.has(newThread.id)) { + threadUpdateMap.set(newThread.id, oldThread); + } + + // Trigger the debounced handler + handleEvent(newThread.id, newThread); + }); +} diff --git a/src/index.ts b/src/index.ts index 8197d75..140f792 100644 --- a/src/index.ts +++ b/src/index.ts @@ -3,6 +3,7 @@ import { config } from "./lib/config.js"; import registerCommandEvents from "./events/commands.js"; import registerWalkthroughEvents from "./events/walkthrough.js"; import registerMessageEvents from "./events/messages.js"; +import registerChannelEvents from "./events/channels.js"; import { Client, Events, GatewayIntentBits, ActivityType } from "discord.js"; @@ -37,6 +38,7 @@ client.once(Events.ClientReady, () => { registerCommandEvents(client); registerWalkthroughEvents(client); registerMessageEvents(client); + registerChannelEvents(client); shufflePresence(); setInterval(shufflePresence, config.presenceDelay);