Enhance component message handling and introduce new features

- Added `ComponentMessageBinding` model to manage message components in the database.
- Updated `WelcomeConfig`, `Tag`, and `ScheduledMessage` models to support new component fields.
- Integrated component handling in various modules, including Scheduler and Tags, to improve message customization.
- Enhanced user experience by implementing new commands for creating and managing component messages in the utility module.
- Updated localization files to reflect new component-related features and improve user guidance.
This commit is contained in:
TheOnlyMace
2026-07-22 22:55:37 +02:00
parent 95eed45ec4
commit 4e135bcf43
46 changed files with 4505 additions and 194 deletions

View File

@@ -242,6 +242,14 @@ export const SETTINGS_SEARCH_FIELDS: SearchIndexEntry[] = [
href: 'welcome',
hash: 'field-welcome-embed'
},
{
id: 'setting-welcome-components',
kind: 'setting',
labelKey: 'modulePages.welcome.welcomeComponents',
href: 'welcome',
hash: 'field-welcome-components',
keywords: ['components v2', 'layout']
},
{
id: 'setting-welcome-dm',
kind: 'setting',
@@ -292,6 +300,13 @@ export const SETTINGS_SEARCH_FIELDS: SearchIndexEntry[] = [
href: 'welcome',
hash: 'field-leave-channel'
},
{
id: 'setting-leave-type',
kind: 'setting',
labelKey: 'modulePages.welcome.leaveType',
href: 'welcome',
hash: 'field-leave-type'
},
{
id: 'setting-leave-content',
kind: 'setting',
@@ -306,6 +321,14 @@ export const SETTINGS_SEARCH_FIELDS: SearchIndexEntry[] = [
href: 'welcome',
hash: 'field-leave-embed'
},
{
id: 'setting-leave-components',
kind: 'setting',
labelKey: 'modulePages.welcome.leaveComponents',
href: 'welcome',
hash: 'field-leave-components',
keywords: ['components v2', 'layout']
},
// Verification
{
id: 'setting-verify-enabled',
@@ -825,6 +848,15 @@ export const SETTINGS_SEARCH_FIELDS: SearchIndexEntry[] = [
href: 'scheduler',
hash: 'field-scheduler-create'
},
// Messages
{
id: 'setting-messages-send',
kind: 'setting',
labelKey: 'modulePages.messages.sendTitle',
href: 'messages',
hash: 'field-messages-send',
keywords: ['send', 'post', 'embed', 'components v2']
},
// Backup
{
id: 'setting-backup',

View File

@@ -0,0 +1,73 @@
import {
DashboardMessageSendResultSchema,
MessageComponentsV2Schema,
type DashboardMessageSend,
type DashboardMessageSendResult
} from '@nexumi/shared';
import { Prisma } from '@prisma/client';
import { prisma } from '../prisma';
import { addJobAndAwait, getMessagesQueue, getMessagesQueueEvents } from '../queues';
export class MessageSendTimeoutError extends Error {
constructor() {
super('The bot did not confirm the message in time. It may still have been sent — check the channel shortly.');
}
}
/**
* Sends a dashboard message via the bot's `messages` BullMQ queue
* (`dashboardMessageSend` job). For Components V2, a binding row is created
* first so interaction custom IDs can reference a stable ref id.
*/
export async function sendDashboardMessageFromWebui(
guildId: string,
createdById: string,
input: DashboardMessageSend
): Promise<DashboardMessageSendResult> {
await prisma.guild.upsert({ where: { id: guildId }, update: {}, create: { id: guildId } });
let bindingId: string | null = null;
if (input.mode === 'COMPONENTS_V2') {
const components = MessageComponentsV2Schema.parse(input.components);
const binding = await prisma.componentMessageBinding.create({
data: {
guildId,
channelId: input.channelId,
payload: components as Prisma.InputJsonValue,
createdById
}
});
bindingId = binding.id;
}
const { confirmed, result } = await addJobAndAwait<{
bindingId: string | null;
messageId: string | null;
channelId: string;
}>(
getMessagesQueue(),
getMessagesQueueEvents(),
'dashboardMessageSend',
{
guildId,
channelId: input.channelId,
createdById,
mode: input.mode,
embed: input.embed ?? null,
components: input.components ?? null,
bindingId
},
{ removeOnComplete: true, removeOnFail: 25 }
);
if (!confirmed || !result) {
throw new MessageSendTimeoutError();
}
return DashboardMessageSendResultSchema.parse({
bindingId: result.bindingId ?? bindingId,
messageId: result.messageId,
channelId: result.channelId,
confirmed: true
});
}

View File

@@ -1,5 +1,7 @@
import {
MessageComponentsV2Schema,
WelcomeEmbedSchema,
type MessageComponentsV2,
type ScheduledMessageDashboard,
type ScheduledMessageDashboardCreate,
type WelcomeEmbed
@@ -19,6 +21,11 @@ function parseEmbed(raw: unknown): WelcomeEmbed | null {
return parsed.success ? parsed.data : null;
}
function parseComponents(raw: unknown): MessageComponentsV2 | null {
const parsed = MessageComponentsV2Schema.safeParse(raw);
return parsed.success ? parsed.data : null;
}
function toDashboard(schedule: ScheduledMessage): ScheduledMessageDashboard {
return {
id: schedule.id,
@@ -26,6 +33,7 @@ function toDashboard(schedule: ScheduledMessage): ScheduledMessageDashboard {
creatorId: schedule.creatorId,
content: schedule.content,
embed: parseEmbed(schedule.embed),
components: parseComponents(schedule.components),
rolePingId: schedule.rolePingId,
cron: schedule.cron,
runAt: schedule.runAt?.toISOString() ?? null,
@@ -84,6 +92,10 @@ export async function createScheduledMessageDashboard(
input.embed === undefined || input.embed === null
? Prisma.JsonNull
: (input.embed as Prisma.InputJsonValue),
components:
input.components === undefined || input.components === null
? Prisma.JsonNull
: (input.components as Prisma.InputJsonValue),
rolePingId: input.rolePingId || null,
cron,
runAt

View File

@@ -1,5 +1,7 @@
import {
MessageComponentsV2Schema,
WelcomeEmbedSchema,
type MessageComponentsV2,
type TagDashboard,
type TagDashboardCreate,
type TagDashboardUpdate,
@@ -26,12 +28,18 @@ function parseEmbed(raw: unknown): WelcomeEmbed | null {
return parsed.success ? parsed.data : null;
}
function parseComponents(raw: unknown): MessageComponentsV2 | null {
const parsed = MessageComponentsV2Schema.safeParse(raw);
return parsed.success ? parsed.data : null;
}
function toDashboard(tag: Tag): TagDashboard {
return {
id: tag.id,
name: tag.name,
content: tag.content,
embed: parseEmbed(tag.embed),
components: parseComponents(tag.components),
responseType: tag.responseType as TagDashboard['responseType'],
triggerWord: tag.triggerWord,
allowedRoleIds: tag.allowedRoleIds,
@@ -69,6 +77,10 @@ export async function createTag(
input.embed === undefined || input.embed === null
? Prisma.JsonNull
: (input.embed as Prisma.InputJsonValue),
components:
input.components === undefined || input.components === null
? Prisma.JsonNull
: (input.components as Prisma.InputJsonValue),
responseType: input.responseType,
triggerWord: input.triggerWord ?? null,
allowedRoleIds: input.allowedRoleIds,
@@ -104,6 +116,12 @@ export async function updateTag(
...(patch.embed !== undefined
? { embed: patch.embed === null ? Prisma.JsonNull : (patch.embed as Prisma.InputJsonValue) }
: {}),
...(patch.components !== undefined
? {
components:
patch.components === null ? Prisma.JsonNull : (patch.components as Prisma.InputJsonValue)
}
: {}),
...(patch.responseType !== undefined ? { responseType: patch.responseType } : {}),
...(patch.triggerWord !== undefined ? { triggerWord: patch.triggerWord } : {}),
...(patch.allowedRoleIds !== undefined ? { allowedRoleIds: patch.allowedRoleIds } : {}),

View File

@@ -1,5 +1,7 @@
import {
MessageComponentsV2Schema,
WelcomeEmbedSchema,
type MessageComponentsV2,
type WelcomeConfigDashboard,
type WelcomeConfigDashboardPatch,
type WelcomeEmbed
@@ -21,6 +23,11 @@ function parseEmbed(raw: unknown): WelcomeEmbed | null {
return parsed.success ? parsed.data : null;
}
function parseComponents(raw: unknown): MessageComponentsV2 | null {
const parsed = MessageComponentsV2Schema.safeParse(raw);
return parsed.success ? parsed.data : null;
}
function toDashboard(config: Awaited<ReturnType<typeof ensureWelcomeConfig>>): WelcomeConfigDashboard {
return {
welcomeEnabled: config.welcomeEnabled,
@@ -30,8 +37,11 @@ function toDashboard(config: Awaited<ReturnType<typeof ensureWelcomeConfig>>): W
welcomeType: config.welcomeType as WelcomeConfigDashboard['welcomeType'],
welcomeContent: config.welcomeContent,
welcomeEmbed: parseEmbed(config.welcomeEmbed),
welcomeComponents: parseComponents(config.welcomeComponents),
leaveType: config.leaveType as WelcomeConfigDashboard['leaveType'],
leaveContent: config.leaveContent,
leaveEmbed: parseEmbed(config.leaveEmbed),
leaveComponents: parseComponents(config.leaveComponents),
welcomeDmEnabled: config.welcomeDmEnabled,
welcomeDmContent: config.welcomeDmContent,
userAutoroleIds: config.userAutoroleIds,
@@ -52,7 +62,15 @@ export async function updateWelcomeDashboard(
): Promise<WelcomeConfigDashboard> {
await ensureWelcomeConfig(guildId);
const { welcomeChannelId, leaveChannelId, welcomeEmbed, leaveEmbed, ...rest } = patch;
const {
welcomeChannelId,
leaveChannelId,
welcomeEmbed,
leaveEmbed,
welcomeComponents,
leaveComponents,
...rest
} = patch;
const data: Prisma.WelcomeConfigUpdateInput = { ...rest };
if (welcomeChannelId !== undefined) {
@@ -67,6 +85,14 @@ export async function updateWelcomeDashboard(
if (leaveEmbed !== undefined) {
data.leaveEmbed = leaveEmbed === null ? Prisma.JsonNull : (leaveEmbed as Prisma.InputJsonValue);
}
if (welcomeComponents !== undefined) {
data.welcomeComponents =
welcomeComponents === null ? Prisma.JsonNull : (welcomeComponents as Prisma.InputJsonValue);
}
if (leaveComponents !== undefined) {
data.leaveComponents =
leaveComponents === null ? Prisma.JsonNull : (leaveComponents as Prisma.InputJsonValue);
}
const updated = await prisma.welcomeConfig.update({ where: { guildId }, data });
return toDashboard(updated);

View File

@@ -12,7 +12,8 @@ const REDIS_ONLY_MODULES = new Set<DashboardModuleId>([
'selfroles',
'feeds',
'scheduler',
'guildbackup'
'guildbackup',
'messages'
]);
function redisModulesKey(guildId: string): string {
@@ -121,7 +122,8 @@ export async function getModuleStatuses(guildId: string): Promise<ModuleStatus[]
stats: statsConfig?.enabled ?? false,
feeds: redisFlags.feeds ?? true,
scheduler: redisFlags.scheduler ?? true,
guildbackup: redisFlags.guildbackup ?? true
guildbackup: redisFlags.guildbackup ?? true,
messages: redisFlags.messages ?? true
};
return DASHBOARD_MODULES.map((module) => ({

View File

@@ -18,15 +18,18 @@ export const giveawayQueueName = 'giveaways';
export const scheduleQueueName = 'schedules';
export const guildBackupQueueName = 'guild-backups';
export const suggestionsQueueName = 'suggestions';
export const messagesQueueName = 'messages';
const globalForQueues = globalThis as unknown as {
__nexumiGiveawayQueue?: Queue;
__nexumiScheduleQueue?: Queue;
__nexumiGuildBackupQueue?: Queue;
__nexumiSuggestionsQueue?: Queue;
__nexumiMessagesQueue?: Queue;
__nexumiGiveawayQueueEvents?: QueueEvents;
__nexumiGuildBackupQueueEvents?: QueueEvents;
__nexumiSuggestionsQueueEvents?: QueueEvents;
__nexumiMessagesQueueEvents?: QueueEvents;
};
function queueConnection() {
@@ -66,6 +69,13 @@ export function getSuggestionsQueue(): Queue {
return globalForQueues.__nexumiSuggestionsQueue;
}
export function getMessagesQueue(): Queue {
globalForQueues.__nexumiMessagesQueue ??= new Queue(messagesQueueName, {
connection: queueConnection()
});
return globalForQueues.__nexumiMessagesQueue;
}
/**
* QueueEvents instances are only needed for jobs where the dashboard needs
* to wait for the bot to finish (e.g. ending a giveaway so the winners list
@@ -92,6 +102,13 @@ export function getSuggestionsQueueEvents(): QueueEvents {
return globalForQueues.__nexumiSuggestionsQueueEvents;
}
export function getMessagesQueueEvents(): QueueEvents {
globalForQueues.__nexumiMessagesQueueEvents ??= new QueueEvents(messagesQueueName, {
connection: queueEventsConnection()
});
return globalForQueues.__nexumiMessagesQueueEvents;
}
const DEFAULT_WAIT_TIMEOUT_MS = 15_000;
/**