Modal interaction already acknowledged?

I have an issue where I'm consistently getting the error Interaction has already been acknowledged and I think it's because I'm not handling my modal interactions properly. For example.
const modal = new BeginQuestModal(`Quest: ${questName}`, registrationConfig.fields.map(field => field.field_title));
await interaction.showModal(modal);
const modalInteraction = await interaction.awaitModalSubmit({ time: 60_000, filter: modal.filter });
const modal = new BeginQuestModal(`Quest: ${questName}`, registrationConfig.fields.map(field => field.field_title));
await interaction.showModal(modal);
const modalInteraction = await interaction.awaitModalSubmit({ time: 60_000, filter: modal.filter });
Suppose I click a button that leads to this, then I cancel the modal, then click it again. The second time I click it, the interaction has already been acknowledged error pops up. Now, everytime that button is clicked, the modal is created with a new unique custom ID using randomUUID() however the code after the awaitModalSubmit is never executed, meaning I think I'm failing to clean up or something. Anyone have some advice on this issue?
>>> Modal Custom ID: modal:beginQuest:f238fe7d-ad41-4ecd-a60f-4b84dcb29b8a // No problems Here, I cancel this one.
>>> Modal Custom ID: modal:beginQuest:0d74ed48-1336-4ce2-ad56-a38007963f09 // Problem happens here, on second interaction.
>>> Modal Custom ID: modal:beginQuest:f238fe7d-ad41-4ecd-a60f-4b84dcb29b8a // No problems Here, I cancel this one.
>>> Modal Custom ID: modal:beginQuest:0d74ed48-1336-4ce2-ad56-a38007963f09 // Problem happens here, on second interaction.
You can have a look at the code in it's entirety here. https://sourceb.in/T7dAcwtYhq
29 Replies
d.js toolkit
d.js toolkit14h ago
- What's your exact discord.js npm list discord.js and node node -v version? - Not a discord.js issue? Check out #other-js-ts. - Consider reading #how-to-get-help to improve your question! - Explain what exactly your issue is. - Post the full error stack trace, not just the top part! - Show your code! - Issue solved? Press the button!
Amgelo
Amgelo14h ago
you're deferring in the filter, that's a reply
No description
Amgelo
Amgelo14h ago
and you're deferring before you check the customId, so it'll effectively defer every modal btw there's a 100 char limit for customIds so you might want to use something more compact than a full uuid if you want to store even more data, could look into nanoid
xTwisteDx
xTwisteDxOP14h ago
So It does the same thing without the i.deferUpdate() that was me toying around with potential solutions.
Amgelo
Amgelo14h ago
ah, wait, the button's the one acknowledged, not the modal? what actually happens in discord? any reply or error?
xTwisteDx
xTwisteDxOP13h ago
Honestly, it's the weirdest thing. Everything works as intended xD One sec I'll find out if it's the button or modal. I know it's type = 2, whatever that is from debugger.
Amgelo
Amgelo13h ago
so it errors in your console, but it replies as if it's working?
xTwisteDx
xTwisteDxOP13h ago
Correct
Amgelo
Amgelo13h ago
sounds like you could have a lingering process which would be the one not erroring and replying correctly and your current one can't reply anymore
xTwisteDx
xTwisteDxOP13h ago
Am I supposed to do any form of cleanup if a modal interaction is cancelled? Also, the modal is triggered from a button interaction (Which has a static and consistent customID)
Amgelo
Amgelo13h ago
no, you can't detect that you should only cleanup any lingering collectors, which is why you provide a time or a max/maxCollected could you look into this just in case?
xTwisteDx
xTwisteDxOP13h ago
What do you mean a lingering process? I just went through and put breakpoints everwhere there is a InteractionCreate event listener to ensure I'm not double-firing somehow.
treble/luna
treble/luna13h ago
You need to pass in a nonce in your customid. If you cancel the collector will keep running and it will reply to the new interaction Pass in the interaction id and check that ignore me i didnt read fullt
Amgelo
Amgelo13h ago
can you show how you're replying to the button? or is that handleJoinQuestInteraction?
xTwisteDx
xTwisteDxOP13h ago
It can't reply in the collector, each time the modal is created, it receives a new randomUUID as part of it's customId, it's never the same, the filter is also directly tied to the modal itself... which makes me wonder, should I be using a new filter each time or the same filter?
Amgelo
Amgelo13h ago
I think wolvinny missed the uuid part
xTwisteDx
xTwisteDxOP13h ago
const execute = async (interaction: ButtonInteraction) => {
const { customId } = interaction;
if (!interaction.isButton() || !customId) return;

const parsedButton = parseButtonId(customId);
if (!parsedButton) {
const errorEmbed = new ErrorEmbed('Invalid Button', 'The button ID format is invalid');
await interaction.reply({ embeds: [errorEmbed], flags: MessageFlags.Ephemeral });
return;
}

const persistedComponentData = ComponentData.fromCustomId(interaction.customId);

switch (parsedButton.type) {
case ComponentAction.BEGIN_QUEST:
await handleBeginQuest(interaction, persistedComponentData);
break;
case ComponentAction.DELETE_QUEST:
await handleDeleteQuestInteraction(interaction, persistedComponentData);
break;
case ComponentAction.CONFIRM_DELETE_QUEST:
await handleConfirmDeleteInteraction(interaction, persistedComponentData);
break;
case ComponentAction.CANCEL_DELETE_QUEST:
await handleCancelDeleteInteraction(interaction);
break;
case ComponentAction.APPROVE_QUEST:
await handleApproveQuestInteraction(interaction, persistedComponentData);
break;
case ComponentAction.DELAY_QUEST:
await handleDelayQuestInteraction(interaction, persistedComponentData);
break;
case ComponentAction.GET_USER_REPORT_HISTORY:
await handleGetUserReportHistoryInteraction(interaction, persistedComponentData);
break;
case ComponentAction.REPORT_USER:
await handleReportUserInteraction(interaction, persistedComponentData);
break;
default:
const errorEmbed = new ErrorEmbed('Unknown Button Type', 'This button type is not handled');
await interaction.reply({ embeds: [errorEmbed] });
break;
}
};
const execute = async (interaction: ButtonInteraction) => {
const { customId } = interaction;
if (!interaction.isButton() || !customId) return;

const parsedButton = parseButtonId(customId);
if (!parsedButton) {
const errorEmbed = new ErrorEmbed('Invalid Button', 'The button ID format is invalid');
await interaction.reply({ embeds: [errorEmbed], flags: MessageFlags.Ephemeral });
return;
}

const persistedComponentData = ComponentData.fromCustomId(interaction.customId);

switch (parsedButton.type) {
case ComponentAction.BEGIN_QUEST:
await handleBeginQuest(interaction, persistedComponentData);
break;
case ComponentAction.DELETE_QUEST:
await handleDeleteQuestInteraction(interaction, persistedComponentData);
break;
case ComponentAction.CONFIRM_DELETE_QUEST:
await handleConfirmDeleteInteraction(interaction, persistedComponentData);
break;
case ComponentAction.CANCEL_DELETE_QUEST:
await handleCancelDeleteInteraction(interaction);
break;
case ComponentAction.APPROVE_QUEST:
await handleApproveQuestInteraction(interaction, persistedComponentData);
break;
case ComponentAction.DELAY_QUEST:
await handleDelayQuestInteraction(interaction, persistedComponentData);
break;
case ComponentAction.GET_USER_REPORT_HISTORY:
await handleGetUserReportHistoryInteraction(interaction, persistedComponentData);
break;
case ComponentAction.REPORT_USER:
await handleReportUserInteraction(interaction, persistedComponentData);
break;
default:
const errorEmbed = new ErrorEmbed('Unknown Button Type', 'This button type is not handled');
await interaction.reply({ embeds: [errorEmbed] });
break;
}
};
I verified, there are no other trace event listeners that can handle this, only here.
Amgelo
Amgelo13h ago
and that execute is called in a collector, or a direct interactionCreate listener?
xTwisteDx
xTwisteDxOP13h ago
In this case, it's the BEGIN_QUEST case. No, it's an event listener.
export default {
name: event,
execute,
} satisfies Event<Events.InteractionCreate>;

export type Event<T extends keyof ClientEvents = keyof ClientEvents> = {
execute(...parameters: ClientEvents[T]): Promise<void> | void;
name: T;
once?: boolean;
};

export const schema = z.object({
name: z.string(),
once: z.boolean().optional().default(false),
execute: z.function(),
});

export const predicate: StructurePredicate<Event> = (structure: unknown): structure is Event =>
schema.safeParse(structure).success;
export default {
name: event,
execute,
} satisfies Event<Events.InteractionCreate>;

export type Event<T extends keyof ClientEvents = keyof ClientEvents> = {
execute(...parameters: ClientEvents[T]): Promise<void> | void;
name: T;
once?: boolean;
};

export const schema = z.object({
name: z.string(),
once: z.boolean().optional().default(false),
execute: z.function(),
});

export const predicate: StructurePredicate<Event> = (structure: unknown): structure is Event =>
schema.safeParse(structure).success;
Amgelo
Amgelo13h ago
it really sounds like you have a lingering bot process, another bot running that shouldn't be running anymore so you're running two bots with the same code and the same token
xTwisteDx
xTwisteDxOP13h ago
Lemme check that RQ, 99% sure I'm not though.
Amgelo
Amgelo13h ago
hmmm yeah I think not following how the events should happen I was thinking something like - button clicked - bot A starts collector - bot B starts collector - button clicked again - bot A replies - bot B errors since A already replied but both would try to showModal, one should error and its collector wouldn't be created at all
xTwisteDx
xTwisteDxOP13h ago
Okay so now I'm wholly confused.. I have another bot running somewhere and it's not my computer xD Unless my web dev got frisky and wanted to dabble
Amgelo
Amgelo13h ago
okay maybe it's running with outdated code and that's why it's not erroring but if you're 100% sure there's another one running then you can change your token it'll "logout" all connections
xTwisteDx
xTwisteDxOP13h ago
Yeh, let me see to make sure portaininer isn't running it. A loose terminal couldn't get it stuck could it?
Amgelo
Amgelo13h ago
I haven't used portainer so no idea
xTwisteDx
xTwisteDxOP13h ago
Portainer is just a fancy docker managagement utility for self-hosting stuff Resetting the client secret did the trick.. so weird.
Amgelo
Amgelo13h ago
yeah that'll logout all connections, including your lingering one
xTwisteDx
xTwisteDxOP13h ago
I'd have never guessed that. Thanks! I learned something new.

Did you find this page helpful?