Skip to content

Snaps

A Snap is a persistent handler for a Discord component interaction — buttons, select menus, modals. Each file in src/snaps/ registers by customId pattern rather than by per-message collector.

The key difference from collectors: Snaps are registered at startup and survive indefinitely. A user can click a button from last week and the Snap will still handle it. Collectors attached to a specific message expire when the bot restarts.

src/snaps/confirm.ts
import { snap } from "popii-framework";
export default snap({
customId: "confirm",
async do(pop) {
await pop.reply({ content: "Confirmed!", ephemeral: true });
},
});

Any button or component with customId: "confirm" triggers this handler — regardless of which message it’s on or when it was sent.

Use a RegExp for IDs that carry data in their structure:

src/snaps/delete-message.ts
import { snap } from "popii-framework";
export default snap({
customId: /^delete:(\d+)$/,
async do(pop) {
const [, messageId] = pop.snapMatches!;
// fetch and delete the message with messageId
await pop.reply({ content: "Message deleted.", ephemeral: true });
},
});

pop.snapMatches is the result of customId.exec(interaction.customId).

Simpler than regex when you just need a namespace:

import { snap } from "popii-framework";
export default snap({
prefix: "vote:", // matches "vote:yes", "vote:no", "vote:anything"
async do(pop) {
const choice = pop.interaction.customId.slice("vote:".length);
await pop.reply(`You voted: **${choice}**`);
},
});

Embedding structured data with pack/unpack

Section titled “Embedding structured data with pack/unpack”

Discord’s customId field is capped at 100 characters — not enough for JSON. Use pop.pack() to encode a payload into a compact string, and read it back with pop.snapData in the handler:

// In a command — build a button with embedded context
const row = new ActionRowBuilder().addComponents(
new ButtonBuilder()
.setLabel("Ban")
.setStyle(ButtonStyle.Danger)
.setCustomId(pop.pack("mod:ban", { targetId: member.id, reason: "spam" }))
);
await pop.reply({ components: [row], ephemeral: true });
// In the snap — read the context back
export default snap({
prefix: "mod:ban",
permissions: ["BanMembers"],
async do(pop) {
const { targetId, reason } = pop.snapData as { targetId: string; reason: string };
await pop.guild?.members.ban(targetId, { reason });
await pop.reply({ content: "User banned.", ephemeral: true });
},
});
src/snaps/role-picker.ts
import { snap } from "popii-framework";
export default snap({
customId: "role-picker",
async do(pop) {
const values = (pop.interaction as any).values as string[];
await pop.reply(`You selected: ${values.join(", ")}`);
},
});
src/snaps/feedback-modal.ts
import { snap } from "popii-framework";
export default snap({
customId: "feedback-modal",
async do(pop) {
const fields = (pop.interaction as any).fields;
const feedback = fields.getTextInputValue("message");
// store or forward feedback
await pop.reply({ content: "Thanks for the feedback!", ephemeral: true });
},
});

Remove a handler automatically after a time window:

export default snap({
customId: "one-time-confirm",
expiresIn: 300_000, // handler removed after 5 minutes
async do(pop) {
await pop.reply("Action confirmed.");
},
});

Snaps support the same guards as commands:

export default snap({
customId: "admin-panel",
guildOnly: true,
permissions: ["Administrator"],
cooldown: 5_000,
async do(pop) {
// only guild Administrators can trigger this, once every 5 seconds
},
});