-
Notifications
You must be signed in to change notification settings - Fork 2
feat: add /nuke slash command #32
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Merged
+287
−1
Merged
Changes from all commits
Commits
File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,231 @@ | ||
| import { | ||
| ButtonBuilder, | ||
| ButtonStyle, | ||
| ActionRowBuilder, | ||
| MessageFlags, | ||
| ChannelType | ||
| } from "discord.js"; | ||
| import { createCardEmbed } from "../utils/cardEmbed.js"; | ||
| import { hasAdminRole } from "../config/constants.js"; | ||
|
|
||
| const colors = { | ||
| success: 0x4ade80, | ||
| error: 0xff6b81, | ||
| info: 0x7f8cff, | ||
| warning: 0xffb347 | ||
| }; | ||
|
|
||
| const CONFIRM_TIMEOUT_MS = 15_000; | ||
|
|
||
| // Returns human-readable feature names tied to a channel ID, so the confirm | ||
| // warning can tell the admin exactly which bot features will break. | ||
| export function getAffectedBotFeatures(channelId) { | ||
| const features = []; | ||
| if (process.env.WELCOME_CHANNEL_ID === channelId) features.push("welcome messages (WELCOME_CHANNEL_ID)"); | ||
| if (process.env.HONEYPOT_CHANNEL_ID === channelId) features.push("honeypot (HONEYPOT_CHANNEL_ID)"); | ||
| return features; | ||
| } | ||
|
|
||
| export async function executeNuke(interaction) { | ||
| if (!hasAdminRole(interaction.member.roles.cache)) { | ||
| await interaction.reply({ | ||
| embeds: [ | ||
| createCardEmbed({ | ||
| title: "Permission Required", | ||
| description: "Only admins can use this command.", | ||
| color: colors.error | ||
| }) | ||
| ], | ||
| flags: MessageFlags.Ephemeral | ||
| }); | ||
| return; | ||
| } | ||
|
|
||
| const channel = interaction.channel; | ||
| if (!channel || channel.type !== ChannelType.GuildText) { | ||
| await interaction.reply({ | ||
| embeds: [ | ||
| createCardEmbed({ | ||
| title: "Invalid Channel", | ||
| description: "This command can only be used in a text channel.", | ||
| color: colors.error | ||
| }) | ||
| ], | ||
| flags: MessageFlags.Ephemeral | ||
| }); | ||
| return; | ||
| } | ||
|
|
||
| const affectedFeatures = getAffectedBotFeatures(channel.id); | ||
| const isBotChannel = affectedFeatures.length > 0; | ||
|
|
||
| const warningLines = [ | ||
| "This will **clone** this channel (with all permissions and settings) and then **delete the current channel**.", | ||
| "The new channel will have a **different ID** — anything referencing this channel by ID will break.", | ||
| "This action **cannot be undone**." | ||
| ]; | ||
|
|
||
| if (isBotChannel) { | ||
| warningLines.push( | ||
| "", | ||
| `⚠️ **This channel is used by the bot** for: ${affectedFeatures.join(", ")}.`, | ||
| "Nuking it will break those bot features. Update the relevant env var(s) to the new channel ID afterward." | ||
| ); | ||
| } | ||
|
|
||
| const confirmButton = new ButtonBuilder() | ||
| .setCustomId("confirm_nuke") | ||
| .setLabel("Confirm Nuke") | ||
| .setStyle(ButtonStyle.Danger); | ||
|
|
||
| const cancelButton = new ButtonBuilder() | ||
| .setCustomId("cancel_nuke") | ||
| .setLabel("Cancel") | ||
| .setStyle(ButtonStyle.Secondary); | ||
|
|
||
| const row = new ActionRowBuilder().addComponents(confirmButton, cancelButton); | ||
|
|
||
| const reply = await interaction.reply({ | ||
| embeds: [ | ||
| createCardEmbed({ | ||
| title: "Confirm Channel Nuke", | ||
| description: warningLines.join("\n"), | ||
| color: colors.warning | ||
| }) | ||
| ], | ||
| components: [row], | ||
| flags: MessageFlags.Ephemeral | ||
| }); | ||
|
|
||
| const filter = i => | ||
| i.user.id === interaction.user.id && | ||
| (i.customId === "confirm_nuke" || i.customId === "cancel_nuke"); | ||
|
|
||
| try { | ||
| const buttonInteraction = await reply.awaitMessageComponent({ | ||
| filter, | ||
| time: CONFIRM_TIMEOUT_MS | ||
| }); | ||
|
DysektAI marked this conversation as resolved.
|
||
|
|
||
| if (buttonInteraction.customId === "cancel_nuke") { | ||
| await buttonInteraction.update({ | ||
| embeds: [ | ||
| createCardEmbed({ | ||
| title: "Nuke Cancelled", | ||
| description: "Channel nuke was cancelled.", | ||
| color: colors.info | ||
| }) | ||
| ], | ||
| components: [] | ||
| }); | ||
| return; | ||
| } | ||
|
|
||
| await buttonInteraction.deferUpdate(); | ||
|
|
||
| const oldChannel = channel; | ||
| const guild = interaction.guild; | ||
|
|
||
| // Clone the channel with all carry-over properties. | ||
| const cloneOptions = { | ||
| name: oldChannel.name, | ||
| type: oldChannel.type, | ||
| topic: oldChannel.topic, | ||
| nsfw: oldChannel.nsfw, | ||
| rateLimitPerUser: oldChannel.rateLimitPerUser, | ||
| parent: oldChannel.parentId, | ||
| permissionOverwrites: oldChannel.permissionOverwrites.cache, | ||
| reason: `/nuke by ${interaction.user.tag}` | ||
| }; | ||
|
|
||
| if (oldChannel.defaultAutoArchiveDuration) { | ||
| cloneOptions.defaultAutoArchiveDuration = oldChannel.defaultAutoArchiveDuration; | ||
| } | ||
|
|
||
| let clonedChannel; | ||
| try { | ||
| clonedChannel = await guild.channels.create(cloneOptions); | ||
| } catch (err) { | ||
| console.error("Nuke: failed to clone channel:", err); | ||
| await interaction.editReply({ | ||
| embeds: [ | ||
| createCardEmbed({ | ||
| title: "Nuke Failed", | ||
| description: "Failed to clone the channel. The original channel is untouched. Check bot permissions (Manage Channels).", | ||
| color: colors.error | ||
| }) | ||
| ], | ||
| components: [] | ||
| }).catch(() => {}); | ||
| return; | ||
| } | ||
|
|
||
| // Place the clone at the old channel's position so it sits in the same spot. | ||
| try { | ||
| await clonedChannel.setPosition(oldChannel.position, { reason: `/nuke by ${interaction.user.tag}` }); | ||
| } catch (err) { | ||
| // Non-fatal: the clone exists, just not at the exact position. | ||
| console.warn("Nuke: could not restore channel position:", err); | ||
| } | ||
|
|
||
| // Delete the old channel now that the clone is in place. | ||
| let oldChannelDeleted = true; | ||
| try { | ||
| await oldChannel.delete(`/nuke by ${interaction.user.tag}`); | ||
| } catch (err) { | ||
| console.error("Nuke: failed to delete old channel:", err); | ||
| oldChannelDeleted = false; | ||
| } | ||
|
|
||
| // Post a fresh-chat notice in the new channel. | ||
| await clonedChannel.send({ | ||
| embeds: [ | ||
| createCardEmbed({ | ||
| title: "Channel Nuked", | ||
| description: `This channel was nuked by ${interaction.user}.\nAll permissions and settings were carried over from the previous channel.${isBotChannel ? "\n\n⚠️ **This was a bot-related channel** — update the bot's env configuration to use the new channel ID." : ""}`, | ||
| color: colors.success | ||
| }) | ||
| ] | ||
| }).catch(() => {}); | ||
|
|
||
| // The original ephemeral reply lived in the deleted channel, so report the | ||
| // final outcome in the new channel as a visible message. | ||
| await clonedChannel.send({ | ||
| embeds: [ | ||
| createCardEmbed({ | ||
| title: oldChannelDeleted ? "Nuke Complete" : "Nuke Partially Complete", | ||
| description: oldChannelDeleted | ||
| ? `Channel cloned and the old one deleted. New channel ID: \`${clonedChannel.id}\`.` | ||
| : `Channel cloned, but **the old channel could not be deleted** (check bot permissions). Two channels now exist — delete the old one manually. New channel ID: \`${clonedChannel.id}\`.`, | ||
| color: oldChannelDeleted ? colors.success : colors.warning | ||
| }) | ||
| ] | ||
| }).catch(() => {}); | ||
| } catch (err) { | ||
| if (err.code === "InteractionCollectorError") { | ||
| await interaction.editReply({ | ||
| embeds: [ | ||
| createCardEmbed({ | ||
| title: "Nuke Timed Out", | ||
| description: "Confirmation timed out. Please run /nuke again.", | ||
| color: colors.error | ||
| }) | ||
| ], | ||
| components: [] | ||
| }).catch(() => {}); | ||
| return; | ||
| } | ||
|
|
||
| console.error("Error during channel nuke:", err); | ||
| await interaction.editReply({ | ||
| embeds: [ | ||
| createCardEmbed({ | ||
| title: "Nuke Failed", | ||
| description: "Failed to nuke the channel. Check bot permissions.", | ||
| color: colors.error | ||
| }) | ||
| ], | ||
| components: [] | ||
| }).catch(() => {}); | ||
| } | ||
| } | ||
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,47 @@ | ||
| import { test, afterEach } from "node:test"; | ||
| import assert from "node:assert/strict"; | ||
|
|
||
| // nukeCommand reads env vars at call time via the exported helper, so we can | ||
| // set them per-test without cache busting. | ||
|
|
||
| async function importNuke() { | ||
| const url = new URL("../src/commands/nukeCommand.js", import.meta.url).href + "?t=" + Date.now(); | ||
| return import(url); | ||
| } | ||
|
|
||
| const envVarsToClean = new Set(); | ||
|
|
||
| function setEnv(name, value) { | ||
| process.env[name] = value; | ||
| envVarsToClean.add(name); | ||
| } | ||
|
|
||
| afterEach(() => { | ||
| for (const name of envVarsToClean) { | ||
| delete process.env[name]; | ||
| } | ||
| envVarsToClean.clear(); | ||
| }); | ||
|
|
||
| test("getAffectedBotFeatures lists features tied to the channel ID", async () => { | ||
| setEnv("WELCOME_CHANNEL_ID", "111"); | ||
| setEnv("HONEYPOT_CHANNEL_ID", "111"); // same channel used by both | ||
| const { getAffectedBotFeatures } = await importNuke(); | ||
| const features = getAffectedBotFeatures("111"); | ||
| assert.ok(features.some(f => f.includes("welcome messages"))); | ||
| assert.ok(features.some(f => f.includes("honeypot"))); | ||
| }); | ||
|
|
||
| test("getAffectedBotFeatures returns empty for a non-bot channel", async () => { | ||
| setEnv("WELCOME_CHANNEL_ID", "111"); | ||
| setEnv("HONEYPOT_CHANNEL_ID", "222"); | ||
| const { getAffectedBotFeatures } = await importNuke(); | ||
| assert.deepEqual(getAffectedBotFeatures("999"), []); | ||
| }); | ||
|
|
||
| test("getAffectedBotFeatures returns empty when no bot channel env vars are set", async () => { | ||
| delete process.env.WELCOME_CHANNEL_ID; | ||
| delete process.env.HONEYPOT_CHANNEL_ID; | ||
| const { getAffectedBotFeatures } = await importNuke(); | ||
| assert.deepEqual(getAffectedBotFeatures("111"), []); | ||
| }); |
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
Uh oh!
There was an error while loading. Please reload this page.