Skip to content

Fix crashes when a ticket channel has no database entry - #108

Open
mods-hd wants to merge 1 commit into
ralphkb:mainfrom
mods-hd:fix-missing-ticket-data
Open

Fix crashes when a ticket channel has no database entry#108
mods-hd wants to merge 1 commit into
ralphkb:mainfrom
mods-hd:fix-missing-ticket-data

Conversation

@mods-hd

@mods-hd mods-hd commented Aug 31, 2026

Copy link
Copy Markdown

I ran into this on a bot whose tickets.sqlite had lost rows for channels that still existed in Discord. Pressing the close button in one of those channels threw, and every attempt afterwards made things a little worse.

The reason is the order of operations at the top of closeTicket. It writes closeUserID before it reads anything, so on a channel with no row quick.db happily creates one holding just that field. The very next line reads userID out of that same channel, gets null, and hands it to getUser, which fires a request at /users/null:

DiscordAPIError[50035]: Invalid Form Body
user_id[NUMBER_TYPE_COERCE]: Value "null" is not snowflake.
    at async getUser (utils/mainUtils.js:67:14)
    at async closeTicket (utils/ticketClose.js:27:24)

getUser catches that and returns null, so a few lines later the embed field blows up on ticketUserID.id:

TypeError: Cannot read properties of null (reading 'id')
    at closeTicket (utils/ticketClose.js:62:35)

The stub row it left behind is the annoying part, because ticketsDB.has() now returns true for that channel. /delete checks exactly that before anything else, so it walks straight past its "you are not in a ticket channel" guard and into checkSupportRole, where the missing button field turns into ticketCategories[undefined]:

TypeError: Cannot read properties of undefined (reading 'support_role_ids')
    at checkSupportRole (utils/mainUtils.js:39:50)
    at async Object.execute (commands/Tickets/delete.js:34:28)

And since /delete throws before its deferReply, the catch block in interactionCreate.js tries to editReply a reply that was never sent, so the user sees nothing at all and the log gets a second, misleading error:

DiscordjsError [InteractionNotReplied]: The reply to this interaction has not been sent or deferred.

Three changes here. closeTicket now reads the ticket creator first and, if there is no creator or the fetch fails, tells the staff member the data is missing and returns without writing anything — so the stub row never gets created in the first place. checkSupportRole returns false when the button id is not in the configured categories instead of throwing. And the command error handler picks reply or editReply based on interaction.deferred || interaction.replied, so a command that fails before deferring still gets its error message through.

On how this happens in the first place: mine came from a partial data restore, where main.sqlite survived and tickets.sqlite did not. A ticket deleted from the database while its channel stuck around would do it too. Either way the bot should say so rather than throw, and it should not corrupt the row on the way out.

Two calls I would rather you made than me. The message in the early return is a bare string instead of a config.errors.* lookup like everything else in the codebase — I left it inline to keep the diff to three files, but say the word and I will add the key to config.yml.example and locale.yml.example. And the guard treats a failed getUser the same as missing data, so a transient API hiccup or a deleted account would now block the close rather than throw. That is no worse than the current behaviour, but if you would rather only the missing-row case be caught, that is a small change.

On testing: this is running on my bot and it starts and operates normally, so nothing here regresses the usual paths. I have not been able to exercise the new guard itself, though, because I cleared the bad rows and deleted the orphaned channel before restarting, so there is no longer a channel in that state to click Close on. The reasoning for the early return is that all four callers of closeTicket (commands/Tickets/close.js, plus three in interactionCreate.js) deferReply first, which is what makes the editReply there safe — worth a second pair of eyes.

Closing a channel with no ticket row wrote closeUserID before it read
anything back, so the row got recreated as a stub holding only that field.
getUser then fetched user "null" and the 400 came back as a null user,
which crashed on ticketUserID.id. The stub also made ticketsDB.has()
return true afterwards, so /delete sailed past its "not in a ticket" guard
and died on ticketCategories[undefined].support_role_ids instead.

Now closeTicket reads the creator first and bails with a message if it is
missing, checkSupportRole returns false for an unknown button id, and the
command error handler replies instead of editing when the interaction was
never deferred.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant