Refactor ephemeral message handling in ticket interactions

- Updated ticket command and interaction responses to utilize the new `ephemeral()` function for consistent ephemeral message handling.
- Enhanced error handling for member fetching in ticket interactions, ensuring proper responses when members are not found.
- Improved localization for new error messages related to member presence in ticket commands.
- Documented changes in phase tracking to reflect updates in ticket interaction responses.
This commit is contained in:
TheOnlyMace
2026-07-25 16:32:30 +02:00
parent b5bf214d7f
commit 5f932f5d63
7 changed files with 118 additions and 30 deletions

View File

@@ -15,6 +15,7 @@ import { ensureRecurringJobs, startWorkers } from './jobs.js';
import type { BotContext } from './types.js'; import type { BotContext } from './types.js';
import { startHealthServer } from './health.js'; import { startHealthServer } from './health.js';
import { getGuildLocale } from './i18n.js'; import { getGuildLocale } from './i18n.js';
import { ephemeral } from './interaction-reply.js';
import { t } from '@nexumi/shared'; import { t } from '@nexumi/shared';
import { handleAutoModMessage } from './modules/automod/actions.js'; import { handleAutoModMessage } from './modules/automod/actions.js';
import { registerLoggingEvents } from './modules/logging/events.js'; import { registerLoggingEvents } from './modules/logging/events.js';
@@ -227,9 +228,9 @@ if (!isShard) {
const message = t(locale, 'generic.error'); const message = t(locale, 'generic.error');
if (interaction.isRepliable()) { if (interaction.isRepliable()) {
if (interaction.replied || interaction.deferred) { if (interaction.replied || interaction.deferred) {
await interaction.followUp({ content: message, ephemeral: true }); await interaction.followUp({ content: message, ...ephemeral() });
} else { } else {
await interaction.reply({ content: message, ephemeral: true }); await interaction.reply({ content: message, ...ephemeral() });
} }
} }
} catch (replyError) { } catch (replyError) {

View File

@@ -10,6 +10,7 @@ import type {
import { decodeComponentCustomId, t, type DashboardModuleId } from '@nexumi/shared'; import { decodeComponentCustomId, t, type DashboardModuleId } from '@nexumi/shared';
import { env } from './env.js'; import { env } from './env.js';
import { getGuildLocale } from './i18n.js'; import { getGuildLocale } from './i18n.js';
import { ephemeral } from './interaction-reply.js';
import { isModuleEnabled, moduleForComponent } from './module-gates.js'; import { isModuleEnabled, moduleForComponent } from './module-gates.js';
import { isMaintenanceMode } from './presence.js'; import { isMaintenanceMode } from './presence.js';
import type { BotContext } from './types.js'; import type { BotContext } from './types.js';
@@ -176,7 +177,7 @@ export async function routeComponentInteraction(
if (!interaction.replied && !interaction.deferred) { if (!interaction.replied && !interaction.deferred) {
await interaction.reply({ await interaction.reply({
content: maintenance.message?.trim() || t(locale, 'generic.maintenance'), content: maintenance.message?.trim() || t(locale, 'generic.maintenance'),
ephemeral: true ...ephemeral()
}); });
} }
return true; return true;
@@ -198,7 +199,7 @@ export async function routeComponentInteraction(
if (!interaction.replied && !interaction.deferred) { if (!interaction.replied && !interaction.deferred) {
await interaction.reply({ await interaction.reply({
content: t(locale, 'generic.moduleDisabled'), content: t(locale, 'generic.moduleDisabled'),
ephemeral: true ...ephemeral()
}); });
} }
return true; return true;

View File

@@ -3,6 +3,7 @@ import { t, tf } from '@nexumi/shared';
import type { SlashCommand } from '../../types.js'; import type { SlashCommand } from '../../types.js';
import { getGuildLocale } from '../../i18n.js'; import { getGuildLocale } from '../../i18n.js';
import { requirePermission } from '../../permissions.js'; import { requirePermission } from '../../permissions.js';
import { ephemeral } from '../../interaction-reply.js';
import { ticketCommandData } from './command-definitions.js'; import { ticketCommandData } from './command-definitions.js';
import { import {
addUserToTicket, addUserToTicket,
@@ -26,7 +27,7 @@ async function ensureManageGuild(
locale: 'de' | 'en' locale: 'de' | 'en'
): Promise<boolean> { ): Promise<boolean> {
if (!interaction.inGuild()) { if (!interaction.inGuild()) {
await interaction.reply({ content: t(locale, 'generic.guildOnly'), ephemeral: true }); await interaction.reply({ content: t(locale, 'generic.guildOnly'), ...ephemeral() });
return false; return false;
} }
return requirePermission(interaction, PermissionFlagsBits.ManageGuild, locale); return requirePermission(interaction, PermissionFlagsBits.ManageGuild, locale);
@@ -37,7 +38,7 @@ const ticketCommand: SlashCommand = {
async execute(interaction, context) { async execute(interaction, context) {
const locale = await getGuildLocale(context.prisma, interaction.guildId); const locale = await getGuildLocale(context.prisma, interaction.guildId);
if (!interaction.inGuild()) { if (!interaction.inGuild()) {
await interaction.reply({ content: t(locale, 'generic.guildOnly'), ephemeral: true }); await interaction.reply({ content: t(locale, 'generic.guildOnly'), ...ephemeral() });
return; return;
} }
@@ -54,7 +55,10 @@ const ticketCommand: SlashCommand = {
const categories = await getGuildCategories(context, interaction.guildId!); const categories = await getGuildCategories(context, interaction.guildId!);
if (categories.length === 0) { if (categories.length === 0) {
await interaction.reply({ content: t(locale, 'ticket.error.no_categories'), ephemeral: true }); await interaction.reply({
content: t(locale, 'ticket.error.no_categories'),
...ephemeral()
});
return; return;
} }
@@ -62,7 +66,7 @@ const ticketCommand: SlashCommand = {
if (!channel?.isTextBased() || channel.isDMBased()) { if (!channel?.isTextBased() || channel.isDMBased()) {
await interaction.reply({ await interaction.reply({
content: t(locale, 'ticket.error.invalid_channel'), content: t(locale, 'ticket.error.invalid_channel'),
ephemeral: true ...ephemeral()
}); });
return; return;
} }
@@ -73,7 +77,7 @@ const ticketCommand: SlashCommand = {
title: name, title: name,
description description
}); });
await interaction.reply({ content: t(locale, 'ticket.panel.created'), ephemeral: true }); await interaction.reply({ content: t(locale, 'ticket.panel.created'), ...ephemeral() });
} catch (error) { } catch (error) {
if (error instanceof TicketPanelError) { if (error instanceof TicketPanelError) {
const key = const key =
@@ -82,7 +86,7 @@ const ticketCommand: SlashCommand = {
: error.code === 'not_configured' : error.code === 'not_configured'
? 'ticket.error.invalid_channel' ? 'ticket.error.invalid_channel'
: 'ticket.error.missing_permission'; : 'ticket.error.missing_permission';
await interaction.reply({ content: t(locale, key), ephemeral: true }); await interaction.reply({ content: t(locale, key), ...ephemeral() });
return; return;
} }
throw error; throw error;
@@ -105,18 +109,28 @@ const ticketCommand: SlashCommand = {
); );
await interaction.reply({ await interaction.reply({
content: tf(locale, 'ticket.category.created', { name: category.name }), content: tf(locale, 'ticket.category.created', { name: category.name }),
ephemeral: true ...ephemeral()
}); });
return; return;
} }
const ticket = await findTicketByChannel(context.prisma, interaction.channelId!); const ticket = await findTicketByChannel(context.prisma, interaction.channelId!);
if (!ticket) { if (!ticket) {
await interaction.reply({ content: t(locale, 'ticket.error.not_in_ticket'), ephemeral: true }); await interaction.reply({
content: t(locale, 'ticket.error.not_in_ticket'),
...ephemeral()
});
return; return;
} }
const member = await interaction.guild!.members.fetch(interaction.user.id); const member = await interaction.guild!.members.fetch(interaction.user.id).catch(() => null);
if (!member) {
await interaction.reply({
content: t(locale, 'ticket.error.member_not_found'),
...ephemeral()
});
return;
}
try { try {
if (sub === 'close') { if (sub === 'close') {
@@ -131,40 +145,57 @@ const ticketCommand: SlashCommand = {
const isOpener = ticket.openerId === interaction.user.id; const isOpener = ticket.openerId === interaction.user.id;
if (!isStaff && !isOpener) { if (!isStaff && !isOpener) {
await interaction.reply({ content: t(locale, 'ticket.error.not_staff'), ephemeral: true }); await interaction.reply({
content: t(locale, 'ticket.error.not_staff'),
...ephemeral()
});
return; return;
} }
const reason = interaction.options.getString('reason') ?? undefined; const reason = interaction.options.getString('reason') ?? undefined;
await closeTicket(context, ticket, interaction.user.id, locale, reason); await closeTicket(context, ticket, interaction.user.id, locale, reason);
await interaction.reply({ content: t(locale, 'ticket.close.success'), ephemeral: true }); await interaction.reply({ content: t(locale, 'ticket.close.success'), ...ephemeral() });
return; return;
} }
if (sub === 'claim') { if (sub === 'claim') {
await claimTicket(context, ticket, member, locale); await claimTicket(context, ticket, member, locale);
await interaction.reply({ content: t(locale, 'ticket.claim.success'), ephemeral: true }); await interaction.reply({ content: t(locale, 'ticket.claim.success'), ...ephemeral() });
return; return;
} }
if (sub === 'add') { if (sub === 'add') {
const user = interaction.options.getUser('user', true); const user = interaction.options.getUser('user', true);
const target = await interaction.guild!.members.fetch(user.id); const target = await interaction.guild!.members.fetch(user.id).catch(() => null);
if (!target) {
await interaction.reply({
content: t(locale, 'ticket.error.member_not_found'),
...ephemeral()
});
return;
}
await addUserToTicket(context, ticket, member, target, locale); await addUserToTicket(context, ticket, member, target, locale);
await interaction.reply({ await interaction.reply({
content: tf(locale, 'ticket.add.success', { user: `<@${user.id}>` }), content: tf(locale, 'ticket.add.success', { user: `<@${user.id}>` }),
ephemeral: true ...ephemeral()
}); });
return; return;
} }
if (sub === 'remove') { if (sub === 'remove') {
const user = interaction.options.getUser('user', true); const user = interaction.options.getUser('user', true);
const target = await interaction.guild!.members.fetch(user.id); const target = await interaction.guild!.members.fetch(user.id).catch(() => null);
if (!target) {
await interaction.reply({
content: t(locale, 'ticket.error.member_not_found'),
...ephemeral()
});
return;
}
await removeUserFromTicket(context, ticket, member, target, locale); await removeUserFromTicket(context, ticket, member, target, locale);
await interaction.reply({ await interaction.reply({
content: tf(locale, 'ticket.remove.success', { user: `<@${user.id}>` }), content: tf(locale, 'ticket.remove.success', { user: `<@${user.id}>` }),
ephemeral: true ...ephemeral()
}); });
return; return;
} }
@@ -174,7 +205,7 @@ const ticketCommand: SlashCommand = {
await renameTicket(context, ticket, member, name, locale); await renameTicket(context, ticket, member, name, locale);
await interaction.reply({ await interaction.reply({
content: tf(locale, 'ticket.rename.success', { name }), content: tf(locale, 'ticket.rename.success', { name }),
ephemeral: true ...ephemeral()
}); });
return; return;
} }
@@ -186,14 +217,14 @@ const ticketCommand: SlashCommand = {
content: tf(locale, 'ticket.priority.success', { content: tf(locale, 'ticket.priority.success', {
priority: t(locale, `ticket.priority.${level}`) priority: t(locale, `ticket.priority.${level}`)
}), }),
ephemeral: true ...ephemeral()
}); });
} }
} catch (error) { } catch (error) {
if (error instanceof TicketError) { if (error instanceof TicketError) {
await interaction.reply({ await interaction.reply({
content: t(locale, `ticket.error.${error.code}`), content: t(locale, `ticket.error.${error.code}`),
ephemeral: true ...ephemeral()
}); });
return; return;
} }

View File

@@ -88,7 +88,14 @@ async function handleOpenTicketRequest(
} }
try { try {
const member = await interaction.guild.members.fetch(interaction.user.id); const member = await interaction.guild.members.fetch(interaction.user.id).catch(() => null);
if (!member) {
await interaction.reply({
content: t(locale, 'ticket.error.member_not_found'),
...ephemeral()
});
return;
}
const ticket = await openTicketForCategory( const ticket = await openTicketForCategory(
context, context,
interaction.guild, interaction.guild,
@@ -126,7 +133,14 @@ async function handleTicketControl(
return; return;
} }
const member = await interaction.guild.members.fetch(interaction.user.id); const member = await interaction.guild.members.fetch(interaction.user.id).catch(() => null);
if (!member) {
await interaction.reply({
content: t(locale, 'ticket.error.member_not_found'),
...ephemeral()
});
return;
}
try { try {
if (interaction.isButton() && interaction.customId === TICKET_CTRL_CLOSE) { if (interaction.isButton() && interaction.customId === TICKET_CTRL_CLOSE) {
@@ -177,7 +191,14 @@ async function handleTicketControl(
await interaction.reply({ content: t(locale, 'ticket.error.not_found'), ...ephemeral() }); await interaction.reply({ content: t(locale, 'ticket.error.not_found'), ...ephemeral() });
return; return;
} }
const target = await interaction.guild.members.fetch(userId); const target = await interaction.guild.members.fetch(userId).catch(() => null);
if (!target) {
await interaction.reply({
content: t(locale, 'ticket.error.member_not_found'),
...ephemeral()
});
return;
}
await addUserToTicket(context, ticket, member, target, locale); await addUserToTicket(context, ticket, member, target, locale);
await interaction.reply({ await interaction.reply({
content: tf(locale, 'ticket.add.success', { user: `<@${userId}>` }), content: tf(locale, 'ticket.add.success', { user: `<@${userId}>` }),
@@ -192,7 +213,14 @@ async function handleTicketControl(
await interaction.reply({ content: t(locale, 'ticket.error.not_found'), ...ephemeral() }); await interaction.reply({ content: t(locale, 'ticket.error.not_found'), ...ephemeral() });
return; return;
} }
const target = await interaction.guild.members.fetch(userId); const target = await interaction.guild.members.fetch(userId).catch(() => null);
if (!target) {
await interaction.reply({
content: t(locale, 'ticket.error.member_not_found'),
...ephemeral()
});
return;
}
await removeUserFromTicket(context, ticket, member, target, locale); await removeUserFromTicket(context, ticket, member, target, locale);
await interaction.reply({ await interaction.reply({
content: tf(locale, 'ticket.remove.success', { user: `<@${userId}>` }), content: tf(locale, 'ticket.remove.success', { user: `<@${userId}>` }),
@@ -298,7 +326,14 @@ export async function handleTicketInteraction(
} }
try { try {
const member = await interaction.guild.members.fetch(interaction.user.id); const member = await interaction.guild.members.fetch(interaction.user.id).catch(() => null);
if (!member) {
await interaction.reply({
content: t(locale, 'ticket.error.member_not_found'),
...ephemeral()
});
return;
}
const ticket = await openTicketForCategory( const ticket = await openTicketForCategory(
context, context,
interaction.guild, interaction.guild,

View File

@@ -71,6 +71,15 @@ export class TicketPanelError extends Error {
} }
} }
function isUnknownChannelError(error: unknown): boolean {
return Boolean(
error &&
typeof error === 'object' &&
'code' in error &&
(error as { code: unknown }).code === 10003
);
}
function botCanPostPanel( function botCanPostPanel(
channel: GuildBasedChannel, channel: GuildBasedChannel,
meId: string meId: string
@@ -707,7 +716,11 @@ async function fetchTicketMessages(
timestamp: message.createdAt.toISOString() timestamp: message.createdAt.toISOString()
})); }));
} catch (error) { } catch (error) {
if (isUnknownChannelError(error)) {
logger.debug({ ticketId: ticket.id }, 'Ticket channel already gone while fetching messages');
} else {
logger.warn({ error, ticketId: ticket.id }, 'Failed to fetch ticket messages'); logger.warn({ error, ticketId: ticket.id }, 'Failed to fetch ticket messages');
}
return []; return [];
} }
} }
@@ -1123,9 +1136,13 @@ export async function closeTicket(
await channel.delete(`Ticket ${ticket.id} closed`); await channel.delete(`Ticket ${ticket.id} closed`);
} }
} catch (error) { } catch (error) {
if (isUnknownChannelError(error)) {
logger.debug({ ticketId: ticket.id }, 'Ticket channel already gone while deleting');
} else {
logger.warn({ error, ticketId: ticket.id }, 'Failed to delete ticket channel'); logger.warn({ error, ticketId: ticket.id }, 'Failed to delete ticket channel');
} }
} }
}
} }
export async function rateTicket( export async function rateTicket(

View File

@@ -509,6 +509,7 @@
- Control-Panel im Ticket: Claim, Close, User-Select Add/Remove (wie TempVoice-Pattern) - Control-Panel im Ticket: Claim, Close, User-Select Add/Remove (wie TempVoice-Pattern)
- Opener darf schließen; Claim/Add/Remove nur Support-Staff - Opener darf schließen; Claim/Add/Remove nur Support-Staff
- Prioritäts-Dropdown im Discord-Control-Panel (`ticket:ctrl:priority`) - Prioritäts-Dropdown im Discord-Control-Panel (`ticket:ctrl:priority`)
- Robustheit: Unknown Channel (10003) beim Close nur noch debug; Unknown Member (10007) bei Add/Remove/Interactions abgefangen; Ticket-Commands nutzen `ephemeral()` flags
### Manuell testen ### Manuell testen

View File

@@ -478,6 +478,7 @@ const de: Dictionary = {
'ticket.error.no_parent_channel': 'Kein Elternkanal für Thread-Tickets konfiguriert.', 'ticket.error.no_parent_channel': 'Kein Elternkanal für Thread-Tickets konfiguriert.',
'ticket.error.invalid_parent_channel': 'Der konfigurierte Elternkanal ist ungültig.', 'ticket.error.invalid_parent_channel': 'Der konfigurierte Elternkanal ist ungültig.',
'ticket.error.not_found': 'Ticket nicht gefunden.', 'ticket.error.not_found': 'Ticket nicht gefunden.',
'ticket.error.member_not_found': 'Mitglied nicht auf dem Server gefunden.',
'ticket.error.already_rated': 'Du hast dieses Ticket bereits bewertet.', 'ticket.error.already_rated': 'Du hast dieses Ticket bereits bewertet.',
'birthdays.error.invalidDate': 'Ungültiges Datum. Prüfe Tag, Monat und optionales Jahr.', 'birthdays.error.invalidDate': 'Ungültiges Datum. Prüfe Tag, Monat und optionales Jahr.',
'birthdays.error.invalidTimezone': 'Ungültige Zeitzone. Nutze z. B. Europe/Berlin.', 'birthdays.error.invalidTimezone': 'Ungültige Zeitzone. Nutze z. B. Europe/Berlin.',
@@ -1090,6 +1091,7 @@ const en: Dictionary = {
'ticket.error.no_parent_channel': 'No parent channel configured for thread tickets.', 'ticket.error.no_parent_channel': 'No parent channel configured for thread tickets.',
'ticket.error.invalid_parent_channel': 'The configured parent channel is invalid.', 'ticket.error.invalid_parent_channel': 'The configured parent channel is invalid.',
'ticket.error.not_found': 'Ticket not found.', 'ticket.error.not_found': 'Ticket not found.',
'ticket.error.member_not_found': 'Member is not on this server.',
'ticket.error.already_rated': 'You already rated this ticket.', 'ticket.error.already_rated': 'You already rated this ticket.',
'birthdays.error.invalidDate': 'Invalid date. Check day, month, and optional year.', 'birthdays.error.invalidDate': 'Invalid date. Check day, month, and optional year.',
'birthdays.error.invalidTimezone': 'Invalid timezone. Use e.g. Europe/Berlin.', 'birthdays.error.invalidTimezone': 'Invalid timezone. Use e.g. Europe/Berlin.',