From 4e135bcf431a13a03538e2939369a5fd82d071e4 Mon Sep 17 00:00:00 2001 From: TheOnlyMace <0815cracky@gmail.com> Date: Wed, 22 Jul 2026 22:55:37 +0200 Subject: [PATCH] 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. --- .../migration.sql | 36 + .../migration.sql | 36 + apps/bot/prisma/schema.prisma | 20 + apps/bot/src/index.ts | 17 + apps/bot/src/jobs.ts | 17 + apps/bot/src/lib/components-v2-payload.ts | 426 ++++++ apps/bot/src/modules/components-v2/index.ts | 4 + .../src/modules/components-v2/interactions.ts | 335 +++++ apps/bot/src/modules/components-v2/send.ts | 120 ++ apps/bot/src/modules/scheduler/service.ts | 28 +- apps/bot/src/modules/tags/commands.ts | 4 +- apps/bot/src/modules/tags/service.ts | 37 +- .../modules/utility/command-definitions.ts | 40 +- apps/bot/src/modules/utility/commands.ts | 118 +- apps/bot/src/modules/welcome/commands.ts | 28 +- apps/bot/src/modules/welcome/events.ts | 5 +- apps/bot/src/modules/welcome/renderer.ts | 58 +- apps/bot/src/modules/welcome/service.ts | 22 +- apps/bot/src/queues.ts | 2 + .../api/guilds/[guildId]/messages/route.ts | 41 + .../dashboard/[guildId]/messages/loading.tsx | 13 + .../app/dashboard/[guildId]/messages/page.tsx | 21 + apps/webui/src/components/layout/sidebar.tsx | 4 +- .../components/modules/messages-manager.tsx | 147 ++ .../components/modules/scheduler-manager.tsx | 105 +- .../src/components/modules/tags-manager.tsx | 150 +- .../src/components/modules/welcome-form.tsx | 212 ++- .../ui/discord-components-v2-builder.tsx | 1300 +++++++++++++++++ apps/webui/src/lib/dashboard-search-index.ts | 32 + apps/webui/src/lib/module-configs/messages.ts | 73 + .../webui/src/lib/module-configs/scheduler.ts | 12 + apps/webui/src/lib/module-configs/tags.ts | 18 + apps/webui/src/lib/module-configs/welcome.ts | 28 +- apps/webui/src/lib/modules.ts | 6 +- apps/webui/src/lib/queues.ts | 17 + apps/webui/src/messages/de.json | 121 +- apps/webui/src/messages/en.json | 121 +- docs/PHASE-TRACKING.md | 25 + packages/shared/src/command-locales.ts | 28 +- packages/shared/src/components-v2.test.ts | 147 ++ packages/shared/src/components-v2.ts | 562 +++++++ packages/shared/src/dashboard.ts | 115 +- packages/shared/src/index.ts | 37 +- packages/shared/src/phase2.ts | 5 +- packages/shared/src/phase4.ts | 2 +- packages/shared/src/phase6.ts | 4 +- 46 files changed, 4505 insertions(+), 194 deletions(-) create mode 100644 apps/bot/apps/bot/prisma/migrations/20260722220000_components_v2/migration.sql create mode 100644 apps/bot/prisma/migrations/20260722220000_components_v2/migration.sql create mode 100644 apps/bot/src/lib/components-v2-payload.ts create mode 100644 apps/bot/src/modules/components-v2/index.ts create mode 100644 apps/bot/src/modules/components-v2/interactions.ts create mode 100644 apps/bot/src/modules/components-v2/send.ts create mode 100644 apps/webui/src/app/api/guilds/[guildId]/messages/route.ts create mode 100644 apps/webui/src/app/dashboard/[guildId]/messages/loading.tsx create mode 100644 apps/webui/src/app/dashboard/[guildId]/messages/page.tsx create mode 100644 apps/webui/src/components/modules/messages-manager.tsx create mode 100644 apps/webui/src/components/ui/discord-components-v2-builder.tsx create mode 100644 apps/webui/src/lib/module-configs/messages.ts create mode 100644 packages/shared/src/components-v2.test.ts create mode 100644 packages/shared/src/components-v2.ts diff --git a/apps/bot/apps/bot/prisma/migrations/20260722220000_components_v2/migration.sql b/apps/bot/apps/bot/prisma/migrations/20260722220000_components_v2/migration.sql new file mode 100644 index 0000000..e0fedb0 --- /dev/null +++ b/apps/bot/apps/bot/prisma/migrations/20260722220000_components_v2/migration.sql @@ -0,0 +1,36 @@ +-- AlterTable +ALTER TABLE "WelcomeConfig" ADD COLUMN IF NOT EXISTS "welcomeComponents" JSONB; +ALTER TABLE "WelcomeConfig" ADD COLUMN IF NOT EXISTS "leaveType" TEXT NOT NULL DEFAULT 'TEXT'; +ALTER TABLE "WelcomeConfig" ADD COLUMN IF NOT EXISTS "leaveComponents" JSONB; + +-- AlterTable +ALTER TABLE "Tag" ADD COLUMN IF NOT EXISTS "components" JSONB; + +-- AlterTable +ALTER TABLE "ScheduledMessage" ADD COLUMN IF NOT EXISTS "components" JSONB; + +-- CreateTable +CREATE TABLE IF NOT EXISTS "ComponentMessageBinding" ( + "id" TEXT NOT NULL, + "guildId" TEXT NOT NULL, + "channelId" TEXT NOT NULL, + "messageId" TEXT, + "payload" JSONB NOT NULL, + "createdById" TEXT NOT NULL, + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + + CONSTRAINT "ComponentMessageBinding_pkey" PRIMARY KEY ("id") +); + +-- CreateIndex +CREATE INDEX IF NOT EXISTS "ComponentMessageBinding_guildId_createdAt_idx" ON "ComponentMessageBinding"("guildId", "createdAt"); + +-- CreateIndex +CREATE INDEX IF NOT EXISTS "ComponentMessageBinding_messageId_idx" ON "ComponentMessageBinding"("messageId"); + +-- AddForeignKey +DO $$ BEGIN + ALTER TABLE "ComponentMessageBinding" ADD CONSTRAINT "ComponentMessageBinding_guildId_fkey" FOREIGN KEY ("guildId") REFERENCES "Guild"("id") ON DELETE CASCADE ON UPDATE CASCADE; +EXCEPTION + WHEN duplicate_object THEN null; +END $$; diff --git a/apps/bot/prisma/migrations/20260722220000_components_v2/migration.sql b/apps/bot/prisma/migrations/20260722220000_components_v2/migration.sql new file mode 100644 index 0000000..e0fedb0 --- /dev/null +++ b/apps/bot/prisma/migrations/20260722220000_components_v2/migration.sql @@ -0,0 +1,36 @@ +-- AlterTable +ALTER TABLE "WelcomeConfig" ADD COLUMN IF NOT EXISTS "welcomeComponents" JSONB; +ALTER TABLE "WelcomeConfig" ADD COLUMN IF NOT EXISTS "leaveType" TEXT NOT NULL DEFAULT 'TEXT'; +ALTER TABLE "WelcomeConfig" ADD COLUMN IF NOT EXISTS "leaveComponents" JSONB; + +-- AlterTable +ALTER TABLE "Tag" ADD COLUMN IF NOT EXISTS "components" JSONB; + +-- AlterTable +ALTER TABLE "ScheduledMessage" ADD COLUMN IF NOT EXISTS "components" JSONB; + +-- CreateTable +CREATE TABLE IF NOT EXISTS "ComponentMessageBinding" ( + "id" TEXT NOT NULL, + "guildId" TEXT NOT NULL, + "channelId" TEXT NOT NULL, + "messageId" TEXT, + "payload" JSONB NOT NULL, + "createdById" TEXT NOT NULL, + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + + CONSTRAINT "ComponentMessageBinding_pkey" PRIMARY KEY ("id") +); + +-- CreateIndex +CREATE INDEX IF NOT EXISTS "ComponentMessageBinding_guildId_createdAt_idx" ON "ComponentMessageBinding"("guildId", "createdAt"); + +-- CreateIndex +CREATE INDEX IF NOT EXISTS "ComponentMessageBinding_messageId_idx" ON "ComponentMessageBinding"("messageId"); + +-- AddForeignKey +DO $$ BEGIN + ALTER TABLE "ComponentMessageBinding" ADD CONSTRAINT "ComponentMessageBinding_guildId_fkey" FOREIGN KEY ("guildId") REFERENCES "Guild"("id") ON DELETE CASCADE ON UPDATE CASCADE; +EXCEPTION + WHEN duplicate_object THEN null; +END $$; diff --git a/apps/bot/prisma/schema.prisma b/apps/bot/prisma/schema.prisma index 8e34207..be28a14 100644 --- a/apps/bot/prisma/schema.prisma +++ b/apps/bot/prisma/schema.prisma @@ -51,6 +51,7 @@ model Guild { socialFeeds SocialFeed[] scheduledMessages ScheduledMessage[] guildBackups GuildBackup[] + componentMessageBindings ComponentMessageBinding[] dashboardAccessRules DashboardAccessRule[] dashboardAuditLogs DashboardAuditLog[] commandOverrides CommandOverride[] @@ -245,8 +246,11 @@ model WelcomeConfig { welcomeType String @default("TEXT") welcomeContent String? welcomeEmbed Json? + welcomeComponents Json? + leaveType String @default("TEXT") leaveContent String? leaveEmbed Json? + leaveComponents Json? welcomeDmEnabled Boolean @default(false) welcomeDmContent String? userAutoroleIds String[] @default([]) @@ -555,6 +559,7 @@ model Tag { name String content String? embed Json? + components Json? responseType String @default("TEXT") triggerWord String? allowedRoleIds String[] @default([]) @@ -774,6 +779,7 @@ model ScheduledMessage { creatorId String content String? embed Json? + components Json? rolePingId String? cron String? runAt DateTime? @@ -786,6 +792,20 @@ model ScheduledMessage { @@index([guildId, enabled]) } +model ComponentMessageBinding { + id String @id @default(cuid()) + guildId String + channelId String + messageId String? + payload Json + createdById String + createdAt DateTime @default(now()) + guild Guild @relation(fields: [guildId], references: [id], onDelete: Cascade) + + @@index([guildId, createdAt]) + @@index([messageId]) +} + model GuildBackup { id String @id @default(cuid()) guildId String diff --git a/apps/bot/src/index.ts b/apps/bot/src/index.ts index 05dda92..466d35b 100644 --- a/apps/bot/src/index.ts +++ b/apps/bot/src/index.ts @@ -64,6 +64,10 @@ import { } from './modules/tempvoice/index.js'; import { registerStatsEvents } from './modules/stats/index.js'; import { handleGuildBackupButton, isGuildBackupButton } from './modules/guildbackup/index.js'; +import { + handleComponentsV2Interaction, + isComponentsV2Interaction +} from './modules/components-v2/index.js'; const isShard = process.argv.includes('--shard'); @@ -220,6 +224,19 @@ if (!isShard) { await handleEmbedModal(interaction, context); return; } + + if ( + (interaction.isButton() || + interaction.isStringSelectMenu() || + interaction.isUserSelectMenu() || + interaction.isRoleSelectMenu() || + interaction.isChannelSelectMenu() || + interaction.isMentionableSelectMenu()) && + isComponentsV2Interaction(interaction.customId) + ) { + await handleComponentsV2Interaction(interaction, context); + return; + } } catch (error) { logger.error({ error }, 'Interaction handling failed'); const locale = await getGuildLocale(context.prisma, interaction.guildId); diff --git a/apps/bot/src/jobs.ts b/apps/bot/src/jobs.ts index 8740b2b..2642871 100644 --- a/apps/bot/src/jobs.ts +++ b/apps/bot/src/jobs.ts @@ -18,6 +18,7 @@ import { expireBirthdayRole, runBirthdayCheck } from './modules/birthdays/index. import { ensureStatsRecurringJobs, startStatsWorker } from './modules/stats/index.js'; import { pollAllFeeds } from './modules/feeds/index.js'; import { sendScheduledMessage } from './modules/scheduler/index.js'; +import { sendDashboardMessage, type DashboardMessageSendJob } from './modules/components-v2/send.js'; import { runGuildBackupRestoreJob } from './modules/guildbackup/index.js'; import { runSuggestionStatusUpdateJob } from './modules/suggestions/index.js'; import { runPresenceRefreshJob } from './presence.js'; @@ -32,6 +33,7 @@ import { feedsQueueName, giveawayQueueName, guildBackupQueueName, + messagesQueueName, moderationQueueName, presenceQueue, presenceQueueName, @@ -281,6 +283,20 @@ export function startWorkers(context: BotContext): Worker[] { logger.error({ jobId: job?.id, error }, 'Schedule job failed'); }); + const messagesWorker = new Worker( + messagesQueueName, + async (job) => { + if (job.name !== 'dashboardMessageSend') { + return; + } + return sendDashboardMessage(context, job.data); + }, + { connection: redis } + ); + messagesWorker.on('failed', (job, error) => { + logger.error({ jobId: job?.id, error }, 'Dashboard message send job failed'); + }); + const guildBackupWorker = new Worker<{ backupId: string; guildId: string }>( guildBackupQueueName, async (job) => { @@ -349,6 +365,7 @@ export function startWorkers(context: BotContext): Worker[] { statsWorker, feedsWorker, scheduleWorker, + messagesWorker, guildBackupWorker, suggestionsWorker, presenceWorker, diff --git a/apps/bot/src/lib/components-v2-payload.ts b/apps/bot/src/lib/components-v2-payload.ts new file mode 100644 index 0000000..fee1f2f --- /dev/null +++ b/apps/bot/src/lib/components-v2-payload.ts @@ -0,0 +1,426 @@ +import { + ActionRowBuilder, + ButtonBuilder, + ButtonStyle, + ChannelSelectMenuBuilder, + ContainerBuilder, + MediaGalleryBuilder, + MediaGalleryItemBuilder, + MentionableSelectMenuBuilder, + MessageFlags, + RoleSelectMenuBuilder, + SectionBuilder, + SeparatorBuilder, + SeparatorSpacingSize, + StringSelectMenuBuilder, + TextDisplayBuilder, + ThumbnailBuilder, + UserSelectMenuBuilder, + type MessageActionRowComponentBuilder, + type MessageCreateOptions +} from 'discord.js'; +import { + encodeComponentCustomId, + mapComponentsV2TextFields, + MessageComponentsV2Schema, + type ComponentButton, + type ComponentButtonStyle, + type ComponentSelect, + type ComponentV2Node, + type ComponentV2Source, + type MessageComponentsV2, + type SeparatorSpacing +} from '@nexumi/shared'; + +function isHttpUrl(value: string | undefined): value is string { + if (!value) { + return false; + } + try { + const url = new URL(value); + return url.protocol === 'http:' || url.protocol === 'https:'; + } catch { + return false; + } +} + +const BUTTON_STYLE_MAP: Record = { + Primary: ButtonStyle.Primary, + Secondary: ButtonStyle.Secondary, + Success: ButtonStyle.Success, + Danger: ButtonStyle.Danger, + Link: ButtonStyle.Link +}; + +const SPACING_MAP: Record = { + Small: SeparatorSpacingSize.Small, + Large: SeparatorSpacingSize.Large +}; + +export interface BuildComponentsV2Options { + source: ComponentV2Source; + ref: string; + renderText?: (value: string) => string; +} + +function buildButton( + button: ComponentButton, + options: BuildComponentsV2Options +): ButtonBuilder | null { + const builder = new ButtonBuilder() + .setLabel(button.label.slice(0, 80)) + .setDisabled(Boolean(button.disabled)); + + if (button.emoji) { + builder.setEmoji(button.emoji); + } + + if (button.style === 'Link') { + if (!isHttpUrl(button.url)) { + return null; + } + builder.setStyle(ButtonStyle.Link).setURL(button.url); + return builder; + } + + if (!button.actionId) { + return null; + } + + builder + .setStyle(BUTTON_STYLE_MAP[button.style] ?? ButtonStyle.Secondary) + .setCustomId(encodeComponentCustomId(options.source, options.ref, button.actionId)); + return builder; +} + +function buildSelect( + select: ComponentSelect, + options: BuildComponentsV2Options +): MessageActionRowComponentBuilder | null { + const placeholder = select.placeholder?.slice(0, 150); + const minValues = select.minValues; + const maxValues = select.maxValues; + + if (select.type === 'string_select') { + const menu = new StringSelectMenuBuilder() + .setCustomId( + encodeComponentCustomId( + options.source, + options.ref, + select.actionId ?? select.id ?? 'select' + ) + ) + .setDisabled(Boolean(select.disabled)) + .addOptions( + select.options.slice(0, 25).map((option) => { + const entry: { + label: string; + value: string; + description?: string; + emoji?: string; + } = { + label: option.label.slice(0, 100), + value: option.value.slice(0, 100) + }; + if (option.description) { + entry.description = option.description.slice(0, 100); + } + if (option.emoji) { + entry.emoji = option.emoji; + } + return entry; + }) + ); + if (placeholder) { + menu.setPlaceholder(placeholder); + } + if (minValues !== undefined) { + menu.setMinValues(minValues); + } + if (maxValues !== undefined) { + menu.setMaxValues(maxValues); + } + return menu; + } + + const customId = encodeComponentCustomId(options.source, options.ref, select.actionId); + let menu: + | UserSelectMenuBuilder + | RoleSelectMenuBuilder + | ChannelSelectMenuBuilder + | MentionableSelectMenuBuilder; + + switch (select.type) { + case 'user_select': + menu = new UserSelectMenuBuilder().setCustomId(customId); + break; + case 'role_select': + menu = new RoleSelectMenuBuilder().setCustomId(customId); + break; + case 'channel_select': + menu = new ChannelSelectMenuBuilder().setCustomId(customId); + break; + case 'mentionable_select': + menu = new MentionableSelectMenuBuilder().setCustomId(customId); + break; + default: + return null; + } + + menu.setDisabled(Boolean(select.disabled)); + if (placeholder) { + menu.setPlaceholder(placeholder); + } + if (minValues !== undefined) { + menu.setMinValues(minValues); + } + if (maxValues !== undefined) { + menu.setMaxValues(maxValues); + } + return menu; +} + +function buildThumbnail(url: string, description?: string, spoiler?: boolean): ThumbnailBuilder | null { + if (!isHttpUrl(url)) { + return null; + } + const thumb = new ThumbnailBuilder().setURL(url); + if (description) { + thumb.setDescription(description.slice(0, 1024)); + } + if (spoiler) { + thumb.setSpoiler(true); + } + return thumb; +} + +type TopLevelBuilder = + | ContainerBuilder + | TextDisplayBuilder + | SeparatorBuilder + | MediaGalleryBuilder + | SectionBuilder + | ActionRowBuilder; + +function buildNode(node: ComponentV2Node, options: BuildComponentsV2Options): TopLevelBuilder | null { + switch (node.type) { + case 'text_display': + return new TextDisplayBuilder().setContent(node.content.slice(0, 4000)); + case 'separator': { + const sep = new SeparatorBuilder(); + if (node.divider !== undefined) { + sep.setDivider(node.divider); + } + if (node.spacing) { + sep.setSpacing(SPACING_MAP[node.spacing]); + } + return sep; + } + case 'media_gallery': { + const gallery = new MediaGalleryBuilder(); + for (const item of node.items) { + if (!isHttpUrl(item.url)) { + continue; + } + const media = new MediaGalleryItemBuilder().setURL(item.url); + if (item.description) { + media.setDescription(item.description.slice(0, 1024)); + } + if (item.spoiler) { + media.setSpoiler(true); + } + gallery.addItems(media); + } + return gallery; + } + case 'section': { + const section = new SectionBuilder(); + const texts = Array.isArray(node.text) ? node.text : [node.text]; + for (const text of texts.slice(0, 3)) { + section.addTextDisplayComponents(new TextDisplayBuilder().setContent(text.slice(0, 4000))); + } + if (node.accessory.type === 'thumbnail') { + const thumb = buildThumbnail( + node.accessory.url, + node.accessory.description, + node.accessory.spoiler + ); + if (thumb) { + section.setThumbnailAccessory(thumb); + } + } else { + const button = buildButton(node.accessory, options); + if (button) { + section.setButtonAccessory(button); + } + } + return section; + } + case 'action_row': { + const row = new ActionRowBuilder(); + for (const child of node.components) { + if (child.type === 'button') { + const button = buildButton(child, options); + if (button) { + row.addComponents(button); + } + } else { + const select = buildSelect(child, options); + if (select) { + row.addComponents(select); + } + } + } + return row.components.length > 0 ? row : null; + } + case 'button': { + const button = buildButton(node, options); + if (!button) { + return null; + } + return new ActionRowBuilder().addComponents(button); + } + case 'string_select': + case 'user_select': + case 'role_select': + case 'channel_select': + case 'mentionable_select': { + const select = buildSelect(node, options); + if (!select) { + return null; + } + return new ActionRowBuilder().addComponents(select); + } + case 'thumbnail': + // Thumbnails must be section accessories; skip top-level. + return null; + case 'container': { + const container = new ContainerBuilder(); + if (node.accentColor !== undefined) { + container.setAccentColor(node.accentColor); + } + if (node.spoiler) { + container.setSpoiler(true); + } + for (const child of node.components) { + switch (child.type) { + case 'text_display': + container.addTextDisplayComponents( + new TextDisplayBuilder().setContent(child.content.slice(0, 4000)) + ); + break; + case 'separator': { + const sep = new SeparatorBuilder(); + if (child.divider !== undefined) { + sep.setDivider(child.divider); + } + if (child.spacing) { + sep.setSpacing(SPACING_MAP[child.spacing]); + } + container.addSeparatorComponents(sep); + break; + } + case 'media_gallery': { + const gallery = new MediaGalleryBuilder(); + for (const item of child.items) { + if (!isHttpUrl(item.url)) { + continue; + } + const media = new MediaGalleryItemBuilder().setURL(item.url); + if (item.description) { + media.setDescription(item.description.slice(0, 1024)); + } + if (item.spoiler) { + media.setSpoiler(true); + } + gallery.addItems(media); + } + container.addMediaGalleryComponents(gallery); + break; + } + case 'section': { + const section = buildNode(child, options); + if (section instanceof SectionBuilder) { + container.addSectionComponents(section); + } + break; + } + case 'action_row': + case 'button': + case 'string_select': + case 'user_select': + case 'role_select': + case 'channel_select': + case 'mentionable_select': { + const row = buildNode(child, options); + if (row instanceof ActionRowBuilder) { + container.addActionRowComponents(row); + } + break; + } + case 'container': + for (const nested of child.components) { + const sibling = buildNode(nested, options); + if (sibling instanceof TextDisplayBuilder) { + container.addTextDisplayComponents(sibling); + } else if (sibling instanceof SeparatorBuilder) { + container.addSeparatorComponents(sibling); + } else if (sibling instanceof MediaGalleryBuilder) { + container.addMediaGalleryComponents(sibling); + } else if (sibling instanceof SectionBuilder) { + container.addSectionComponents(sibling); + } else if (sibling instanceof ActionRowBuilder) { + container.addActionRowComponents(sibling); + } + } + break; + default: + break; + } + } + return container; + } + default: + return null; + } +} + +/** + * Builds a discord.js Components V2 message payload from shared JSON. + * Sets MessageFlags.IsComponentsV2; never includes content/embeds. + */ +export function buildComponentsV2Payload( + raw: MessageComponentsV2 | null | undefined, + options: BuildComponentsV2Options +): MessageCreateOptions | null { + const parsed = MessageComponentsV2Schema.safeParse(raw); + if (!parsed.success) { + return null; + } + + const data = options.renderText + ? mapComponentsV2TextFields(parsed.data, options.renderText) + : parsed.data; + + const components: TopLevelBuilder[] = []; + for (const node of data.components) { + const built = buildNode(node, options); + if (built) { + components.push(built); + } + } + + if (components.length === 0) { + return null; + } + + return { + components, + flags: MessageFlags.IsComponentsV2 + }; +} + +export function parseStoredComponentsV2(raw: unknown): MessageComponentsV2 | null { + const parsed = MessageComponentsV2Schema.safeParse(raw); + return parsed.success ? parsed.data : null; +} diff --git a/apps/bot/src/modules/components-v2/index.ts b/apps/bot/src/modules/components-v2/index.ts new file mode 100644 index 0000000..8b8b2b7 --- /dev/null +++ b/apps/bot/src/modules/components-v2/index.ts @@ -0,0 +1,4 @@ +export { + handleComponentsV2Interaction, + isComponentsV2Interaction +} from './interactions.js'; diff --git a/apps/bot/src/modules/components-v2/interactions.ts b/apps/bot/src/modules/components-v2/interactions.ts new file mode 100644 index 0000000..6df750d --- /dev/null +++ b/apps/bot/src/modules/components-v2/interactions.ts @@ -0,0 +1,335 @@ +import { + PermissionFlagsBits, + type ButtonInteraction, + type GuildMember, + type Interaction, + type MessageComponentInteraction, + type RoleSelectMenuInteraction, + type StringSelectMenuInteraction +} from 'discord.js'; +import { + decodeComponentCustomId, + mapComponentsV2TextFields, + parseMessageComponentsV2, + t, + type ComponentAction, + type ComponentV2Source, + type Locale, + type MessageComponentsV2 +} from '@nexumi/shared'; +import type { BotContext } from '../../types.js'; +import { getGuildLocale } from '../../i18n.js'; +import { logger } from '../../logger.js'; + +export function isComponentsV2Interaction(customId: string): boolean { + return decodeComponentCustomId(customId) !== null; +} + +async function loadPayload( + context: BotContext, + source: ComponentV2Source, + ref: string, + guildId: string +): Promise { + switch (source) { + case 'w': { + const config = await context.prisma.welcomeConfig.findUnique({ where: { guildId: ref } }); + if (!config || config.guildId !== guildId) { + return null; + } + return parseMessageComponentsV2(config.welcomeComponents); + } + case 'l': { + const config = await context.prisma.welcomeConfig.findUnique({ where: { guildId: ref } }); + if (!config || config.guildId !== guildId) { + return null; + } + return parseMessageComponentsV2(config.leaveComponents); + } + case 't': { + const tag = await context.prisma.tag.findUnique({ where: { id: ref } }); + if (!tag || tag.guildId !== guildId) { + return null; + } + return parseMessageComponentsV2(tag.components); + } + case 's': { + const schedule = await context.prisma.scheduledMessage.findUnique({ where: { id: ref } }); + if (!schedule || schedule.guildId !== guildId) { + return null; + } + return parseMessageComponentsV2(schedule.components); + } + case 'm': { + const binding = await context.prisma.componentMessageBinding.findUnique({ where: { id: ref } }); + if (!binding || binding.guildId !== guildId) { + return null; + } + return parseMessageComponentsV2(binding.payload); + } + default: + return null; + } +} + +function resolveStringSelectAction( + payload: MessageComponentsV2, + actionId: string, + selectedValues: string[] +): ComponentAction | null { + const direct = payload.actions[actionId]; + if (direct && selectedValues.length === 0) { + return direct; + } + + // Prefer per-option actionId when present. + const findOptionAction = (nodes: MessageComponentsV2['components']): ComponentAction | null => { + for (const node of nodes) { + if (node.type === 'string_select') { + for (const option of node.options) { + if (selectedValues.includes(option.value) && option.actionId) { + return payload.actions[option.actionId] ?? null; + } + } + } + if (node.type === 'action_row') { + const nested = findOptionAction(node.components as MessageComponentsV2['components']); + if (nested) { + return nested; + } + } + if (node.type === 'container') { + const nested = findOptionAction(node.components); + if (nested) { + return nested; + } + } + } + return null; + }; + + return findOptionAction(payload.components) ?? payload.actions[actionId] ?? null; +} + +async function assertRoleAssignable(member: GuildMember, roleId: string, locale: Locale): Promise { + const me = member.guild.members.me; + if (!me?.permissions.has(PermissionFlagsBits.ManageRoles)) { + return t(locale, 'componentsV2.error.botMissingManageRoles'); + } + const role = member.guild.roles.cache.get(roleId) ?? (await member.guild.roles.fetch(roleId).catch(() => null)); + if (!role) { + return t(locale, 'componentsV2.error.roleNotFound'); + } + if (role.managed) { + return t(locale, 'componentsV2.error.roleManaged'); + } + if (role.position >= me.roles.highest.position) { + return t(locale, 'componentsV2.error.roleHierarchy'); + } + return null; +} + +async function executeAction( + interaction: MessageComponentInteraction, + action: ComponentAction, + locale: Locale, + selectedRoleIds: string[] +): Promise { + const member = interaction.member; + if (!member || !interaction.guild || typeof member === 'string') { + await interaction.reply({ content: t(locale, 'generic.guildOnly'), ephemeral: true }); + return; + } + + const guildMember = + 'roles' in member && typeof (member as GuildMember).roles?.add === 'function' + ? (member as GuildMember) + : await interaction.guild.members.fetch(interaction.user.id); + + switch (action.type) { + case 'NONE': + await interaction.deferUpdate(); + return; + case 'EPHEMERAL_REPLY': + await interaction.reply({ content: action.content ?? '—', ephemeral: true }); + return; + case 'PUBLIC_REPLY': + await interaction.reply({ content: action.content ?? '—' }); + return; + case 'ASSIGN_ROLE': { + const err = await assertRoleAssignable(guildMember, action.roleId!, locale); + if (err) { + await interaction.reply({ content: err, ephemeral: true }); + return; + } + await guildMember.roles.add(action.roleId!); + logger.info( + { guildId: interaction.guildId, userId: interaction.user.id, roleId: action.roleId, action: 'ASSIGN_ROLE' }, + 'Components V2 role action' + ); + await interaction.reply({ + content: t(locale, 'componentsV2.role.assigned'), + ephemeral: true + }); + return; + } + case 'REMOVE_ROLE': { + const err = await assertRoleAssignable(guildMember, action.roleId!, locale); + if (err) { + await interaction.reply({ content: err, ephemeral: true }); + return; + } + await guildMember.roles.remove(action.roleId!); + logger.info( + { guildId: interaction.guildId, userId: interaction.user.id, roleId: action.roleId, action: 'REMOVE_ROLE' }, + 'Components V2 role action' + ); + await interaction.reply({ + content: t(locale, 'componentsV2.role.removed'), + ephemeral: true + }); + return; + } + case 'TOGGLE_ROLE': { + const err = await assertRoleAssignable(guildMember, action.roleId!, locale); + if (err) { + await interaction.reply({ content: err, ephemeral: true }); + return; + } + const hasRole = guildMember.roles.cache.has(action.roleId!); + if (hasRole) { + await guildMember.roles.remove(action.roleId!); + await interaction.reply({ + content: t(locale, 'componentsV2.role.removed'), + ephemeral: true + }); + } else { + await guildMember.roles.add(action.roleId!); + await interaction.reply({ + content: t(locale, 'componentsV2.role.assigned'), + ephemeral: true + }); + } + logger.info( + { + guildId: interaction.guildId, + userId: interaction.user.id, + roleId: action.roleId, + action: 'TOGGLE_ROLE', + removed: hasRole + }, + 'Components V2 role action' + ); + return; + } + case 'ASSIGN_SELECTED_ROLES': { + if (selectedRoleIds.length === 0) { + await interaction.reply({ + content: t(locale, 'componentsV2.error.noRolesSelected'), + ephemeral: true + }); + return; + } + const assigned: string[] = []; + for (const roleId of selectedRoleIds) { + const err = await assertRoleAssignable(guildMember, roleId, locale); + if (err) { + continue; + } + await guildMember.roles.add(roleId); + assigned.push(roleId); + } + logger.info( + { guildId: interaction.guildId, userId: interaction.user.id, roleIds: assigned, action: 'ASSIGN_SELECTED_ROLES' }, + 'Components V2 role action' + ); + if (assigned.length === 0) { + await interaction.reply({ + content: t(locale, 'componentsV2.error.roleNotFound'), + ephemeral: true + }); + return; + } + await interaction.reply({ + content: t(locale, 'componentsV2.role.assignedSelected'), + ephemeral: true + }); + return; + } + default: + await interaction.reply({ content: t(locale, 'generic.error'), ephemeral: true }); + } +} + +export async function handleComponentsV2Interaction( + interaction: Interaction, + context: BotContext +): Promise { + if ( + !interaction.isButton() && + !interaction.isStringSelectMenu() && + !interaction.isUserSelectMenu() && + !interaction.isRoleSelectMenu() && + !interaction.isChannelSelectMenu() && + !interaction.isMentionableSelectMenu() + ) { + return; + } + + const locale = await getGuildLocale(context.prisma, interaction.guildId); + if (!interaction.inGuild() || !interaction.guildId) { + await interaction.reply({ content: t(locale, 'generic.guildOnly'), ephemeral: true }); + return; + } + + const decoded = decodeComponentCustomId(interaction.customId); + if (!decoded) { + return; + } + + const payload = await loadPayload(context, decoded.source, decoded.ref, interaction.guildId); + if (!payload) { + await interaction.reply({ + content: t(locale, 'componentsV2.error.notFound'), + ephemeral: true + }); + return; + } + + let action: ComponentAction | null = null; + let selectedRoleIds: string[] = []; + + if (interaction.isStringSelectMenu()) { + action = resolveStringSelectAction(payload, decoded.actionId, interaction.values); + } else if (interaction.isRoleSelectMenu()) { + action = payload.actions[decoded.actionId] ?? null; + selectedRoleIds = (interaction as RoleSelectMenuInteraction).values; + } else if (interaction.isButton()) { + action = payload.actions[decoded.actionId] ?? null; + } else { + action = payload.actions[decoded.actionId] ?? null; + } + + if (!action) { + await interaction.reply({ + content: t(locale, 'componentsV2.error.actionMissing'), + ephemeral: true + }); + return; + } + + // Apply placeholders for reply content when possible. + const renderedActions = mapComponentsV2TextFields(payload, (value) => value).actions; + const rendered = renderedActions[decoded.actionId] ?? action; + + await executeAction( + interaction as MessageComponentInteraction, + interaction.isStringSelectMenu() ? action : rendered, + locale, + selectedRoleIds + ); +} + +// Re-export for typing convenience in index routing. +export type ComponentsV2ButtonInteraction = ButtonInteraction; +export type ComponentsV2StringSelectInteraction = StringSelectMenuInteraction; diff --git a/apps/bot/src/modules/components-v2/send.ts b/apps/bot/src/modules/components-v2/send.ts new file mode 100644 index 0000000..c82da90 --- /dev/null +++ b/apps/bot/src/modules/components-v2/send.ts @@ -0,0 +1,120 @@ +import { + PermissionFlagsBits, + type GuildTextBasedChannel, + type Message, + type TextChannel +} from 'discord.js'; +import { + DashboardMessageSendSchema, + MessageComponentsV2Schema, + WelcomeEmbedSchema, + type MessageComponentsV2 +} from '@nexumi/shared'; +import { ensureGuild } from '../../guild.js'; +import { buildEmbedFromPayload } from '../../lib/embed-payload.js'; +import { buildComponentsV2Payload } from '../../lib/components-v2-payload.js'; +import { logger } from '../../logger.js'; +import type { BotContext } from '../../types.js'; + +export class MessageSendError extends Error { + constructor(public readonly code: string) { + super(code); + } +} + +async function assertSendableChannel( + context: BotContext, + channelId: string +): Promise { + const channel = await context.client.channels.fetch(channelId); + if (!channel?.isTextBased() || channel.isDMBased()) { + throw new MessageSendError('invalid_channel'); + } + + const me = channel.guild.members.me; + if ( + !me?.permissionsIn(channel).has(PermissionFlagsBits.SendMessages) || + !me.permissionsIn(channel).has(PermissionFlagsBits.ViewChannel) + ) { + throw new MessageSendError('channel_unavailable'); + } + + return channel; +} + +export type DashboardMessageSendJob = { + guildId: string; + channelId: string; + createdById: string; + mode: 'EMBED' | 'COMPONENTS_V2'; + embed?: unknown; + components?: unknown; + bindingId?: string | null; +}; + +export type DashboardMessageSendResult = { + bindingId: string | null; + messageId: string | null; + channelId: string; +}; + +export async function sendDashboardMessage( + context: BotContext, + job: DashboardMessageSendJob +): Promise { + await ensureGuild(context.prisma, job.guildId); + const input = DashboardMessageSendSchema.parse({ + channelId: job.channelId, + mode: job.mode, + embed: job.embed ?? null, + components: job.components ?? null + }); + + const channel = await assertSendableChannel(context, input.channelId); + + if (input.mode === 'EMBED') { + const embedData = WelcomeEmbedSchema.parse(input.embed); + const embed = buildEmbedFromPayload(embedData, { renderText: (value) => value }); + if (!embed) { + throw new MessageSendError('content_required'); + } + const message = await (channel as TextChannel).send({ embeds: [embed] }); + return { bindingId: null, messageId: message.id, channelId: channel.id }; + } + + const components = MessageComponentsV2Schema.parse(input.components) as MessageComponentsV2; + let bindingId = job.bindingId ?? null; + + if (!bindingId) { + const binding = await context.prisma.componentMessageBinding.create({ + data: { + guildId: job.guildId, + channelId: input.channelId, + payload: components, + createdById: job.createdById + } + }); + bindingId = binding.id; + } + + const payload = buildComponentsV2Payload(components, { + source: 'm', + ref: bindingId + }); + if (!payload) { + throw new MessageSendError('content_required'); + } + + const message: Message = await (channel as TextChannel).send(payload); + await context.prisma.componentMessageBinding.update({ + where: { id: bindingId }, + data: { messageId: message.id } + }); + + logger.info( + { guildId: job.guildId, channelId: channel.id, messageId: message.id, bindingId }, + 'Dashboard Components V2 message sent' + ); + + return { bindingId, messageId: message.id, channelId: channel.id }; +} diff --git a/apps/bot/src/modules/scheduler/service.ts b/apps/bot/src/modules/scheduler/service.ts index 5b1bb57..e282a83 100644 --- a/apps/bot/src/modules/scheduler/service.ts +++ b/apps/bot/src/modules/scheduler/service.ts @@ -4,10 +4,11 @@ import { type MessageCreateOptions, type TextChannel } from 'discord.js'; -import { WelcomeEmbedSchema, expandBracketChannelMentions, t, tf } from '@nexumi/shared'; +import { WelcomeEmbedSchema, expandBracketChannelMentions, parseMessageComponentsV2, t, tf } from '@nexumi/shared'; import type { ScheduledMessage } from '@prisma/client'; import { ensureGuild } from '../../guild.js'; import { buildEmbedFromPayload } from '../../lib/embed-payload.js'; +import { buildComponentsV2Payload } from '../../lib/components-v2-payload.js'; import { logger } from '../../logger.js'; import { scheduleQueue } from '../../queues.js'; import type { BotContext } from '../../types.js'; @@ -91,8 +92,25 @@ function parseStoredEmbed(raw: unknown) { export function buildAnnouncementPayload(schedule: { content: string | null; embed: unknown; + components?: unknown; rolePingId: string | null; + id?: string; }): MessageCreateOptions { + const componentsData = parseMessageComponentsV2(schedule.components); + if (componentsData && schedule.id) { + const componentsPayload = buildComponentsV2Payload(componentsData, { + source: 's', + ref: schedule.id, + renderText: expandBracketChannelMentions + }); + if (componentsPayload) { + if (schedule.rolePingId) { + // Components V2 cannot mix classic content; role ping is skipped. + } + return componentsPayload; + } + } + const embedData = parseStoredEmbed(schedule.embed); const embed = buildEmbedFromPayload(embedData, { renderText: expandBracketChannelMentions @@ -255,7 +273,11 @@ export async function listScheduledMessages( .map((schedule) => { const preview = schedule.content?.slice(0, 60) ?? - (schedule.embed ? t(locale, 'scheduler.list.embedPreview') : '—'); + (schedule.components + ? t(locale, 'scheduler.list.componentsPreview') + : schedule.embed + ? t(locale, 'scheduler.list.embedPreview') + : '—'); const timing = schedule.cron ? tf(locale, 'scheduler.list.cron', { cron: schedule.cron }) : schedule.runAt @@ -313,7 +335,7 @@ export async function sendScheduledMessage(context: BotContext, scheduleId: stri try { const channel = await assertSendableChannel(context, schedule.channelId); const payload = buildAnnouncementPayload(schedule); - if (!payload.content && !payload.embeds?.length) { + if (!payload.content && !payload.embeds?.length && !payload.components?.length) { throw new SchedulerError('content_required'); } await (channel as TextChannel).send(payload); diff --git a/apps/bot/src/modules/tags/commands.ts b/apps/bot/src/modules/tags/commands.ts index c0bdc9c..2ee8c39 100644 --- a/apps/bot/src/modules/tags/commands.ts +++ b/apps/bot/src/modules/tags/commands.ts @@ -1,4 +1,4 @@ -import { PermissionFlagsBits } from 'discord.js'; +import { PermissionFlagsBits, type InteractionReplyOptions } from 'discord.js'; import { TagResponseTypeSchema, t, tf } from '@nexumi/shared'; import type { SlashCommand } from '../../types.js'; import { getGuildLocale } from '../../i18n.js'; @@ -67,7 +67,7 @@ const tagCommand: SlashCommand = { interaction.channelId!, args ); - await interaction.reply(payload); + await interaction.reply(payload as InteractionReplyOptions); return; } diff --git a/apps/bot/src/modules/tags/service.ts b/apps/bot/src/modules/tags/service.ts index 13e708e..9734cb2 100644 --- a/apps/bot/src/modules/tags/service.ts +++ b/apps/bot/src/modules/tags/service.ts @@ -1,15 +1,19 @@ -import type { EmbedBuilder, Guild, GuildMember } from 'discord.js'; +import type { Guild, GuildMember, MessageCreateOptions } from 'discord.js'; import { applyTagPlaceholders, + componentsV2HasContent, embedHasContent, + parseMessageComponentsV2, TagResponseTypeSchema, WelcomeEmbedSchema, + type MessageComponentsV2, type TagResponseType, type WelcomeEmbed } from '@nexumi/shared'; import type { Tag } from '@prisma/client'; import { ensureGuild } from '../../guild.js'; import { buildEmbedFromPayload } from '../../lib/embed-payload.js'; +import { buildComponentsV2Payload } from '../../lib/components-v2-payload.js'; import type { BotContext } from '../../types.js'; import { assertPremiumLimit, PremiumLimitError } from '../../premium.js'; @@ -70,10 +74,7 @@ export function parseTagEmbed(raw: unknown) { return parsed.success ? parsed.data : null; } -export type TagReplyPayload = { - content?: string; - embeds?: EmbedBuilder[]; -}; +export type TagReplyPayload = MessageCreateOptions; export function buildTagReply( tag: Tag, @@ -84,6 +85,16 @@ export function buildTagReply( const vars = buildTagPlaceholderVars(member, guild, args); const responseType = TagResponseTypeSchema.parse(tag.responseType); + if (responseType === 'COMPONENTS_V2') { + const components = parseMessageComponentsV2(tag.components); + const payload = buildComponentsV2Payload(components, { + source: 't', + ref: tag.id, + renderText: (value) => renderTagContent(value, vars) + }); + return payload ?? { content: '—' }; + } + if (responseType === 'EMBED') { const embedData = parseTagEmbed(tag.embed); const embed = buildEmbedFromPayload(embedData, { @@ -134,6 +145,7 @@ export async function createTag( responseType: TagResponseType; content?: string | null; embed?: WelcomeEmbed | null; + components?: MessageComponentsV2 | null; triggerWord?: string | null; allowedRoleIds?: string[]; allowedChannelIds?: string[]; @@ -155,6 +167,10 @@ export async function createTag( throw new TagError('embed_required'); } + if (params.responseType === 'COMPONENTS_V2' && !componentsV2HasContent(params.components)) { + throw new TagError('components_required'); + } + const currentCount = await context.prisma.tag.count({ where: { guildId: params.guildId } }); try { await assertPremiumLimit(context.prisma, params.guildId, 'tags', currentCount); @@ -172,6 +188,7 @@ export async function createTag( responseType: params.responseType, content: params.content ?? null, embed: params.embed ?? undefined, + components: params.components ?? undefined, triggerWord: params.triggerWord?.trim() || null, allowedRoleIds: params.allowedRoleIds ?? [], allowedChannelIds: params.allowedChannelIds ?? [], @@ -189,6 +206,7 @@ export async function updateTag( responseType?: TagResponseType; content?: string | null; embed?: WelcomeEmbed | null; + components?: MessageComponentsV2 | null; triggerWord?: string | null; allowedRoleIds?: string[]; allowedChannelIds?: string[]; @@ -202,6 +220,10 @@ export async function updateTag( const responseType = updates.responseType ?? TagResponseTypeSchema.parse(existing.responseType); const content = updates.content !== undefined ? updates.content : existing.content; const embed = updates.embed !== undefined ? updates.embed : parseTagEmbed(existing.embed); + const components = + updates.components !== undefined + ? updates.components + : parseMessageComponentsV2(existing.components); if (responseType === 'TEXT' && !content?.trim()) { throw new TagError('content_required'); @@ -211,6 +233,10 @@ export async function updateTag( throw new TagError('embed_required'); } + if (responseType === 'COMPONENTS_V2' && !componentsV2HasContent(components)) { + throw new TagError('components_required'); + } + let newName: string | undefined; if (updates.newName) { newName = normalizeTagName(updates.newName); @@ -226,6 +252,7 @@ export async function updateTag( responseType, content, embed: embed ?? undefined, + components: components ?? undefined, triggerWord: updates.triggerWord !== undefined ? updates.triggerWord?.trim() || null : existing.triggerWord, allowedRoleIds: updates.allowedRoleIds ?? existing.allowedRoleIds, diff --git a/apps/bot/src/modules/utility/command-definitions.ts b/apps/bot/src/modules/utility/command-definitions.ts index 34369db..b6ee0cf 100644 --- a/apps/bot/src/modules/utility/command-definitions.ts +++ b/apps/bot/src/modules/utility/command-definitions.ts @@ -180,6 +180,40 @@ export const editsnipeCommandData = applyCommandDescription( export const embedCommandData = applyCommandDescription( new SlashCommandBuilder().setName('embed'), 'utility.embed.description' -).addSubcommand((s) => - applyCommandDescription(s.setName('builder'), 'utility.embed.builder.description') -); +) + .addSubcommand((s) => + applyCommandDescription(s.setName('builder'), 'utility.embed.builder.description') + ) + .addSubcommand((s) => + applyCommandDescription(s.setName('components'), 'utility.embed.components.description') + .addChannelOption((o) => + applyOptionDescription( + o.setName('channel').setRequired(true), + 'utility.embed.components.options.channel' + ) + ) + .addStringOption((o) => + applyOptionDescription( + o.setName('text').setRequired(true).setMaxLength(2000), + 'utility.embed.components.options.text' + ) + ) + .addStringOption((o) => + applyOptionDescription( + o.setName('image_url').setMaxLength(2048), + 'utility.embed.components.options.image_url' + ) + ) + .addStringOption((o) => + applyOptionDescription( + o.setName('button_label').setMaxLength(80), + 'utility.embed.components.options.button_label' + ) + ) + .addStringOption((o) => + applyOptionDescription( + o.setName('button_url').setMaxLength(2048), + 'utility.embed.components.options.button_url' + ) + ) + ); diff --git a/apps/bot/src/modules/utility/commands.ts b/apps/bot/src/modules/utility/commands.ts index 00a05b5..c5facb5 100644 --- a/apps/bot/src/modules/utility/commands.ts +++ b/apps/bot/src/modules/utility/commands.ts @@ -422,7 +422,123 @@ const embedCommand: SlashCommand = { if (!(await requirePermission(interaction, PermissionFlagsBits.ManageMessages, locale))) { return; } - await showEmbedBuilderModal(interaction); + + const sub = interaction.options.getSubcommand(); + if (sub === 'builder') { + await showEmbedBuilderModal(interaction); + return; + } + + if (sub === 'components') { + const { ChannelType } = await import('discord.js'); + const channel = interaction.options.getChannel('channel', true); + const text = interaction.options.getString('text', true); + const imageUrl = interaction.options.getString('image_url'); + const buttonLabel = interaction.options.getString('button_label'); + const buttonUrl = interaction.options.getString('button_url'); + + if (!text.trim()) { + await interaction.reply({ + content: t(locale, 'utility.embed.components.missingText'), + ephemeral: true + }); + return; + } + + if ( + !channel || + (channel.type !== ChannelType.GuildText && + channel.type !== ChannelType.GuildAnnouncement && + channel.type !== ChannelType.PublicThread && + channel.type !== ChannelType.PrivateThread) + ) { + await interaction.reply({ + content: t(locale, 'utility.error.channel_not_found'), + ephemeral: true + }); + return; + } + + const containerChildren: import('@nexumi/shared').ComponentV2Node[] = [ + { type: 'text_display', content: text.trim() } + ]; + if (imageUrl?.trim()) { + containerChildren.push({ + type: 'media_gallery', + items: [{ url: imageUrl.trim() }] + }); + } + + const components: import('@nexumi/shared').MessageComponentsV2 = { + components: [ + { + type: 'container', + components: containerChildren + } + ], + actions: {} + }; + + if (buttonLabel?.trim() && buttonUrl?.trim()) { + components.components.push({ + type: 'action_row', + components: [ + { + type: 'button', + style: 'Link', + label: buttonLabel.trim(), + url: buttonUrl.trim() + } + ] + }); + } + + const binding = await context.prisma.componentMessageBinding.create({ + data: { + guildId: interaction.guildId!, + channelId: channel.id, + payload: components, + createdById: interaction.user.id + } + }); + + const { buildComponentsV2Payload } = await import('../../lib/components-v2-payload.js'); + const payload = buildComponentsV2Payload(components, { + source: 'm', + ref: binding.id + }); + if (!payload) { + await interaction.reply({ content: t(locale, 'generic.error'), ephemeral: true }); + return; + } + + const textChannel = await context.client.channels.fetch(channel.id); + if (!textChannel?.isTextBased() || textChannel.isDMBased()) { + await interaction.reply({ + content: t(locale, 'utility.error.channel_not_found'), + ephemeral: true + }); + return; + } + + const sent = await textChannel.send(payload); + await context.prisma.componentMessageBinding.update({ + where: { id: binding.id }, + data: { messageId: sent.id } + }); + + const webui = (await import('../../env.js')).env.WEBUI_URL; + const hint = webui + ? tf(locale, 'utility.embed.components.dashboardHint', { + url: `${webui}/dashboard/${interaction.guildId}/messages` + }) + : ''; + + await interaction.reply({ + content: [t(locale, 'utility.embed.components.sent'), hint].filter(Boolean).join('\n'), + ephemeral: true + }); + } } }; diff --git a/apps/bot/src/modules/welcome/commands.ts b/apps/bot/src/modules/welcome/commands.ts index 650cb22..278258d 100644 --- a/apps/bot/src/modules/welcome/commands.ts +++ b/apps/bot/src/modules/welcome/commands.ts @@ -1,4 +1,4 @@ -import { PermissionFlagsBits } from 'discord.js'; +import { MessageFlags, PermissionFlagsBits, type InteractionReplyOptions } from 'discord.js'; import { t } from '@nexumi/shared'; import type { SlashCommand } from '../../types.js'; import { getGuildLocale } from '../../i18n.js'; @@ -7,6 +7,26 @@ import { welcomeCommandData } from './command-definitions.js'; import { getWelcomeConfig, resolveWelcomePayload } from './service.js'; import { buildWelcomeMessage } from './renderer.js'; +function toInteractionReply( + message: Awaited>, + ephemeral: boolean +): InteractionReplyOptions { + const flags = + typeof message.flags === 'number' + ? ephemeral + ? message.flags | MessageFlags.Ephemeral + : message.flags + : undefined; + + return { + content: message.content, + embeds: message.embeds, + files: message.files, + components: message.components, + ...(flags !== undefined ? { flags } : ephemeral ? { ephemeral: true } : {}) + }; +} + const welcomeCommand: SlashCommand = { data: welcomeCommandData, async execute(interaction, context) { @@ -32,7 +52,7 @@ const welcomeCommand: SlashCommand = { if (sub === 'preview') { const message = await buildWelcomeMessage(payload, guildMember, interaction.guild!); - await interaction.reply({ ...message, ephemeral: true }); + await interaction.reply(toInteractionReply(message, true)); return; } @@ -48,7 +68,9 @@ const welcomeCommand: SlashCommand = { } const message = await buildWelcomeMessage(payload, guildMember, interaction.guild!); - await channel.send(message); + if ('send' in channel) { + await channel.send(message); + } await interaction.reply({ content: t(locale, 'welcome.testSent'), ephemeral: true }); } }; diff --git a/apps/bot/src/modules/welcome/events.ts b/apps/bot/src/modules/welcome/events.ts index a62ecb6..f344ffe 100644 --- a/apps/bot/src/modules/welcome/events.ts +++ b/apps/bot/src/modules/welcome/events.ts @@ -1,6 +1,6 @@ import { PermissionFlagsBits, type GuildMember } from 'discord.js'; import type { BotContext } from '../../types.js'; -import { getWelcomeConfig, resolveWelcomePayload, renderWelcomeText } from './service.js'; +import { getWelcomeConfig, resolveWelcomePayload, resolveLeavePayload, renderWelcomeText } from './service.js'; import { sendLeaveMessage, sendWelcomeMessage } from './renderer.js'; import { logger } from '../../logger.js'; @@ -55,8 +55,7 @@ export async function handleMemberLeave(context: BotContext, member: GuildMember if (!channel?.isTextBased() || channel.isDMBased()) { return; } - const content = config.leaveContent ?? '{user.tag} left {server}.'; - await sendLeaveMessage(channel, content, member, member.guild); + await sendLeaveMessage(channel, resolveLeavePayload(config), member, member.guild); } export function registerWelcomeEvents(context: BotContext): void { diff --git a/apps/bot/src/modules/welcome/renderer.ts b/apps/bot/src/modules/welcome/renderer.ts index 6451644..339d880 100644 --- a/apps/bot/src/modules/welcome/renderer.ts +++ b/apps/bot/src/modules/welcome/renderer.ts @@ -1,25 +1,37 @@ import { AttachmentBuilder, - EmbedBuilder, type Guild, type GuildMember, + type MessageCreateOptions, type SendableChannels, type TextBasedChannel } from 'discord.js'; import { createCanvas, loadImage } from '@napi-rs/canvas'; import { buildEmbedFromPayload } from '../../lib/embed-payload.js'; -import type { WelcomePayload } from './service.js'; +import { buildComponentsV2Payload } from '../../lib/components-v2-payload.js'; +import type { LeavePayload, WelcomePayload } from './service.js'; import { renderWelcomeText } from './service.js'; export async function buildWelcomeMessage( payload: WelcomePayload, member: GuildMember, guild: Guild -): Promise<{ content?: string; embeds?: EmbedBuilder[]; files?: AttachmentBuilder[] }> { +): Promise { + if (payload.type === 'COMPONENTS_V2') { + const componentsPayload = buildComponentsV2Payload(payload.components, { + source: 'w', + ref: guild.id, + renderText: (value) => renderWelcomeText(value, member, guild) + }); + if (componentsPayload) { + return componentsPayload; + } + return { content: renderWelcomeText('{user}', member, guild) }; + } + if (payload.type === 'EMBED') { const embed = buildEmbedFromPayload(payload.embed, { renderText: (value) => renderWelcomeText(value, member, guild), - // Keep previous Welcome behavior when no custom thumbnail is set. defaultThumbnailUrl: member.user.displayAvatarURL({ size: 256 }) }); return embed ? { embeds: [embed] } : { content: renderWelcomeText('{user}', member, guild) }; @@ -89,13 +101,47 @@ export async function sendWelcomeMessage( } } +export async function buildLeaveMessage( + payload: LeavePayload, + member: GuildMember, + guild: Guild +): Promise { + if (payload.type === 'COMPONENTS_V2') { + const componentsPayload = buildComponentsV2Payload(payload.components, { + source: 'l', + ref: guild.id, + renderText: (value) => renderWelcomeText(value, member, guild) + }); + if (componentsPayload) { + return componentsPayload; + } + } + + if (payload.type === 'EMBED') { + const embed = buildEmbedFromPayload(payload.embed, { + renderText: (value) => renderWelcomeText(value, member, guild), + defaultThumbnailUrl: member.user.displayAvatarURL({ size: 256 }) + }); + if (embed) { + return { embeds: [embed] }; + } + } + + const content = payload.content ?? '{user.tag} left {server}.'; + return { content: renderWelcomeText(content, member, guild) }; +} + export async function sendLeaveMessage( channel: TextBasedChannel, - content: string, + payload: LeavePayload | string, member: GuildMember, guild: Guild ): Promise { if ('send' in channel) { - await (channel as SendableChannels).send({ content: renderWelcomeText(content, member, guild) }); + const message = + typeof payload === 'string' + ? { content: renderWelcomeText(payload, member, guild) } + : await buildLeaveMessage(payload, member, guild); + await (channel as SendableChannels).send(message); } } diff --git a/apps/bot/src/modules/welcome/service.ts b/apps/bot/src/modules/welcome/service.ts index 60254ca..4d5c7c0 100644 --- a/apps/bot/src/modules/welcome/service.ts +++ b/apps/bot/src/modules/welcome/service.ts @@ -1,6 +1,6 @@ import type { PrismaClient } from '@prisma/client'; import type { WelcomeEmbed, WelcomeMessageType } from '@nexumi/shared'; -import { applyWelcomePlaceholders } from '@nexumi/shared'; +import { applyWelcomePlaceholders, parseMessageComponentsV2 } from '@nexumi/shared'; import type { Guild, GuildMember } from 'discord.js'; import { ensureGuild } from '../../guild.js'; @@ -41,6 +41,7 @@ export type WelcomePayload = { type: WelcomeMessageType; content?: string | null; embed?: WelcomeEmbed | null; + components?: import('@nexumi/shared').MessageComponentsV2 | null; imageTitle?: string | null; imageSubtitle?: string | null; }; @@ -52,7 +53,26 @@ export function resolveWelcomePayload( type: config.welcomeType as WelcomeMessageType, content: config.welcomeContent, embed: parseWelcomeEmbed(config.welcomeEmbed), + components: parseMessageComponentsV2(config.welcomeComponents), imageTitle: config.imageTitle, imageSubtitle: config.imageSubtitle }; } + +export type LeavePayload = { + type: import('@nexumi/shared').LeaveMessageType; + content?: string | null; + embed?: WelcomeEmbed | null; + components?: import('@nexumi/shared').MessageComponentsV2 | null; +}; + +export function resolveLeavePayload( + config: Awaited> +): LeavePayload { + return { + type: (config.leaveType as LeavePayload['type']) || 'TEXT', + content: config.leaveContent, + embed: parseWelcomeEmbed(config.leaveEmbed), + components: parseMessageComponentsV2(config.leaveComponents) + }; +} diff --git a/apps/bot/src/queues.ts b/apps/bot/src/queues.ts index 7d1fb4e..a2f1535 100644 --- a/apps/bot/src/queues.ts +++ b/apps/bot/src/queues.ts @@ -23,6 +23,8 @@ export const feedsQueueName = 'feeds'; export const feedsQueue = new Queue(feedsQueueName, { connection: redis }); export const scheduleQueueName = 'schedules'; export const scheduleQueue = new Queue(scheduleQueueName, { connection: redis }); +export const messagesQueueName = 'messages'; +export const messagesQueue = new Queue(messagesQueueName, { connection: redis }); export const guildBackupQueueName = 'guild-backups'; export const guildBackupQueue = new Queue(guildBackupQueueName, { connection: redis }); export const suggestionsQueueName = 'suggestions'; diff --git a/apps/webui/src/app/api/guilds/[guildId]/messages/route.ts b/apps/webui/src/app/api/guilds/[guildId]/messages/route.ts new file mode 100644 index 0000000..63dce7d --- /dev/null +++ b/apps/webui/src/app/api/guilds/[guildId]/messages/route.ts @@ -0,0 +1,41 @@ +import { DashboardMessageSendSchema } from '@nexumi/shared'; +import { type NextRequest, NextResponse } from 'next/server'; +import { requireGuildAccess, toApiErrorResponse } from '@/lib/auth'; +import { writeDashboardAudit } from '@/lib/audit'; +import { MessageSendTimeoutError, sendDashboardMessageFromWebui } from '@/lib/module-configs/messages'; +import { prisma } from '@/lib/prisma'; + +interface RouteParams { + params: Promise<{ guildId: string }>; +} + +export async function POST(request: NextRequest, { params }: RouteParams) { + const { guildId } = await params; + try { + const session = await requireGuildAccess(guildId); + const body = await request.json(); + const input = DashboardMessageSendSchema.parse(body); + + const result = await sendDashboardMessageFromWebui(guildId, session.user.id, input); + + await writeDashboardAudit(prisma, { + guildId, + actorUserId: session.user.id, + action: 'messages.send', + path: `/dashboard/${guildId}/messages`, + after: { + channelId: result.channelId, + mode: input.mode, + messageId: result.messageId, + bindingId: result.bindingId + } + }); + + return NextResponse.json(result, { status: 201 }); + } catch (error) { + if (error instanceof MessageSendTimeoutError) { + return NextResponse.json({ error: error.message }, { status: 504 }); + } + return toApiErrorResponse(error); + } +} diff --git a/apps/webui/src/app/dashboard/[guildId]/messages/loading.tsx b/apps/webui/src/app/dashboard/[guildId]/messages/loading.tsx new file mode 100644 index 0000000..d3cba05 --- /dev/null +++ b/apps/webui/src/app/dashboard/[guildId]/messages/loading.tsx @@ -0,0 +1,13 @@ +import { Skeleton } from '@/components/ui/skeleton'; + +export default function MessagesLoading() { + return ( +
+
+ + +
+ +
+ ); +} diff --git a/apps/webui/src/app/dashboard/[guildId]/messages/page.tsx b/apps/webui/src/app/dashboard/[guildId]/messages/page.tsx new file mode 100644 index 0000000..db847fa --- /dev/null +++ b/apps/webui/src/app/dashboard/[guildId]/messages/page.tsx @@ -0,0 +1,21 @@ +import { MessagesManager } from '@/components/modules/messages-manager'; +import { getLocale, t } from '@/lib/i18n'; + +interface MessagesPageProps { + params: Promise<{ guildId: string }>; +} + +export default async function MessagesPage({ params }: MessagesPageProps) { + const { guildId } = await params; + const locale = await getLocale(); + + return ( +
+
+

{t(locale, 'modules.messages.label')}

+

{t(locale, 'modules.messages.description')}

+
+ +
+ ); +} diff --git a/apps/webui/src/components/layout/sidebar.tsx b/apps/webui/src/components/layout/sidebar.tsx index 6a3150a..6ef2c49 100644 --- a/apps/webui/src/components/layout/sidebar.tsx +++ b/apps/webui/src/components/layout/sidebar.tsx @@ -11,6 +11,7 @@ import { Gift, Hash, LayoutGrid, + MessageSquare, MessageSquareQuote, MessagesSquare, PartyPopper, @@ -63,7 +64,8 @@ const MODULE_ICONS: Record = { stats: BarChart3, feeds: Rss, scheduler: CalendarClock, - guildbackup: Archive + guildbackup: Archive, + messages: MessageSquare }; interface SidebarNavProps { diff --git a/apps/webui/src/components/modules/messages-manager.tsx b/apps/webui/src/components/modules/messages-manager.tsx new file mode 100644 index 0000000..caa36f4 --- /dev/null +++ b/apps/webui/src/components/modules/messages-manager.tsx @@ -0,0 +1,147 @@ +'use client'; + +import { + componentsV2HasContent, + embedHasContent, + type DashboardMessageMode, + type MessageComponentsV2, + type WelcomeEmbed +} from '@nexumi/shared'; +import { Send } from 'lucide-react'; +import { useState } from 'react'; +import { toast } from 'sonner'; +import { FieldAnchor } from '@/components/layout/field-anchor'; +import { useTranslations } from '@/components/locale-provider'; +import { Button } from '@/components/ui/button'; +import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'; +import { DiscordChannelSelect } from '@/components/ui/discord-channel-select'; +import { + DiscordComponentsV2Builder, + emptyComponentsV2, + normalizeComponentsV2 +} from '@/components/ui/discord-components-v2-builder'; +import { DiscordEmbedBuilder, emptyEmbed, normalizeEmbed } from '@/components/ui/discord-embed-builder'; +import { Label } from '@/components/ui/label'; +import { cn } from '@/lib/utils'; + +interface MessagesManagerProps { + guildId: string; +} + +export function MessagesManager({ guildId }: MessagesManagerProps) { + const t = useTranslations(); + const [channelId, setChannelId] = useState(''); + const [mode, setMode] = useState('EMBED'); + const [embed, setEmbed] = useState(emptyEmbed()); + const [components, setComponents] = useState(emptyComponentsV2()); + const [sending, setSending] = useState(false); + + async function handleSend() { + if (!channelId.trim()) { + toast.error(t('modulePages.messages.channelRequired')); + return; + } + + const normalizedEmbed = mode === 'EMBED' ? normalizeEmbed(embed) : null; + const normalizedComponents = mode === 'COMPONENTS_V2' ? normalizeComponentsV2(components) : null; + + if ( + mode === 'EMBED' && + !embedHasContent(normalizedEmbed) && + normalizedEmbed?.color === undefined + ) { + toast.error(t('modulePages.messages.embedRequired')); + return; + } + + if (mode === 'COMPONENTS_V2' && !componentsV2HasContent(normalizedComponents)) { + toast.error(t('modulePages.messages.componentsRequired')); + return; + } + + setSending(true); + try { + const response = await fetch(`/api/guilds/${guildId}/messages`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + channelId: channelId.trim(), + mode, + embed: normalizedEmbed, + components: normalizedComponents + }) + }); + const body = (await response.json().catch(() => null)) as { error?: string; messageId?: string | null } | null; + if (!response.ok || !body) { + toast.error(body?.error ?? t('common.saveError')); + return; + } + toast.success( + body.messageId + ? t('modulePages.messages.sentWithId', { id: body.messageId }) + : t('modulePages.messages.sent') + ); + } catch { + toast.error(t('common.saveError')); + } finally { + setSending(false); + } + } + + return ( + + + + {t('modulePages.messages.sendTitle')} + {t('modulePages.messages.sendDescription')} + + +
+ + +
+ +
+ +
+ {(['EMBED', 'COMPONENTS_V2'] as DashboardMessageMode[]).map((entry) => ( + + ))} +
+
+ + {mode === 'EMBED' ? ( + + ) : ( + + )} + + +
+
+
+ ); +} diff --git a/apps/webui/src/components/modules/scheduler-manager.tsx b/apps/webui/src/components/modules/scheduler-manager.tsx index 446de6f..1d5eaee 100644 --- a/apps/webui/src/components/modules/scheduler-manager.tsx +++ b/apps/webui/src/components/modules/scheduler-manager.tsx @@ -1,6 +1,12 @@ 'use client'; -import { embedHasContent, type ScheduledMessageDashboard, type WelcomeEmbed } from '@nexumi/shared'; +import { + componentsV2HasContent, + embedHasContent, + type MessageComponentsV2, + type ScheduledMessageDashboard, + type WelcomeEmbed +} from '@nexumi/shared'; import { Plus, Trash2 } from 'lucide-react'; import { useState } from 'react'; import { toast } from 'sonner'; @@ -9,17 +15,27 @@ import { useTranslations } from '@/components/locale-provider'; import { Button } from '@/components/ui/button'; import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'; import { DiscordChannelSelect } from '@/components/ui/discord-channel-select'; +import { + DiscordComponentsV2Builder, + emptyComponentsV2, + normalizeComponentsV2 +} from '@/components/ui/discord-components-v2-builder'; import { DiscordEmbedBuilder, normalizeEmbed } from '@/components/ui/discord-embed-builder'; import { DiscordRoleSelect } from '@/components/ui/discord-role-select'; import { Input } from '@/components/ui/input'; import { Label } from '@/components/ui/label'; import { PlaceholderHelp } from '@/components/ui/placeholder-help'; import { Textarea } from '@/components/ui/textarea'; +import { cn } from '@/lib/utils'; + +type ScheduleContentMode = 'text' | 'embed' | 'components_v2'; const EMPTY_DRAFT = { channelId: '', + contentMode: 'text' as ScheduleContentMode, content: '', embed: null as WelcomeEmbed | null, + components: null as MessageComponentsV2 | null, rolePingId: '', cron: '', runAt: '' @@ -30,7 +46,7 @@ function formatTiming(schedule: ScheduledMessageDashboard, t: (key: string) => s return `${t('modulePages.scheduler.cron')}: ${schedule.cron}`; } if (schedule.runAt) { - return `${t('modulePages.scheduler.runAt')}: ${new Date(schedule.runAt).toLocaleString(undefined, { + return `${new Date(schedule.runAt).toLocaleString(undefined, { dateStyle: 'medium', timeStyle: 'short' })}`; @@ -42,6 +58,9 @@ function schedulePreview(schedule: ScheduledMessageDashboard, t: (key: string) = if (schedule.content?.trim()) { return schedule.content; } + if (componentsV2HasContent(schedule.components)) { + return t('modulePages.scheduler.componentsPreview'); + } if (schedule.embed?.title?.trim()) { return schedule.embed.title; } @@ -69,13 +88,21 @@ export function SchedulerManager({ guildId, initialSchedules }: SchedulerManager const [creating, setCreating] = useState(false); async function handleCreate() { - const embed = normalizeEmbed(draft.embed); + const embed = draft.contentMode === 'embed' ? normalizeEmbed(draft.embed) : null; + const components = + draft.contentMode === 'components_v2' ? normalizeComponentsV2(draft.components ?? emptyComponentsV2()) : null; + const content = draft.contentMode === 'text' ? draft.content.trim() || null : null; + const hasContent = - Boolean(draft.content.trim()) || embedHasContent(embed) || embed?.color !== undefined; + Boolean(content) || + (draft.contentMode === 'embed' && (embedHasContent(embed) || embed?.color !== undefined)) || + (draft.contentMode === 'components_v2' && componentsV2HasContent(components)); + if (!draft.channelId.trim() || !hasContent || (!draft.cron.trim() && !draft.runAt)) { toast.error(t('modulePages.scheduler.formIncomplete')); return; } + setCreating(true); try { const response = await fetch(`/api/guilds/${guildId}/scheduler`, { @@ -83,8 +110,9 @@ export function SchedulerManager({ guildId, initialSchedules }: SchedulerManager headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ channelId: draft.channelId.trim(), - content: draft.content.trim() || null, + content, embed, + components, rolePingId: draft.rolePingId.trim() || undefined, cron: draft.cron.trim() || null, runAt: draft.cron.trim() ? null : draft.runAt ? new Date(draft.runAt).toISOString() : null @@ -169,23 +197,60 @@ export function SchedulerManager({ guildId, initialSchedules }: SchedulerManager /> +
- -