deploy #1

Merged
smueller merged 30 commits from deploy into main 2026-07-24 08:41:09 +00:00
46 changed files with 4505 additions and 194 deletions
Showing only changes of commit 4e135bcf43 - Show all commits

View File

@@ -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 $$;

View File

@@ -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 $$;

View File

@@ -51,6 +51,7 @@ model Guild {
socialFeeds SocialFeed[] socialFeeds SocialFeed[]
scheduledMessages ScheduledMessage[] scheduledMessages ScheduledMessage[]
guildBackups GuildBackup[] guildBackups GuildBackup[]
componentMessageBindings ComponentMessageBinding[]
dashboardAccessRules DashboardAccessRule[] dashboardAccessRules DashboardAccessRule[]
dashboardAuditLogs DashboardAuditLog[] dashboardAuditLogs DashboardAuditLog[]
commandOverrides CommandOverride[] commandOverrides CommandOverride[]
@@ -245,8 +246,11 @@ model WelcomeConfig {
welcomeType String @default("TEXT") welcomeType String @default("TEXT")
welcomeContent String? welcomeContent String?
welcomeEmbed Json? welcomeEmbed Json?
welcomeComponents Json?
leaveType String @default("TEXT")
leaveContent String? leaveContent String?
leaveEmbed Json? leaveEmbed Json?
leaveComponents Json?
welcomeDmEnabled Boolean @default(false) welcomeDmEnabled Boolean @default(false)
welcomeDmContent String? welcomeDmContent String?
userAutoroleIds String[] @default([]) userAutoroleIds String[] @default([])
@@ -555,6 +559,7 @@ model Tag {
name String name String
content String? content String?
embed Json? embed Json?
components Json?
responseType String @default("TEXT") responseType String @default("TEXT")
triggerWord String? triggerWord String?
allowedRoleIds String[] @default([]) allowedRoleIds String[] @default([])
@@ -774,6 +779,7 @@ model ScheduledMessage {
creatorId String creatorId String
content String? content String?
embed Json? embed Json?
components Json?
rolePingId String? rolePingId String?
cron String? cron String?
runAt DateTime? runAt DateTime?
@@ -786,6 +792,20 @@ model ScheduledMessage {
@@index([guildId, enabled]) @@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 { model GuildBackup {
id String @id @default(cuid()) id String @id @default(cuid())
guildId String guildId String

View File

@@ -64,6 +64,10 @@ import {
} from './modules/tempvoice/index.js'; } from './modules/tempvoice/index.js';
import { registerStatsEvents } from './modules/stats/index.js'; import { registerStatsEvents } from './modules/stats/index.js';
import { handleGuildBackupButton, isGuildBackupButton } from './modules/guildbackup/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'); const isShard = process.argv.includes('--shard');
@@ -220,6 +224,19 @@ if (!isShard) {
await handleEmbedModal(interaction, context); await handleEmbedModal(interaction, context);
return; return;
} }
if (
(interaction.isButton() ||
interaction.isStringSelectMenu() ||
interaction.isUserSelectMenu() ||
interaction.isRoleSelectMenu() ||
interaction.isChannelSelectMenu() ||
interaction.isMentionableSelectMenu()) &&
isComponentsV2Interaction(interaction.customId)
) {
await handleComponentsV2Interaction(interaction, context);
return;
}
} catch (error) { } catch (error) {
logger.error({ error }, 'Interaction handling failed'); logger.error({ error }, 'Interaction handling failed');
const locale = await getGuildLocale(context.prisma, interaction.guildId); const locale = await getGuildLocale(context.prisma, interaction.guildId);

View File

@@ -18,6 +18,7 @@ import { expireBirthdayRole, runBirthdayCheck } from './modules/birthdays/index.
import { ensureStatsRecurringJobs, startStatsWorker } from './modules/stats/index.js'; import { ensureStatsRecurringJobs, startStatsWorker } from './modules/stats/index.js';
import { pollAllFeeds } from './modules/feeds/index.js'; import { pollAllFeeds } from './modules/feeds/index.js';
import { sendScheduledMessage } from './modules/scheduler/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 { runGuildBackupRestoreJob } from './modules/guildbackup/index.js';
import { runSuggestionStatusUpdateJob } from './modules/suggestions/index.js'; import { runSuggestionStatusUpdateJob } from './modules/suggestions/index.js';
import { runPresenceRefreshJob } from './presence.js'; import { runPresenceRefreshJob } from './presence.js';
@@ -32,6 +33,7 @@ import {
feedsQueueName, feedsQueueName,
giveawayQueueName, giveawayQueueName,
guildBackupQueueName, guildBackupQueueName,
messagesQueueName,
moderationQueueName, moderationQueueName,
presenceQueue, presenceQueue,
presenceQueueName, presenceQueueName,
@@ -281,6 +283,20 @@ export function startWorkers(context: BotContext): Worker[] {
logger.error({ jobId: job?.id, error }, 'Schedule job failed'); logger.error({ jobId: job?.id, error }, 'Schedule job failed');
}); });
const messagesWorker = new Worker<DashboardMessageSendJob>(
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 }>( const guildBackupWorker = new Worker<{ backupId: string; guildId: string }>(
guildBackupQueueName, guildBackupQueueName,
async (job) => { async (job) => {
@@ -349,6 +365,7 @@ export function startWorkers(context: BotContext): Worker[] {
statsWorker, statsWorker,
feedsWorker, feedsWorker,
scheduleWorker, scheduleWorker,
messagesWorker,
guildBackupWorker, guildBackupWorker,
suggestionsWorker, suggestionsWorker,
presenceWorker, presenceWorker,

View File

@@ -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<ComponentButtonStyle, ButtonStyle> = {
Primary: ButtonStyle.Primary,
Secondary: ButtonStyle.Secondary,
Success: ButtonStyle.Success,
Danger: ButtonStyle.Danger,
Link: ButtonStyle.Link
};
const SPACING_MAP: Record<SeparatorSpacing, SeparatorSpacingSize> = {
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<MessageActionRowComponentBuilder>;
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<MessageActionRowComponentBuilder>();
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<MessageActionRowComponentBuilder>().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<MessageActionRowComponentBuilder>().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;
}

View File

@@ -0,0 +1,4 @@
export {
handleComponentsV2Interaction,
isComponentsV2Interaction
} from './interactions.js';

View File

@@ -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<MessageComponentsV2 | null> {
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<string | null> {
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<void> {
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<void> {
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;

View File

@@ -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<GuildTextBasedChannel> {
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<DashboardMessageSendResult> {
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 };
}

View File

@@ -4,10 +4,11 @@ import {
type MessageCreateOptions, type MessageCreateOptions,
type TextChannel type TextChannel
} from 'discord.js'; } 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 type { ScheduledMessage } from '@prisma/client';
import { ensureGuild } from '../../guild.js'; import { ensureGuild } from '../../guild.js';
import { buildEmbedFromPayload } from '../../lib/embed-payload.js'; import { buildEmbedFromPayload } from '../../lib/embed-payload.js';
import { buildComponentsV2Payload } from '../../lib/components-v2-payload.js';
import { logger } from '../../logger.js'; import { logger } from '../../logger.js';
import { scheduleQueue } from '../../queues.js'; import { scheduleQueue } from '../../queues.js';
import type { BotContext } from '../../types.js'; import type { BotContext } from '../../types.js';
@@ -91,8 +92,25 @@ function parseStoredEmbed(raw: unknown) {
export function buildAnnouncementPayload(schedule: { export function buildAnnouncementPayload(schedule: {
content: string | null; content: string | null;
embed: unknown; embed: unknown;
components?: unknown;
rolePingId: string | null; rolePingId: string | null;
id?: string;
}): MessageCreateOptions { }): 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 embedData = parseStoredEmbed(schedule.embed);
const embed = buildEmbedFromPayload(embedData, { const embed = buildEmbedFromPayload(embedData, {
renderText: expandBracketChannelMentions renderText: expandBracketChannelMentions
@@ -255,7 +273,11 @@ export async function listScheduledMessages(
.map((schedule) => { .map((schedule) => {
const preview = const preview =
schedule.content?.slice(0, 60) ?? 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 const timing = schedule.cron
? tf(locale, 'scheduler.list.cron', { cron: schedule.cron }) ? tf(locale, 'scheduler.list.cron', { cron: schedule.cron })
: schedule.runAt : schedule.runAt
@@ -313,7 +335,7 @@ export async function sendScheduledMessage(context: BotContext, scheduleId: stri
try { try {
const channel = await assertSendableChannel(context, schedule.channelId); const channel = await assertSendableChannel(context, schedule.channelId);
const payload = buildAnnouncementPayload(schedule); const payload = buildAnnouncementPayload(schedule);
if (!payload.content && !payload.embeds?.length) { if (!payload.content && !payload.embeds?.length && !payload.components?.length) {
throw new SchedulerError('content_required'); throw new SchedulerError('content_required');
} }
await (channel as TextChannel).send(payload); await (channel as TextChannel).send(payload);

View File

@@ -1,4 +1,4 @@
import { PermissionFlagsBits } from 'discord.js'; import { PermissionFlagsBits, type InteractionReplyOptions } from 'discord.js';
import { TagResponseTypeSchema, t, tf } from '@nexumi/shared'; import { TagResponseTypeSchema, 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';
@@ -67,7 +67,7 @@ const tagCommand: SlashCommand = {
interaction.channelId!, interaction.channelId!,
args args
); );
await interaction.reply(payload); await interaction.reply(payload as InteractionReplyOptions);
return; return;
} }

View File

@@ -1,15 +1,19 @@
import type { EmbedBuilder, Guild, GuildMember } from 'discord.js'; import type { Guild, GuildMember, MessageCreateOptions } from 'discord.js';
import { import {
applyTagPlaceholders, applyTagPlaceholders,
componentsV2HasContent,
embedHasContent, embedHasContent,
parseMessageComponentsV2,
TagResponseTypeSchema, TagResponseTypeSchema,
WelcomeEmbedSchema, WelcomeEmbedSchema,
type MessageComponentsV2,
type TagResponseType, type TagResponseType,
type WelcomeEmbed type WelcomeEmbed
} from '@nexumi/shared'; } from '@nexumi/shared';
import type { Tag } from '@prisma/client'; import type { Tag } from '@prisma/client';
import { ensureGuild } from '../../guild.js'; import { ensureGuild } from '../../guild.js';
import { buildEmbedFromPayload } from '../../lib/embed-payload.js'; import { buildEmbedFromPayload } from '../../lib/embed-payload.js';
import { buildComponentsV2Payload } from '../../lib/components-v2-payload.js';
import type { BotContext } from '../../types.js'; import type { BotContext } from '../../types.js';
import { assertPremiumLimit, PremiumLimitError } from '../../premium.js'; import { assertPremiumLimit, PremiumLimitError } from '../../premium.js';
@@ -70,10 +74,7 @@ export function parseTagEmbed(raw: unknown) {
return parsed.success ? parsed.data : null; return parsed.success ? parsed.data : null;
} }
export type TagReplyPayload = { export type TagReplyPayload = MessageCreateOptions;
content?: string;
embeds?: EmbedBuilder[];
};
export function buildTagReply( export function buildTagReply(
tag: Tag, tag: Tag,
@@ -84,6 +85,16 @@ export function buildTagReply(
const vars = buildTagPlaceholderVars(member, guild, args); const vars = buildTagPlaceholderVars(member, guild, args);
const responseType = TagResponseTypeSchema.parse(tag.responseType); 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') { if (responseType === 'EMBED') {
const embedData = parseTagEmbed(tag.embed); const embedData = parseTagEmbed(tag.embed);
const embed = buildEmbedFromPayload(embedData, { const embed = buildEmbedFromPayload(embedData, {
@@ -134,6 +145,7 @@ export async function createTag(
responseType: TagResponseType; responseType: TagResponseType;
content?: string | null; content?: string | null;
embed?: WelcomeEmbed | null; embed?: WelcomeEmbed | null;
components?: MessageComponentsV2 | null;
triggerWord?: string | null; triggerWord?: string | null;
allowedRoleIds?: string[]; allowedRoleIds?: string[];
allowedChannelIds?: string[]; allowedChannelIds?: string[];
@@ -155,6 +167,10 @@ export async function createTag(
throw new TagError('embed_required'); 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 } }); const currentCount = await context.prisma.tag.count({ where: { guildId: params.guildId } });
try { try {
await assertPremiumLimit(context.prisma, params.guildId, 'tags', currentCount); await assertPremiumLimit(context.prisma, params.guildId, 'tags', currentCount);
@@ -172,6 +188,7 @@ export async function createTag(
responseType: params.responseType, responseType: params.responseType,
content: params.content ?? null, content: params.content ?? null,
embed: params.embed ?? undefined, embed: params.embed ?? undefined,
components: params.components ?? undefined,
triggerWord: params.triggerWord?.trim() || null, triggerWord: params.triggerWord?.trim() || null,
allowedRoleIds: params.allowedRoleIds ?? [], allowedRoleIds: params.allowedRoleIds ?? [],
allowedChannelIds: params.allowedChannelIds ?? [], allowedChannelIds: params.allowedChannelIds ?? [],
@@ -189,6 +206,7 @@ export async function updateTag(
responseType?: TagResponseType; responseType?: TagResponseType;
content?: string | null; content?: string | null;
embed?: WelcomeEmbed | null; embed?: WelcomeEmbed | null;
components?: MessageComponentsV2 | null;
triggerWord?: string | null; triggerWord?: string | null;
allowedRoleIds?: string[]; allowedRoleIds?: string[];
allowedChannelIds?: string[]; allowedChannelIds?: string[];
@@ -202,6 +220,10 @@ export async function updateTag(
const responseType = updates.responseType ?? TagResponseTypeSchema.parse(existing.responseType); const responseType = updates.responseType ?? TagResponseTypeSchema.parse(existing.responseType);
const content = updates.content !== undefined ? updates.content : existing.content; const content = updates.content !== undefined ? updates.content : existing.content;
const embed = updates.embed !== undefined ? updates.embed : parseTagEmbed(existing.embed); 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()) { if (responseType === 'TEXT' && !content?.trim()) {
throw new TagError('content_required'); throw new TagError('content_required');
@@ -211,6 +233,10 @@ export async function updateTag(
throw new TagError('embed_required'); throw new TagError('embed_required');
} }
if (responseType === 'COMPONENTS_V2' && !componentsV2HasContent(components)) {
throw new TagError('components_required');
}
let newName: string | undefined; let newName: string | undefined;
if (updates.newName) { if (updates.newName) {
newName = normalizeTagName(updates.newName); newName = normalizeTagName(updates.newName);
@@ -226,6 +252,7 @@ export async function updateTag(
responseType, responseType,
content, content,
embed: embed ?? undefined, embed: embed ?? undefined,
components: components ?? undefined,
triggerWord: triggerWord:
updates.triggerWord !== undefined ? updates.triggerWord?.trim() || null : existing.triggerWord, updates.triggerWord !== undefined ? updates.triggerWord?.trim() || null : existing.triggerWord,
allowedRoleIds: updates.allowedRoleIds ?? existing.allowedRoleIds, allowedRoleIds: updates.allowedRoleIds ?? existing.allowedRoleIds,

View File

@@ -180,6 +180,40 @@ export const editsnipeCommandData = applyCommandDescription(
export const embedCommandData = applyCommandDescription( export const embedCommandData = applyCommandDescription(
new SlashCommandBuilder().setName('embed'), new SlashCommandBuilder().setName('embed'),
'utility.embed.description' 'utility.embed.description'
).addSubcommand((s) => )
.addSubcommand((s) =>
applyCommandDescription(s.setName('builder'), 'utility.embed.builder.description') 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'
)
)
); );

View File

@@ -422,7 +422,123 @@ const embedCommand: SlashCommand = {
if (!(await requirePermission(interaction, PermissionFlagsBits.ManageMessages, locale))) { if (!(await requirePermission(interaction, PermissionFlagsBits.ManageMessages, locale))) {
return; return;
} }
const sub = interaction.options.getSubcommand();
if (sub === 'builder') {
await showEmbedBuilderModal(interaction); 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
});
}
} }
}; };

View File

@@ -1,4 +1,4 @@
import { PermissionFlagsBits } from 'discord.js'; import { MessageFlags, PermissionFlagsBits, type InteractionReplyOptions } from 'discord.js';
import { t } from '@nexumi/shared'; import { t } 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';
@@ -7,6 +7,26 @@ import { welcomeCommandData } from './command-definitions.js';
import { getWelcomeConfig, resolveWelcomePayload } from './service.js'; import { getWelcomeConfig, resolveWelcomePayload } from './service.js';
import { buildWelcomeMessage } from './renderer.js'; import { buildWelcomeMessage } from './renderer.js';
function toInteractionReply(
message: Awaited<ReturnType<typeof buildWelcomeMessage>>,
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 = { const welcomeCommand: SlashCommand = {
data: welcomeCommandData, data: welcomeCommandData,
async execute(interaction, context) { async execute(interaction, context) {
@@ -32,7 +52,7 @@ const welcomeCommand: SlashCommand = {
if (sub === 'preview') { if (sub === 'preview') {
const message = await buildWelcomeMessage(payload, guildMember, interaction.guild!); const message = await buildWelcomeMessage(payload, guildMember, interaction.guild!);
await interaction.reply({ ...message, ephemeral: true }); await interaction.reply(toInteractionReply(message, true));
return; return;
} }
@@ -48,7 +68,9 @@ const welcomeCommand: SlashCommand = {
} }
const message = await buildWelcomeMessage(payload, guildMember, interaction.guild!); const message = await buildWelcomeMessage(payload, guildMember, interaction.guild!);
if ('send' in channel) {
await channel.send(message); await channel.send(message);
}
await interaction.reply({ content: t(locale, 'welcome.testSent'), ephemeral: true }); await interaction.reply({ content: t(locale, 'welcome.testSent'), ephemeral: true });
} }
}; };

View File

@@ -1,6 +1,6 @@
import { PermissionFlagsBits, type GuildMember } from 'discord.js'; import { PermissionFlagsBits, type GuildMember } from 'discord.js';
import type { BotContext } from '../../types.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 { sendLeaveMessage, sendWelcomeMessage } from './renderer.js';
import { logger } from '../../logger.js'; import { logger } from '../../logger.js';
@@ -55,8 +55,7 @@ export async function handleMemberLeave(context: BotContext, member: GuildMember
if (!channel?.isTextBased() || channel.isDMBased()) { if (!channel?.isTextBased() || channel.isDMBased()) {
return; return;
} }
const content = config.leaveContent ?? '{user.tag} left {server}.'; await sendLeaveMessage(channel, resolveLeavePayload(config), member, member.guild);
await sendLeaveMessage(channel, content, member, member.guild);
} }
export function registerWelcomeEvents(context: BotContext): void { export function registerWelcomeEvents(context: BotContext): void {

View File

@@ -1,25 +1,37 @@
import { import {
AttachmentBuilder, AttachmentBuilder,
EmbedBuilder,
type Guild, type Guild,
type GuildMember, type GuildMember,
type MessageCreateOptions,
type SendableChannels, type SendableChannels,
type TextBasedChannel type TextBasedChannel
} from 'discord.js'; } from 'discord.js';
import { createCanvas, loadImage } from '@napi-rs/canvas'; import { createCanvas, loadImage } from '@napi-rs/canvas';
import { buildEmbedFromPayload } from '../../lib/embed-payload.js'; 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'; import { renderWelcomeText } from './service.js';
export async function buildWelcomeMessage( export async function buildWelcomeMessage(
payload: WelcomePayload, payload: WelcomePayload,
member: GuildMember, member: GuildMember,
guild: Guild guild: Guild
): Promise<{ content?: string; embeds?: EmbedBuilder[]; files?: AttachmentBuilder[] }> { ): Promise<MessageCreateOptions> {
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') { if (payload.type === 'EMBED') {
const embed = buildEmbedFromPayload(payload.embed, { const embed = buildEmbedFromPayload(payload.embed, {
renderText: (value) => renderWelcomeText(value, member, guild), renderText: (value) => renderWelcomeText(value, member, guild),
// Keep previous Welcome behavior when no custom thumbnail is set.
defaultThumbnailUrl: member.user.displayAvatarURL({ size: 256 }) defaultThumbnailUrl: member.user.displayAvatarURL({ size: 256 })
}); });
return embed ? { embeds: [embed] } : { content: renderWelcomeText('{user}', member, guild) }; 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<MessageCreateOptions> {
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( export async function sendLeaveMessage(
channel: TextBasedChannel, channel: TextBasedChannel,
content: string, payload: LeavePayload | string,
member: GuildMember, member: GuildMember,
guild: Guild guild: Guild
): Promise<void> { ): Promise<void> {
if ('send' in channel) { 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);
} }
} }

View File

@@ -1,6 +1,6 @@
import type { PrismaClient } from '@prisma/client'; import type { PrismaClient } from '@prisma/client';
import type { WelcomeEmbed, WelcomeMessageType } from '@nexumi/shared'; 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 type { Guild, GuildMember } from 'discord.js';
import { ensureGuild } from '../../guild.js'; import { ensureGuild } from '../../guild.js';
@@ -41,6 +41,7 @@ export type WelcomePayload = {
type: WelcomeMessageType; type: WelcomeMessageType;
content?: string | null; content?: string | null;
embed?: WelcomeEmbed | null; embed?: WelcomeEmbed | null;
components?: import('@nexumi/shared').MessageComponentsV2 | null;
imageTitle?: string | null; imageTitle?: string | null;
imageSubtitle?: string | null; imageSubtitle?: string | null;
}; };
@@ -52,7 +53,26 @@ export function resolveWelcomePayload(
type: config.welcomeType as WelcomeMessageType, type: config.welcomeType as WelcomeMessageType,
content: config.welcomeContent, content: config.welcomeContent,
embed: parseWelcomeEmbed(config.welcomeEmbed), embed: parseWelcomeEmbed(config.welcomeEmbed),
components: parseMessageComponentsV2(config.welcomeComponents),
imageTitle: config.imageTitle, imageTitle: config.imageTitle,
imageSubtitle: config.imageSubtitle 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<ReturnType<typeof getWelcomeConfig>>
): LeavePayload {
return {
type: (config.leaveType as LeavePayload['type']) || 'TEXT',
content: config.leaveContent,
embed: parseWelcomeEmbed(config.leaveEmbed),
components: parseMessageComponentsV2(config.leaveComponents)
};
}

View File

@@ -23,6 +23,8 @@ export const feedsQueueName = 'feeds';
export const feedsQueue = new Queue(feedsQueueName, { connection: redis }); export const feedsQueue = new Queue(feedsQueueName, { connection: redis });
export const scheduleQueueName = 'schedules'; export const scheduleQueueName = 'schedules';
export const scheduleQueue = new Queue(scheduleQueueName, { connection: redis }); 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 guildBackupQueueName = 'guild-backups';
export const guildBackupQueue = new Queue(guildBackupQueueName, { connection: redis }); export const guildBackupQueue = new Queue(guildBackupQueueName, { connection: redis });
export const suggestionsQueueName = 'suggestions'; export const suggestionsQueueName = 'suggestions';

View File

@@ -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);
}
}

View File

@@ -0,0 +1,13 @@
import { Skeleton } from '@/components/ui/skeleton';
export default function MessagesLoading() {
return (
<div className="space-y-6">
<div className="space-y-2">
<Skeleton className="h-8 w-48" />
<Skeleton className="h-4 w-80" />
</div>
<Skeleton className="h-96 rounded-lg" />
</div>
);
}

View File

@@ -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 (
<div className="space-y-6">
<div>
<h1 className="text-2xl font-semibold">{t(locale, 'modules.messages.label')}</h1>
<p className="text-sm text-muted-foreground">{t(locale, 'modules.messages.description')}</p>
</div>
<MessagesManager guildId={guildId} />
</div>
);
}

View File

@@ -11,6 +11,7 @@ import {
Gift, Gift,
Hash, Hash,
LayoutGrid, LayoutGrid,
MessageSquare,
MessageSquareQuote, MessageSquareQuote,
MessagesSquare, MessagesSquare,
PartyPopper, PartyPopper,
@@ -63,7 +64,8 @@ const MODULE_ICONS: Record<DashboardModuleId, LucideIcon> = {
stats: BarChart3, stats: BarChart3,
feeds: Rss, feeds: Rss,
scheduler: CalendarClock, scheduler: CalendarClock,
guildbackup: Archive guildbackup: Archive,
messages: MessageSquare
}; };
interface SidebarNavProps { interface SidebarNavProps {

View File

@@ -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<DashboardMessageMode>('EMBED');
const [embed, setEmbed] = useState<WelcomeEmbed | null>(emptyEmbed());
const [components, setComponents] = useState<MessageComponentsV2 | null>(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 (
<FieldAnchor id="field-messages-send">
<Card>
<CardHeader>
<CardTitle>{t('modulePages.messages.sendTitle')}</CardTitle>
<CardDescription>{t('modulePages.messages.sendDescription')}</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
<div className="space-y-2">
<Label>{t('modulePages.messages.channelId')}</Label>
<DiscordChannelSelect guildId={guildId} value={channelId} onChange={setChannelId} />
</div>
<div className="space-y-2">
<Label>{t('modulePages.messages.mode')}</Label>
<div className="flex flex-wrap gap-2">
{(['EMBED', 'COMPONENTS_V2'] as DashboardMessageMode[]).map((entry) => (
<Button
key={entry}
type="button"
variant={mode === entry ? 'default' : 'outline'}
size="sm"
className={cn(mode !== entry && 'text-muted-foreground')}
onClick={() => setMode(entry)}
>
{t(`modulePages.messages.modes.${entry}`)}
</Button>
))}
</div>
</div>
{mode === 'EMBED' ? (
<DiscordEmbedBuilder
value={embed}
onChange={setEmbed}
placeholderPreset="scheduler"
showClearHint={false}
/>
) : (
<DiscordComponentsV2Builder
guildId={guildId}
value={components}
onChange={setComponents}
placeholderPreset="scheduler"
showClearHint={false}
/>
)}
<Button type="button" onClick={handleSend} disabled={sending}>
<Send className="size-4" />
{sending ? t('modulePages.messages.sending') : t('modulePages.messages.send')}
</Button>
</CardContent>
</Card>
</FieldAnchor>
);
}

View File

@@ -1,6 +1,12 @@
'use client'; '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 { Plus, Trash2 } from 'lucide-react';
import { useState } from 'react'; import { useState } from 'react';
import { toast } from 'sonner'; import { toast } from 'sonner';
@@ -9,17 +15,27 @@ import { useTranslations } from '@/components/locale-provider';
import { Button } from '@/components/ui/button'; import { Button } from '@/components/ui/button';
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'; import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
import { DiscordChannelSelect } from '@/components/ui/discord-channel-select'; 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 { DiscordEmbedBuilder, normalizeEmbed } from '@/components/ui/discord-embed-builder';
import { DiscordRoleSelect } from '@/components/ui/discord-role-select'; import { DiscordRoleSelect } from '@/components/ui/discord-role-select';
import { Input } from '@/components/ui/input'; import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label'; import { Label } from '@/components/ui/label';
import { PlaceholderHelp } from '@/components/ui/placeholder-help'; import { PlaceholderHelp } from '@/components/ui/placeholder-help';
import { Textarea } from '@/components/ui/textarea'; import { Textarea } from '@/components/ui/textarea';
import { cn } from '@/lib/utils';
type ScheduleContentMode = 'text' | 'embed' | 'components_v2';
const EMPTY_DRAFT = { const EMPTY_DRAFT = {
channelId: '', channelId: '',
contentMode: 'text' as ScheduleContentMode,
content: '', content: '',
embed: null as WelcomeEmbed | null, embed: null as WelcomeEmbed | null,
components: null as MessageComponentsV2 | null,
rolePingId: '', rolePingId: '',
cron: '', cron: '',
runAt: '' runAt: ''
@@ -30,7 +46,7 @@ function formatTiming(schedule: ScheduledMessageDashboard, t: (key: string) => s
return `${t('modulePages.scheduler.cron')}: ${schedule.cron}`; return `${t('modulePages.scheduler.cron')}: ${schedule.cron}`;
} }
if (schedule.runAt) { if (schedule.runAt) {
return `${t('modulePages.scheduler.runAt')}: ${new Date(schedule.runAt).toLocaleString(undefined, { return `${new Date(schedule.runAt).toLocaleString(undefined, {
dateStyle: 'medium', dateStyle: 'medium',
timeStyle: 'short' timeStyle: 'short'
})}`; })}`;
@@ -42,6 +58,9 @@ function schedulePreview(schedule: ScheduledMessageDashboard, t: (key: string) =
if (schedule.content?.trim()) { if (schedule.content?.trim()) {
return schedule.content; return schedule.content;
} }
if (componentsV2HasContent(schedule.components)) {
return t('modulePages.scheduler.componentsPreview');
}
if (schedule.embed?.title?.trim()) { if (schedule.embed?.title?.trim()) {
return schedule.embed.title; return schedule.embed.title;
} }
@@ -69,13 +88,21 @@ export function SchedulerManager({ guildId, initialSchedules }: SchedulerManager
const [creating, setCreating] = useState(false); const [creating, setCreating] = useState(false);
async function handleCreate() { 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 = 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)) { if (!draft.channelId.trim() || !hasContent || (!draft.cron.trim() && !draft.runAt)) {
toast.error(t('modulePages.scheduler.formIncomplete')); toast.error(t('modulePages.scheduler.formIncomplete'));
return; return;
} }
setCreating(true); setCreating(true);
try { try {
const response = await fetch(`/api/guilds/${guildId}/scheduler`, { const response = await fetch(`/api/guilds/${guildId}/scheduler`, {
@@ -83,8 +110,9 @@ export function SchedulerManager({ guildId, initialSchedules }: SchedulerManager
headers: { 'Content-Type': 'application/json' }, headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ body: JSON.stringify({
channelId: draft.channelId.trim(), channelId: draft.channelId.trim(),
content: draft.content.trim() || null, content,
embed, embed,
components,
rolePingId: draft.rolePingId.trim() || undefined, rolePingId: draft.rolePingId.trim() || undefined,
cron: draft.cron.trim() || null, cron: draft.cron.trim() || null,
runAt: draft.cron.trim() ? null : draft.runAt ? new Date(draft.runAt).toISOString() : null runAt: draft.cron.trim() ? null : draft.runAt ? new Date(draft.runAt).toISOString() : null
@@ -169,6 +197,26 @@ export function SchedulerManager({ guildId, initialSchedules }: SchedulerManager
/> />
</div> </div>
</div> </div>
<div className="space-y-2">
<Label>{t('modulePages.scheduler.contentMode')}</Label>
<div className="flex flex-wrap gap-2">
{(['text', 'embed', 'components_v2'] as ScheduleContentMode[]).map((mode) => (
<Button
key={mode}
type="button"
variant={draft.contentMode === mode ? 'default' : 'outline'}
size="sm"
className={cn(draft.contentMode !== mode && 'text-muted-foreground')}
onClick={() => setDraft((prev) => ({ ...prev, contentMode: mode }))}
>
{t(`modulePages.scheduler.contentModes.${mode}`)}
</Button>
))}
</div>
</div>
{draft.contentMode === 'text' ? (
<div className="space-y-2"> <div className="space-y-2">
<Label>{t('modulePages.scheduler.content')}</Label> <Label>{t('modulePages.scheduler.content')}</Label>
<Textarea <Textarea
@@ -178,6 +226,9 @@ export function SchedulerManager({ guildId, initialSchedules }: SchedulerManager
/> />
<PlaceholderHelp preset="scheduler" /> <PlaceholderHelp preset="scheduler" />
</div> </div>
) : null}
{draft.contentMode === 'embed' ? (
<div className="space-y-2"> <div className="space-y-2">
<Label>{t('modulePages.scheduler.embed')}</Label> <Label>{t('modulePages.scheduler.embed')}</Label>
<DiscordEmbedBuilder <DiscordEmbedBuilder
@@ -186,6 +237,20 @@ export function SchedulerManager({ guildId, initialSchedules }: SchedulerManager
placeholderPreset="scheduler" placeholderPreset="scheduler"
/> />
</div> </div>
) : null}
{draft.contentMode === 'components_v2' ? (
<div className="space-y-2">
<Label>{t('modulePages.scheduler.components')}</Label>
<DiscordComponentsV2Builder
guildId={guildId}
value={draft.components ?? emptyComponentsV2()}
onChange={(components) => setDraft((prev) => ({ ...prev, components }))}
placeholderPreset="scheduler"
/>
</div>
) : null}
<p className="text-xs text-muted-foreground">{t('modulePages.scheduler.cronHint')}</p> <p className="text-xs text-muted-foreground">{t('modulePages.scheduler.cronHint')}</p>
<Button type="button" onClick={handleCreate} disabled={creating}> <Button type="button" onClick={handleCreate} disabled={creating}>
<Plus className="size-4" /> <Plus className="size-4" />

View File

@@ -1,6 +1,13 @@
'use client'; 'use client';
import { embedHasContent, type TagDashboard, type TagResponseType, type WelcomeEmbed } from '@nexumi/shared'; import {
componentsV2HasContent,
embedHasContent,
type MessageComponentsV2,
type TagDashboard,
type TagResponseType,
type WelcomeEmbed
} from '@nexumi/shared';
import { Plus, Trash2 } from 'lucide-react'; import { Plus, Trash2 } from 'lucide-react';
import { useState } from 'react'; import { useState } from 'react';
import { toast } from 'sonner'; import { toast } from 'sonner';
@@ -9,6 +16,10 @@ import { useTranslations } from '@/components/locale-provider';
import { Button } from '@/components/ui/button'; import { Button } from '@/components/ui/button';
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'; import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
import { DiscordChannelMultiSelect } from '@/components/ui/discord-channel-select'; import { DiscordChannelMultiSelect } from '@/components/ui/discord-channel-select';
import {
DiscordComponentsV2Builder,
normalizeComponentsV2
} from '@/components/ui/discord-components-v2-builder';
import { DiscordEmbedBuilder, normalizeEmbed } from '@/components/ui/discord-embed-builder'; import { DiscordEmbedBuilder, normalizeEmbed } from '@/components/ui/discord-embed-builder';
import { DiscordRoleMultiSelect } from '@/components/ui/discord-role-select'; import { DiscordRoleMultiSelect } from '@/components/ui/discord-role-select';
import { Input } from '@/components/ui/input'; import { Input } from '@/components/ui/input';
@@ -17,7 +28,7 @@ import { PlaceholderHelp } from '@/components/ui/placeholder-help';
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'; import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
import { Textarea } from '@/components/ui/textarea'; import { Textarea } from '@/components/ui/textarea';
const RESPONSE_TYPES: TagResponseType[] = ['TEXT', 'EMBED']; const RESPONSE_TYPES: TagResponseType[] = ['TEXT', 'EMBED', 'COMPONENTS_V2'];
interface LocalTag extends TagDashboard { interface LocalTag extends TagDashboard {
clientKey: string; clientKey: string;
@@ -28,6 +39,7 @@ const EMPTY_DRAFT = {
name: '', name: '',
content: '', content: '',
embed: null as WelcomeEmbed | null, embed: null as WelcomeEmbed | null,
components: null as MessageComponentsV2 | null,
responseType: 'TEXT' as TagResponseType, responseType: 'TEXT' as TagResponseType,
triggerWord: '', triggerWord: '',
allowedRoleIds: [] as string[], allowedRoleIds: [] as string[],
@@ -45,20 +57,35 @@ export function TagsManager({ guildId, initialTags }: TagsManagerProps) {
initialTags.map((tag, index) => ({ initialTags.map((tag, index) => ({
...tag, ...tag,
embed: tag.embed ?? null, embed: tag.embed ?? null,
components: tag.components ?? null,
clientKey: tag.id ?? `existing-${index}` clientKey: tag.id ?? `existing-${index}`
})) }))
); );
const [draft, setDraft] = useState(EMPTY_DRAFT); const [draft, setDraft] = useState(EMPTY_DRAFT);
const [creating, setCreating] = useState(false); const [creating, setCreating] = useState(false);
function validateTagPayload(responseType: TagResponseType, embed: WelcomeEmbed | null, components: MessageComponentsV2 | null) {
const normalizedEmbed = responseType === 'EMBED' ? normalizeEmbed(embed) : null;
const normalizedComponents = responseType === 'COMPONENTS_V2' ? normalizeComponentsV2(components) : null;
if (responseType === 'EMBED' && !embedHasContent(normalizedEmbed) && normalizedEmbed?.color === undefined) {
toast.error(t('modulePages.tags.embedRequired'));
return null;
}
if (responseType === 'COMPONENTS_V2' && !componentsV2HasContent(normalizedComponents)) {
toast.error(t('modulePages.tags.componentsRequired'));
return null;
}
return { embed: normalizedEmbed, components: normalizedComponents };
}
async function handleCreate() { async function handleCreate() {
if (!draft.name.trim()) { if (!draft.name.trim()) {
toast.error(t('modulePages.tags.nameRequired')); toast.error(t('modulePages.tags.nameRequired'));
return; return;
} }
const embed = draft.responseType === 'EMBED' ? normalizeEmbed(draft.embed) : null; const validated = validateTagPayload(draft.responseType, draft.embed, draft.components);
if (draft.responseType === 'EMBED' && !embedHasContent(embed) && embed?.color === undefined) { if (!validated) {
toast.error(t('modulePages.tags.embedRequired'));
return; return;
} }
setCreating(true); setCreating(true);
@@ -68,8 +95,9 @@ export function TagsManager({ guildId, initialTags }: TagsManagerProps) {
headers: { 'Content-Type': 'application/json' }, headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ body: JSON.stringify({
name: draft.name.trim(), name: draft.name.trim(),
content: draft.content.trim() || null, content: draft.responseType === 'TEXT' ? draft.content.trim() || null : null,
embed, embed: validated.embed,
components: validated.components,
responseType: draft.responseType, responseType: draft.responseType,
triggerWord: draft.triggerWord.trim() || null, triggerWord: draft.triggerWord.trim() || null,
allowedRoleIds: draft.allowedRoleIds, allowedRoleIds: draft.allowedRoleIds,
@@ -98,9 +126,8 @@ export function TagsManager({ guildId, initialTags }: TagsManagerProps) {
} }
async function handleSave(tag: LocalTag) { async function handleSave(tag: LocalTag) {
const embed = tag.responseType === 'EMBED' ? normalizeEmbed(tag.embed ?? null) : null; const validated = validateTagPayload(tag.responseType, tag.embed ?? null, tag.components ?? null);
if (tag.responseType === 'EMBED' && !embedHasContent(embed) && embed?.color === undefined) { if (!validated) {
toast.error(t('modulePages.tags.embedRequired'));
return; return;
} }
setTags((prev) => prev.map((entry) => (entry.clientKey === tag.clientKey ? { ...entry, saving: true } : entry))); setTags((prev) => prev.map((entry) => (entry.clientKey === tag.clientKey ? { ...entry, saving: true } : entry)));
@@ -110,8 +137,9 @@ export function TagsManager({ guildId, initialTags }: TagsManagerProps) {
headers: { 'Content-Type': 'application/json' }, headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ body: JSON.stringify({
name: tag.name, name: tag.name,
content: tag.content, content: tag.responseType === 'TEXT' ? tag.content : null,
embed, embed: validated.embed,
components: validated.components,
responseType: tag.responseType, responseType: tag.responseType,
triggerWord: tag.triggerWord, triggerWord: tag.triggerWord,
allowedRoleIds: tag.allowedRoleIds, allowedRoleIds: tag.allowedRoleIds,
@@ -155,6 +183,45 @@ export function TagsManager({ guildId, initialTags }: TagsManagerProps) {
} }
} }
function renderResponseEditor(
responseType: TagResponseType,
embed: WelcomeEmbed | null,
components: MessageComponentsV2 | null,
content: string,
onEmbedChange: (embed: WelcomeEmbed | null) => void,
onComponentsChange: (components: MessageComponentsV2 | null) => void,
onContentChange: (content: string) => void
) {
if (responseType === 'TEXT') {
return (
<div className="space-y-2">
<Label>{t('modulePages.tags.content')}</Label>
<Textarea rows={3} value={content} onChange={(event) => onContentChange(event.target.value)} />
<PlaceholderHelp preset="tags" />
</div>
);
}
if (responseType === 'EMBED') {
return (
<div className="space-y-2">
<Label>{t('modulePages.tags.embed')}</Label>
<DiscordEmbedBuilder value={embed} onChange={onEmbedChange} placeholderPreset="tags" />
</div>
);
}
return (
<div className="space-y-2">
<Label>{t('modulePages.tags.components')}</Label>
<DiscordComponentsV2Builder
guildId={guildId}
value={components}
onChange={onComponentsChange}
placeholderPreset="tags"
/>
</div>
);
}
return ( return (
<div className="space-y-6"> <div className="space-y-6">
<FieldAnchor id="field-tags-create"> <FieldAnchor id="field-tags-create">
@@ -189,21 +256,14 @@ export function TagsManager({ guildId, initialTags }: TagsManagerProps) {
<Input value={draft.triggerWord} onChange={(event) => setDraft((prev) => ({ ...prev, triggerWord: event.target.value }))} placeholder={t('common.optional')} /> <Input value={draft.triggerWord} onChange={(event) => setDraft((prev) => ({ ...prev, triggerWord: event.target.value }))} placeholder={t('common.optional')} />
</div> </div>
</div> </div>
{draft.responseType === 'TEXT' ? ( {renderResponseEditor(
<div className="space-y-2"> draft.responseType,
<Label>{t('modulePages.tags.content')}</Label> draft.embed,
<Textarea rows={3} value={draft.content} onChange={(event) => setDraft((prev) => ({ ...prev, content: event.target.value }))} /> draft.components,
<PlaceholderHelp preset="tags" /> draft.content,
</div> (embed) => setDraft((prev) => ({ ...prev, embed })),
) : ( (components) => setDraft((prev) => ({ ...prev, components })),
<div className="space-y-2"> (content) => setDraft((prev) => ({ ...prev, content }))
<Label>{t('modulePages.tags.embed')}</Label>
<DiscordEmbedBuilder
value={draft.embed}
onChange={(embed) => setDraft((prev) => ({ ...prev, embed }))}
placeholderPreset="tags"
/>
</div>
)} )}
<div className="grid gap-4 sm:grid-cols-2"> <div className="grid gap-4 sm:grid-cols-2">
<div className="space-y-2"> <div className="space-y-2">
@@ -266,25 +326,23 @@ export function TagsManager({ guildId, initialTags }: TagsManagerProps) {
<Input value={tag.triggerWord ?? ''} onChange={(event) => setTags((prev) => prev.map((entry) => (entry.clientKey === tag.clientKey ? { ...entry, triggerWord: event.target.value || null } : entry)))} /> <Input value={tag.triggerWord ?? ''} onChange={(event) => setTags((prev) => prev.map((entry) => (entry.clientKey === tag.clientKey ? { ...entry, triggerWord: event.target.value || null } : entry)))} />
</div> </div>
</div> </div>
{tag.responseType === 'TEXT' ? ( {renderResponseEditor(
<div className="space-y-2"> tag.responseType,
<Label>{t('modulePages.tags.content')}</Label> tag.embed ?? null,
<Textarea rows={3} value={tag.content ?? ''} onChange={(event) => setTags((prev) => prev.map((entry) => (entry.clientKey === tag.clientKey ? { ...entry, content: event.target.value } : entry)))} /> tag.components ?? null,
<PlaceholderHelp preset="tags" /> tag.content ?? '',
</div> (embed) =>
) : (
<div className="space-y-2">
<Label>{t('modulePages.tags.embed')}</Label>
<DiscordEmbedBuilder
value={tag.embed ?? null}
onChange={(embed) =>
setTags((prev) => setTags((prev) =>
prev.map((entry) => (entry.clientKey === tag.clientKey ? { ...entry, embed } : entry)) prev.map((entry) => (entry.clientKey === tag.clientKey ? { ...entry, embed } : entry))
),
(components) =>
setTags((prev) =>
prev.map((entry) => (entry.clientKey === tag.clientKey ? { ...entry, components } : entry))
),
(content) =>
setTags((prev) =>
prev.map((entry) => (entry.clientKey === tag.clientKey ? { ...entry, content } : entry))
) )
}
placeholderPreset="tags"
/>
</div>
)} )}
<div className="grid gap-3 sm:grid-cols-2"> <div className="grid gap-3 sm:grid-cols-2">
<div className="space-y-2"> <div className="space-y-2">

View File

@@ -1,10 +1,15 @@
'use client'; 'use client';
import type { WelcomeConfigDashboard, WelcomeEmbed } from '@nexumi/shared'; import type { MessageComponentsV2, WelcomeConfigDashboard, WelcomeEmbed } from '@nexumi/shared';
import { FieldAnchor } from '@/components/layout/field-anchor'; import { FieldAnchor } from '@/components/layout/field-anchor';
import { useTranslations } from '@/components/locale-provider'; import { useTranslations } from '@/components/locale-provider';
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'; import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
import { DiscordChannelSelect } from '@/components/ui/discord-channel-select'; import { DiscordChannelSelect } from '@/components/ui/discord-channel-select';
import {
DiscordComponentsV2Builder,
componentsV2FromUnknown,
normalizeComponentsV2
} from '@/components/ui/discord-components-v2-builder';
import { import {
DiscordEmbedBuilder, DiscordEmbedBuilder,
embedFromUnknown, embedFromUnknown,
@@ -31,16 +36,24 @@ const WELCOME_PREVIEW_VARS = {
memberCount: '128' memberCount: '128'
}; };
interface LocalWelcomeValue extends Omit<WelcomeConfigDashboard, 'welcomeEmbed' | 'leaveEmbed'> { interface LocalWelcomeValue extends Omit<
WelcomeConfigDashboard,
'welcomeEmbed' | 'leaveEmbed' | 'welcomeComponents' | 'leaveComponents'
> {
welcomeEmbed: WelcomeEmbed | null; welcomeEmbed: WelcomeEmbed | null;
leaveEmbed: WelcomeEmbed | null; leaveEmbed: WelcomeEmbed | null;
welcomeComponents: MessageComponentsV2 | null;
leaveComponents: MessageComponentsV2 | null;
} }
function toLocal(config: WelcomeConfigDashboard): LocalWelcomeValue { function toLocal(config: WelcomeConfigDashboard): LocalWelcomeValue {
return { return {
...config, ...config,
welcomeEmbed: embedFromUnknown(config.welcomeEmbed), welcomeEmbed: embedFromUnknown(config.welcomeEmbed),
leaveEmbed: embedFromUnknown(config.leaveEmbed) leaveEmbed: embedFromUnknown(config.leaveEmbed),
welcomeComponents: componentsV2FromUnknown(config.welcomeComponents),
leaveComponents: componentsV2FromUnknown(config.leaveComponents),
leaveType: config.leaveType ?? 'TEXT'
}; };
} }
@@ -48,7 +61,9 @@ async function saveWelcome(guildId: string, value: LocalWelcomeValue): Promise<S
const payload: WelcomeConfigDashboard = { const payload: WelcomeConfigDashboard = {
...value, ...value,
welcomeEmbed: normalizeEmbed(value.welcomeEmbed), welcomeEmbed: normalizeEmbed(value.welcomeEmbed),
leaveEmbed: normalizeEmbed(value.leaveEmbed) leaveEmbed: normalizeEmbed(value.leaveEmbed),
welcomeComponents: normalizeComponentsV2(value.welcomeComponents),
leaveComponents: normalizeComponentsV2(value.leaveComponents)
}; };
try { try {
@@ -121,11 +136,13 @@ export function WelcomeForm({ guildId, initialValue }: WelcomeFormProps) {
<SelectItem value="TEXT">{t('modulePages.welcome.type.TEXT')}</SelectItem> <SelectItem value="TEXT">{t('modulePages.welcome.type.TEXT')}</SelectItem>
<SelectItem value="EMBED">{t('modulePages.welcome.type.EMBED')}</SelectItem> <SelectItem value="EMBED">{t('modulePages.welcome.type.EMBED')}</SelectItem>
<SelectItem value="IMAGE">{t('modulePages.welcome.type.IMAGE')}</SelectItem> <SelectItem value="IMAGE">{t('modulePages.welcome.type.IMAGE')}</SelectItem>
<SelectItem value="COMPONENTS_V2">{t('modulePages.welcome.type.COMPONENTS_V2')}</SelectItem>
</SelectContent> </SelectContent>
</Select> </Select>
</div> </div>
</FieldAnchor> </FieldAnchor>
</div> </div>
{value.welcomeType === 'TEXT' ? (
<FieldAnchor id="field-welcome-content"> <FieldAnchor id="field-welcome-content">
<div className="space-y-2"> <div className="space-y-2">
<Label>{t('modulePages.welcome.welcomeContent')}</Label> <Label>{t('modulePages.welcome.welcomeContent')}</Label>
@@ -137,6 +154,8 @@ export function WelcomeForm({ guildId, initialValue }: WelcomeFormProps) {
<PlaceholderHelp preset="welcome" /> <PlaceholderHelp preset="welcome" />
</div> </div>
</FieldAnchor> </FieldAnchor>
) : null}
{value.welcomeType === 'EMBED' ? (
<FieldAnchor id="field-welcome-embed"> <FieldAnchor id="field-welcome-embed">
<div className="space-y-2"> <div className="space-y-2">
<Label>{t('modulePages.welcome.welcomeEmbed')}</Label> <Label>{t('modulePages.welcome.welcomeEmbed')}</Label>
@@ -148,6 +167,21 @@ export function WelcomeForm({ guildId, initialValue }: WelcomeFormProps) {
/> />
</div> </div>
</FieldAnchor> </FieldAnchor>
) : null}
{value.welcomeType === 'COMPONENTS_V2' ? (
<FieldAnchor id="field-welcome-components">
<div className="space-y-2">
<Label>{t('modulePages.welcome.welcomeComponents')}</Label>
<DiscordComponentsV2Builder
guildId={guildId}
value={value.welcomeComponents}
onChange={(welcomeComponents) => setValue((prev) => ({ ...prev, welcomeComponents }))}
previewVars={WELCOME_PREVIEW_VARS}
placeholderPreset="welcome"
/>
</div>
</FieldAnchor>
) : null}
<FieldAnchor id="field-welcome-dm"> <FieldAnchor id="field-welcome-dm">
<div className="space-y-2"> <div className="space-y-2">
<div className="flex items-center justify-between rounded-md border border-border p-4"> <div className="flex items-center justify-between rounded-md border border-border p-4">
@@ -185,6 +219,8 @@ export function WelcomeForm({ guildId, initialValue }: WelcomeFormProps) {
/> />
</div> </div>
</FieldAnchor> </FieldAnchor>
{value.welcomeType === 'IMAGE' ? (
<>
<div className="grid gap-4 sm:grid-cols-2"> <div className="grid gap-4 sm:grid-cols-2">
<FieldAnchor id="field-welcome-image-title"> <FieldAnchor id="field-welcome-image-title">
<div className="space-y-2"> <div className="space-y-2">
@@ -206,6 +242,8 @@ export function WelcomeForm({ guildId, initialValue }: WelcomeFormProps) {
</FieldAnchor> </FieldAnchor>
</div> </div>
<PlaceholderHelp preset="welcome" /> <PlaceholderHelp preset="welcome" />
</>
) : null}
</CardContent> </CardContent>
</Card> </Card>
@@ -234,6 +272,27 @@ export function WelcomeForm({ guildId, initialValue }: WelcomeFormProps) {
/> />
</div> </div>
</FieldAnchor> </FieldAnchor>
<FieldAnchor id="field-leave-type">
<div className="space-y-2">
<Label>{t('modulePages.welcome.leaveType')}</Label>
<Select
value={value.leaveType}
onValueChange={(leaveType) =>
setValue((prev) => ({ ...prev, leaveType: leaveType as WelcomeConfigDashboard['leaveType'] }))
}
>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="TEXT">{t('modulePages.welcome.leaveTypes.TEXT')}</SelectItem>
<SelectItem value="EMBED">{t('modulePages.welcome.leaveTypes.EMBED')}</SelectItem>
<SelectItem value="COMPONENTS_V2">{t('modulePages.welcome.leaveTypes.COMPONENTS_V2')}</SelectItem>
</SelectContent>
</Select>
</div>
</FieldAnchor>
{value.leaveType === 'TEXT' ? (
<FieldAnchor id="field-leave-content"> <FieldAnchor id="field-leave-content">
<div className="space-y-2"> <div className="space-y-2">
<Label>{t('modulePages.welcome.leaveContent')}</Label> <Label>{t('modulePages.welcome.leaveContent')}</Label>
@@ -245,6 +304,8 @@ export function WelcomeForm({ guildId, initialValue }: WelcomeFormProps) {
<PlaceholderHelp preset="welcome" /> <PlaceholderHelp preset="welcome" />
</div> </div>
</FieldAnchor> </FieldAnchor>
) : null}
{value.leaveType === 'EMBED' ? (
<FieldAnchor id="field-leave-embed"> <FieldAnchor id="field-leave-embed">
<div className="space-y-2"> <div className="space-y-2">
<Label>{t('modulePages.welcome.leaveEmbed')}</Label> <Label>{t('modulePages.welcome.leaveEmbed')}</Label>
@@ -258,6 +319,21 @@ export function WelcomeForm({ guildId, initialValue }: WelcomeFormProps) {
/> />
</div> </div>
</FieldAnchor> </FieldAnchor>
) : null}
{value.leaveType === 'COMPONENTS_V2' ? (
<FieldAnchor id="field-leave-components">
<div className="space-y-2">
<Label>{t('modulePages.welcome.leaveComponents')}</Label>
<DiscordComponentsV2Builder
guildId={guildId}
value={value.leaveComponents}
onChange={(leaveComponents) => setValue((prev) => ({ ...prev, leaveComponents }))}
previewVars={WELCOME_PREVIEW_VARS}
placeholderPreset="welcome"
/>
</div>
</FieldAnchor>
) : null}
</CardContent> </CardContent>
</Card> </Card>
</div> </div>

File diff suppressed because it is too large Load Diff

View File

@@ -242,6 +242,14 @@ export const SETTINGS_SEARCH_FIELDS: SearchIndexEntry[] = [
href: 'welcome', href: 'welcome',
hash: 'field-welcome-embed' 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', id: 'setting-welcome-dm',
kind: 'setting', kind: 'setting',
@@ -292,6 +300,13 @@ export const SETTINGS_SEARCH_FIELDS: SearchIndexEntry[] = [
href: 'welcome', href: 'welcome',
hash: 'field-leave-channel' hash: 'field-leave-channel'
}, },
{
id: 'setting-leave-type',
kind: 'setting',
labelKey: 'modulePages.welcome.leaveType',
href: 'welcome',
hash: 'field-leave-type'
},
{ {
id: 'setting-leave-content', id: 'setting-leave-content',
kind: 'setting', kind: 'setting',
@@ -306,6 +321,14 @@ export const SETTINGS_SEARCH_FIELDS: SearchIndexEntry[] = [
href: 'welcome', href: 'welcome',
hash: 'field-leave-embed' 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 // Verification
{ {
id: 'setting-verify-enabled', id: 'setting-verify-enabled',
@@ -825,6 +848,15 @@ export const SETTINGS_SEARCH_FIELDS: SearchIndexEntry[] = [
href: 'scheduler', href: 'scheduler',
hash: 'field-scheduler-create' 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 // Backup
{ {
id: 'setting-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 { import {
MessageComponentsV2Schema,
WelcomeEmbedSchema, WelcomeEmbedSchema,
type MessageComponentsV2,
type ScheduledMessageDashboard, type ScheduledMessageDashboard,
type ScheduledMessageDashboardCreate, type ScheduledMessageDashboardCreate,
type WelcomeEmbed type WelcomeEmbed
@@ -19,6 +21,11 @@ function parseEmbed(raw: unknown): WelcomeEmbed | null {
return parsed.success ? parsed.data : 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 { function toDashboard(schedule: ScheduledMessage): ScheduledMessageDashboard {
return { return {
id: schedule.id, id: schedule.id,
@@ -26,6 +33,7 @@ function toDashboard(schedule: ScheduledMessage): ScheduledMessageDashboard {
creatorId: schedule.creatorId, creatorId: schedule.creatorId,
content: schedule.content, content: schedule.content,
embed: parseEmbed(schedule.embed), embed: parseEmbed(schedule.embed),
components: parseComponents(schedule.components),
rolePingId: schedule.rolePingId, rolePingId: schedule.rolePingId,
cron: schedule.cron, cron: schedule.cron,
runAt: schedule.runAt?.toISOString() ?? null, runAt: schedule.runAt?.toISOString() ?? null,
@@ -84,6 +92,10 @@ export async function createScheduledMessageDashboard(
input.embed === undefined || input.embed === null input.embed === undefined || input.embed === null
? Prisma.JsonNull ? Prisma.JsonNull
: (input.embed as Prisma.InputJsonValue), : (input.embed as Prisma.InputJsonValue),
components:
input.components === undefined || input.components === null
? Prisma.JsonNull
: (input.components as Prisma.InputJsonValue),
rolePingId: input.rolePingId || null, rolePingId: input.rolePingId || null,
cron, cron,
runAt runAt

View File

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

View File

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

View File

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

View File

@@ -18,15 +18,18 @@ export const giveawayQueueName = 'giveaways';
export const scheduleQueueName = 'schedules'; export const scheduleQueueName = 'schedules';
export const guildBackupQueueName = 'guild-backups'; export const guildBackupQueueName = 'guild-backups';
export const suggestionsQueueName = 'suggestions'; export const suggestionsQueueName = 'suggestions';
export const messagesQueueName = 'messages';
const globalForQueues = globalThis as unknown as { const globalForQueues = globalThis as unknown as {
__nexumiGiveawayQueue?: Queue; __nexumiGiveawayQueue?: Queue;
__nexumiScheduleQueue?: Queue; __nexumiScheduleQueue?: Queue;
__nexumiGuildBackupQueue?: Queue; __nexumiGuildBackupQueue?: Queue;
__nexumiSuggestionsQueue?: Queue; __nexumiSuggestionsQueue?: Queue;
__nexumiMessagesQueue?: Queue;
__nexumiGiveawayQueueEvents?: QueueEvents; __nexumiGiveawayQueueEvents?: QueueEvents;
__nexumiGuildBackupQueueEvents?: QueueEvents; __nexumiGuildBackupQueueEvents?: QueueEvents;
__nexumiSuggestionsQueueEvents?: QueueEvents; __nexumiSuggestionsQueueEvents?: QueueEvents;
__nexumiMessagesQueueEvents?: QueueEvents;
}; };
function queueConnection() { function queueConnection() {
@@ -66,6 +69,13 @@ export function getSuggestionsQueue(): Queue {
return globalForQueues.__nexumiSuggestionsQueue; 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 * 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 * 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; return globalForQueues.__nexumiSuggestionsQueueEvents;
} }
export function getMessagesQueueEvents(): QueueEvents {
globalForQueues.__nexumiMessagesQueueEvents ??= new QueueEvents(messagesQueueName, {
connection: queueEventsConnection()
});
return globalForQueues.__nexumiMessagesQueueEvents;
}
const DEFAULT_WAIT_TIMEOUT_MS = 15_000; const DEFAULT_WAIT_TIMEOUT_MS = 15_000;
/** /**

View File

@@ -50,6 +50,78 @@
"timestampPreview": "gerade eben", "timestampPreview": "gerade eben",
"mediaHint": "Bild-URLs müssen mit http(s) beginnen. Für Welcome kannst du {user.avatar} und {server.icon} nutzen." "mediaHint": "Bild-URLs müssen mit http(s) beginnen. Für Welcome kannst du {user.avatar} und {server.icon} nutzen."
}, },
"componentsV2Builder": {
"blocks": "Layout-Blöcke",
"addBlock": "Block hinzufügen…",
"addNestedBlock": "Verschachtelten Block hinzufügen…",
"empty": "Noch keine Blöcke — füge einen Container oder Textblock hinzu.",
"preview": "Vorschau",
"previewEmptyText": "Textinhalt…",
"previewInvalidUrl": "Ungültige Bild-URL",
"clear": "Layout leeren",
"clearHint": "Ungültige Blöcke werden beim Speichern verworfen.",
"actionsTitle": "Button- & Select-Aktionen",
"textPlaceholder": "Text (Discord-Formatierung)",
"divider": "Trennlinie anzeigen",
"spacing": "Abstand",
"spacingSmall": "Klein",
"spacingLarge": "Groß",
"accentColor": "Akzentfarbe (Dezimalwert)",
"spoiler": "Spoiler-Container",
"sectionText": "Abschnittstext",
"accessoryType": "Zubehör",
"accessoryThumbnail": "Thumbnail",
"accessoryButton": "Button",
"urlPlaceholder": "https://… oder {user.avatar}",
"addMediaItem": "Bild hinzufügen",
"addButton": "Button hinzufügen",
"addSelect": "Select hinzufügen…",
"buttonLabel": "Button-Label",
"linkUrl": "Link-URL (https://…)",
"actionId": "Action-ID",
"centralActionId": "Zentrale Action-ID (optional)",
"optionLabel": "Options-Label",
"optionValue": "Options-Wert",
"optionActionId": "Action-ID pro Option (optional)",
"addOption": "Option hinzufügen",
"removeOption": "Option entfernen",
"selectPlaceholder": "Select-Platzhalter",
"replyContent": "Antwortnachricht",
"roleAction": "Zielrolle",
"defaultButtonLabel": "Button",
"blockTypes": {
"container": "Container",
"text_display": "Text",
"separator": "Trenner",
"section": "Abschnitt",
"media_gallery": "Medien-Galerie",
"action_row": "Action Row",
"button": "Button"
},
"buttonStyles": {
"Primary": "Primär",
"Secondary": "Sekundär",
"Success": "Erfolg",
"Danger": "Gefahr",
"Link": "Link"
},
"selectTypes": {
"string_select": "String-Select",
"user_select": "User-Select",
"role_select": "Rollen-Select",
"channel_select": "Kanal-Select",
"mentionable_select": "Mentionable-Select"
},
"actionTypes": {
"EPHEMERAL_REPLY": "Ephemere Antwort",
"PUBLIC_REPLY": "Öffentliche Antwort",
"ASSIGN_ROLE": "Rolle zuweisen",
"REMOVE_ROLE": "Rolle entfernen",
"TOGGLE_ROLE": "Rolle umschalten",
"ASSIGN_SELECTED_ROLES": "Ausgewählte Rollen zuweisen",
"NONE": "Keine Aktion"
}
},
"placeholders": { "placeholders": {
"toggle": "Verfügbare Platzhalter anzeigen", "toggle": "Verfügbare Platzhalter anzeigen",
"hint": "Zum Einfügen auf Kopieren tippen. Nur die hier gelisteten Platzhalter funktionieren in diesem Feld.", "hint": "Zum Einfügen auf Kopieren tippen. Nur die hier gelisteten Platzhalter funktionieren in diesem Feld.",
@@ -174,6 +246,10 @@
"guildbackup": { "guildbackup": {
"label": "Backups", "label": "Backups",
"description": "Server-Backup und -Wiederherstellung" "description": "Server-Backup und -Wiederherstellung"
},
"messages": {
"label": "Nachrichten",
"description": "Embeds oder Components-V2-Layouts in einen Kanal senden"
} }
}, },
"login": { "login": {
@@ -361,12 +437,14 @@
"type": { "type": {
"TEXT": "Text", "TEXT": "Text",
"EMBED": "Embed", "EMBED": "Embed",
"IMAGE": "Bild-Karte" "IMAGE": "Bild-Karte",
"COMPONENTS_V2": "Components V2"
}, },
"welcomeContent": "Nachrichtentext", "welcomeContent": "Nachrichtentext",
"contentPlaceholder": "Willkommen {user} auf {server}!", "contentPlaceholder": "Willkommen {user} auf {server}!",
"placeholdersHint": "Verfügbare Platzhalter: siehe Liste unten.", "placeholdersHint": "Verfügbare Platzhalter: siehe Liste unten.",
"welcomeEmbed": "Embed", "welcomeEmbed": "Embed",
"welcomeComponents": "Components-V2-Layout",
"welcomeDmEnabled": "Willkommens-DM aktiviert", "welcomeDmEnabled": "Willkommens-DM aktiviert",
"dmContentPlaceholder": "Optionale private Nachricht an neue Mitglieder", "dmContentPlaceholder": "Optionale private Nachricht an neue Mitglieder",
"userAutoroles": "Autoroles für Nutzer", "userAutoroles": "Autoroles für Nutzer",
@@ -377,9 +455,16 @@
"leaveDescription": "Nachricht und Kanal, wenn ein Mitglied den Server verlässt.", "leaveDescription": "Nachricht und Kanal, wenn ein Mitglied den Server verlässt.",
"leaveEnabledLabel": "Abschiedsnachricht aktiviert", "leaveEnabledLabel": "Abschiedsnachricht aktiviert",
"leaveChannelId": "Abschieds-Kanal-ID", "leaveChannelId": "Abschieds-Kanal-ID",
"leaveType": "Nachrichtentyp",
"leaveTypes": {
"TEXT": "Text",
"EMBED": "Embed",
"COMPONENTS_V2": "Components V2"
},
"leaveContent": "Nachrichtentext", "leaveContent": "Nachrichtentext",
"leaveContentPlaceholder": "Tschüss {user.tag} — bis bald auf {server}!", "leaveContentPlaceholder": "Tschüss {user.tag} — bis bald auf {server}!",
"leaveEmbed": "Embed", "leaveEmbed": "Embed",
"leaveComponents": "Components-V2-Layout",
"leaveEmbedTitlePlaceholder": "Auf Wiedersehen", "leaveEmbedTitlePlaceholder": "Auf Wiedersehen",
"leaveEmbedDescriptionPlaceholder": "{user.tag} hat {server} verlassen." "leaveEmbedDescriptionPlaceholder": "{user.tag} hat {server} verlassen."
}, },
@@ -550,12 +635,15 @@
"responseType": "Antworttyp", "responseType": "Antworttyp",
"responseTypes": { "responseTypes": {
"TEXT": "Text", "TEXT": "Text",
"EMBED": "Embed" "EMBED": "Embed",
"COMPONENTS_V2": "Components V2"
}, },
"triggerWord": "Auslöse-Wort (optional)", "triggerWord": "Auslöse-Wort (optional)",
"content": "Inhalt", "content": "Inhalt",
"embed": "Embed", "embed": "Embed",
"components": "Components-V2-Layout",
"embedRequired": "Für Embed-Tags sind Titel oder Beschreibung erforderlich.", "embedRequired": "Für Embed-Tags sind Titel oder Beschreibung erforderlich.",
"componentsRequired": "Components-V2-Tags benötigen mindestens einen Block.",
"allowedRoleIds": "Erlaubte Rollen", "allowedRoleIds": "Erlaubte Rollen",
"allowedChannelIds": "Erlaubte Kanal-IDs", "allowedChannelIds": "Erlaubte Kanal-IDs",
"idsPlaceholder": "Eine oder mehrere IDs, kommagetrennt", "idsPlaceholder": "Eine oder mehrere IDs, kommagetrennt",
@@ -658,10 +746,18 @@
"runAt": "Ausführen am", "runAt": "Ausführen am",
"content": "Nachrichtentext", "content": "Nachrichtentext",
"embed": "Embed (optional)", "embed": "Embed (optional)",
"cronHint": "Cron-Ausdruck für wiederkehrende Nachrichten ODER ein einmaliges Datum/Uhrzeit angeben - nicht beides. Text und/oder Embed sind erforderlich.", "components": "Components-V2-Layout",
"contentMode": "Nachrichtenformat",
"contentModes": {
"text": "Text",
"embed": "Embed",
"components_v2": "Components V2"
},
"componentsPreview": "(Components V2)",
"cronHint": "Cron-Ausdruck für wiederkehrende Nachrichten ODER ein einmaliges Datum/Uhrzeit — nicht beides. Wähle Text, Embed oder Components V2 (gegenseitig ausgeschlossen).",
"create": "Nachricht planen", "create": "Nachricht planen",
"created": "Nachricht geplant.", "created": "Nachricht geplant.",
"formIncomplete": "Bitte Kanal, Inhalt oder Embed sowie Cron-Ausdruck oder Datum/Uhrzeit ausfüllen.", "formIncomplete": "Bitte Kanal, Nachrichtenformat mit Inhalt sowie Cron-Ausdruck oder Datum/Uhrzeit ausfüllen.",
"confirmDelete": "Diesen Zeitplan wirklich löschen?", "confirmDelete": "Diesen Zeitplan wirklich löschen?",
"listTitle": "Geplante Nachrichten", "listTitle": "Geplante Nachrichten",
"listDescription": "Anstehende und wiederkehrende geplante Nachrichten.", "listDescription": "Anstehende und wiederkehrende geplante Nachrichten.",
@@ -692,6 +788,23 @@
"confirmDelete": "Dieses Backup wirklich löschen?", "confirmDelete": "Dieses Backup wirklich löschen?",
"ownerOnlyBadge": "Wiederherstellung nur durch Owner" "ownerOnlyBadge": "Wiederherstellung nur durch Owner"
}, },
"messages": {
"sendTitle": "Nachricht senden",
"sendDescription": "Embed oder Components-V2-Layout über den Bot in einen Kanal posten.",
"channelId": "Zielkanal",
"channelRequired": "Bitte zuerst einen Kanal wählen.",
"mode": "Nachrichtentyp",
"modes": {
"EMBED": "Embed",
"COMPONENTS_V2": "Components V2"
},
"embedRequired": "Embed-Modus benötigt Titel, Beschreibung oder eine Farbe.",
"componentsRequired": "Füge mindestens einen gültigen Components-V2-Block hinzu.",
"send": "Nachricht senden",
"sending": "Sende…",
"sent": "Nachricht gesendet.",
"sentWithId": "Nachricht gesendet (ID: {id})."
},
"commands": { "commands": {
"title": "Commands", "title": "Commands",
"description": "Einzelne Slash-Commands aktivieren/deaktivieren und per Rolle, Kanal oder Cooldown einschränken.", "description": "Einzelne Slash-Commands aktivieren/deaktivieren und per Rolle, Kanal oder Cooldown einschränken.",

View File

@@ -50,6 +50,78 @@
"timestampPreview": "just now", "timestampPreview": "just now",
"mediaHint": "Image URLs must start with http(s). For Welcome you can use {user.avatar} and {server.icon}." "mediaHint": "Image URLs must start with http(s). For Welcome you can use {user.avatar} and {server.icon}."
}, },
"componentsV2Builder": {
"blocks": "Layout blocks",
"addBlock": "Add block…",
"addNestedBlock": "Add nested block…",
"empty": "No blocks yet — add a container or text block to get started.",
"preview": "Preview",
"previewEmptyText": "Text content…",
"previewInvalidUrl": "Invalid image URL",
"clear": "Clear layout",
"clearHint": "Invalid blocks are dropped when saving.",
"actionsTitle": "Button & select actions",
"textPlaceholder": "Markdown-style text (Discord formatting)",
"divider": "Show divider line",
"spacing": "Spacing",
"spacingSmall": "Small",
"spacingLarge": "Large",
"accentColor": "Accent color (decimal)",
"spoiler": "Spoiler container",
"sectionText": "Section text",
"accessoryType": "Accessory",
"accessoryThumbnail": "Thumbnail",
"accessoryButton": "Button",
"urlPlaceholder": "https://… or {user.avatar}",
"addMediaItem": "Add image",
"addButton": "Add button",
"addSelect": "Add select…",
"buttonLabel": "Button label",
"linkUrl": "Link URL (https://…)",
"actionId": "Action ID",
"centralActionId": "Central action ID (optional)",
"optionLabel": "Option label",
"optionValue": "Option value",
"optionActionId": "Per-option action ID (optional)",
"addOption": "Add option",
"removeOption": "Remove option",
"selectPlaceholder": "Select placeholder",
"replyContent": "Reply message",
"roleAction": "Target role",
"defaultButtonLabel": "Button",
"blockTypes": {
"container": "Container",
"text_display": "Text",
"separator": "Separator",
"section": "Section",
"media_gallery": "Media gallery",
"action_row": "Action row",
"button": "Button"
},
"buttonStyles": {
"Primary": "Primary",
"Secondary": "Secondary",
"Success": "Success",
"Danger": "Danger",
"Link": "Link"
},
"selectTypes": {
"string_select": "String select",
"user_select": "User select",
"role_select": "Role select",
"channel_select": "Channel select",
"mentionable_select": "Mentionable select"
},
"actionTypes": {
"EPHEMERAL_REPLY": "Ephemeral reply",
"PUBLIC_REPLY": "Public reply",
"ASSIGN_ROLE": "Assign role",
"REMOVE_ROLE": "Remove role",
"TOGGLE_ROLE": "Toggle role",
"ASSIGN_SELECTED_ROLES": "Assign selected roles",
"NONE": "No action"
}
},
"placeholders": { "placeholders": {
"toggle": "Show available placeholders", "toggle": "Show available placeholders",
"hint": "Tap copy to insert. Only the placeholders listed here work in this field.", "hint": "Tap copy to insert. Only the placeholders listed here work in this field.",
@@ -174,6 +246,10 @@
"guildbackup": { "guildbackup": {
"label": "Backups", "label": "Backups",
"description": "Server backup and restore" "description": "Server backup and restore"
},
"messages": {
"label": "Messages",
"description": "Send embeds or Components V2 layouts to a channel"
} }
}, },
"login": { "login": {
@@ -361,12 +437,14 @@
"type": { "type": {
"TEXT": "Text", "TEXT": "Text",
"EMBED": "Embed", "EMBED": "Embed",
"IMAGE": "Image card" "IMAGE": "Image card",
"COMPONENTS_V2": "Components V2"
}, },
"welcomeContent": "Message content", "welcomeContent": "Message content",
"contentPlaceholder": "Welcome {user} to {server}!", "contentPlaceholder": "Welcome {user} to {server}!",
"placeholdersHint": "Available placeholders: see the list below.", "placeholdersHint": "Available placeholders: see the list below.",
"welcomeEmbed": "Embed", "welcomeEmbed": "Embed",
"welcomeComponents": "Components V2 layout",
"welcomeDmEnabled": "Welcome DM enabled", "welcomeDmEnabled": "Welcome DM enabled",
"dmContentPlaceholder": "Optional private message to new members", "dmContentPlaceholder": "Optional private message to new members",
"userAutoroles": "Autoroles for users", "userAutoroles": "Autoroles for users",
@@ -377,9 +455,16 @@
"leaveDescription": "Message and channel when a member leaves the server.", "leaveDescription": "Message and channel when a member leaves the server.",
"leaveEnabledLabel": "Leave message enabled", "leaveEnabledLabel": "Leave message enabled",
"leaveChannelId": "Leave channel ID", "leaveChannelId": "Leave channel ID",
"leaveType": "Message type",
"leaveTypes": {
"TEXT": "Text",
"EMBED": "Embed",
"COMPONENTS_V2": "Components V2"
},
"leaveContent": "Message content", "leaveContent": "Message content",
"leaveContentPlaceholder": "Goodbye {user.tag} — see you around on {server}!", "leaveContentPlaceholder": "Goodbye {user.tag} — see you around on {server}!",
"leaveEmbed": "Embed", "leaveEmbed": "Embed",
"leaveComponents": "Components V2 layout",
"leaveEmbedTitlePlaceholder": "Goodbye", "leaveEmbedTitlePlaceholder": "Goodbye",
"leaveEmbedDescriptionPlaceholder": "{user.tag} left {server}." "leaveEmbedDescriptionPlaceholder": "{user.tag} left {server}."
}, },
@@ -550,12 +635,15 @@
"responseType": "Response type", "responseType": "Response type",
"responseTypes": { "responseTypes": {
"TEXT": "Text", "TEXT": "Text",
"EMBED": "Embed" "EMBED": "Embed",
"COMPONENTS_V2": "Components V2"
}, },
"triggerWord": "Trigger word (optional)", "triggerWord": "Trigger word (optional)",
"content": "Content", "content": "Content",
"embed": "Embed", "embed": "Embed",
"components": "Components V2 layout",
"embedRequired": "Embed tags need a title or description.", "embedRequired": "Embed tags need a title or description.",
"componentsRequired": "Components V2 tags need at least one block.",
"allowedRoleIds": "Allowed roles", "allowedRoleIds": "Allowed roles",
"allowedChannelIds": "Allowed channel IDs", "allowedChannelIds": "Allowed channel IDs",
"idsPlaceholder": "One or more IDs, comma-separated", "idsPlaceholder": "One or more IDs, comma-separated",
@@ -658,10 +746,18 @@
"runAt": "Run at", "runAt": "Run at",
"content": "Message content", "content": "Message content",
"embed": "Embed (optional)", "embed": "Embed (optional)",
"cronHint": "Set a cron expression for recurring messages, or a one-time date/time - not both. Text and/or embed is required.", "components": "Components V2 layout",
"contentMode": "Message format",
"contentModes": {
"text": "Text",
"embed": "Embed",
"components_v2": "Components V2"
},
"componentsPreview": "(Components V2)",
"cronHint": "Set a cron expression for recurring messages, or a one-time date/time — not both. Choose text, embed, or Components V2 (mutually exclusive).",
"create": "Schedule message", "create": "Schedule message",
"created": "Message scheduled.", "created": "Message scheduled.",
"formIncomplete": "Please fill channel, content or embed, and either a cron expression or a date/time.", "formIncomplete": "Please fill channel, choose a message format with content, and either a cron expression or a date/time.",
"confirmDelete": "Really delete this schedule?", "confirmDelete": "Really delete this schedule?",
"listTitle": "Scheduled messages", "listTitle": "Scheduled messages",
"listDescription": "Upcoming and recurring scheduled messages.", "listDescription": "Upcoming and recurring scheduled messages.",
@@ -692,6 +788,23 @@
"confirmDelete": "Really delete this backup?", "confirmDelete": "Really delete this backup?",
"ownerOnlyBadge": "Owner-only restore" "ownerOnlyBadge": "Owner-only restore"
}, },
"messages": {
"sendTitle": "Send a message",
"sendDescription": "Post an embed or Components V2 layout to a channel via the bot.",
"channelId": "Target channel",
"channelRequired": "Select a channel first.",
"mode": "Message type",
"modes": {
"EMBED": "Embed",
"COMPONENTS_V2": "Components V2"
},
"embedRequired": "Embed mode needs title, description, or a color.",
"componentsRequired": "Add at least one valid Components V2 block.",
"send": "Send message",
"sending": "Sending…",
"sent": "Message sent.",
"sentWithId": "Message sent (ID: {id})."
},
"commands": { "commands": {
"title": "Commands", "title": "Commands",
"description": "Enable/disable individual slash commands and restrict them by role, channel or cooldown.", "description": "Enable/disable individual slash commands and restrict them by role, channel or cooldown.",

View File

@@ -170,6 +170,31 @@
- [ ] Welcome: Liste aufklappen, Platzhalter kopieren, in Embed/Text einfügen - [ ] Welcome: Liste aufklappen, Platzhalter kopieren, in Embed/Text einfügen
- [ ] Tags/Birthdays/Leveling/Stats/Feeds/Scheduler: nur die jeweiligen Tokens sichtbar - [ ] Tags/Birthdays/Leveling/Stats/Feeds/Scheduler: nur die jeweiligen Tokens sichtbar
## Post-Phase Components V2 (Status: implementiert, manuelle Tests offen)
### Abgeschlossen (Code)
- Shared Zod: `MessageComponentsV2` + Actions, `COMPONENTS_V2` für Welcome/Tags, Leave-Typen, custom_id `cv2:{source}:{ref}:{actionId}`
- Prisma-Migration `20260722220000_components_v2`: `welcomeComponents`/`leaveType`/`leaveComponents`, `Tag.components`, `ScheduledMessage.components`, `ComponentMessageBinding`
- Bot: `buildComponentsV2Payload`, Anbindung Welcome/Leave/Tags/Scheduler, Interaction-Handler, Dashboard-Send-Queue `messages`/`dashboardMessageSend`
- Slash: `/embed builder` + `/embed components` (Quick-V2)
- WebUI: `DiscordComponentsV2Builder`, Mode-Switches Welcome/Tags/Scheduler, Dashboard-Seite `/messages`
### Manuell testen
- [ ] Welcome V2 mit Container + Thumbnail-Section + Link-Button + Toggle-Role-Button
- [ ] Tag V2 mit String-Select → ephemeral reply
- [ ] Scheduler V2 Ankündigung
- [ ] Dashboard Messages: Embed + Components V2 senden
- [ ] Slash `/embed components`
- [ ] Bestehende Embeds unverändert
- [ ] Migration anwenden (`prisma migrate deploy`) und Bot/WebUI neu bauen
### Bewusst offen
- Modal-only Components, File-Component, Premium-Buttons (nicht im MVP)
- Pixelgenaue Discord-Vorschau im Dashboard
## Nächster geplanter Schritt ## Nächster geplanter Schritt
- Deploy auf Traefik-Server manuell verifizieren; danach ggf. `deploy``main` mergen (nur auf Freigabe). - Deploy auf Traefik-Server manuell verifizieren; danach ggf. `deploy``main` mergen (nur auf Freigabe).

View File

@@ -592,13 +592,37 @@ export const commandLocales = {
en: 'Show last edited message' en: 'Show last edited message'
}, },
'utility.embed.description': { 'utility.embed.description': {
de: 'Embeds erstellen', de: 'Embeds und Components V2 erstellen',
en: 'Create embeds' en: 'Create embeds and Components V2'
}, },
'utility.embed.builder.description': { 'utility.embed.builder.description': {
de: 'Embed per Modal bauen und senden', de: 'Embed per Modal bauen und senden',
en: 'Build and send an embed via modal' en: 'Build and send an embed via modal'
}, },
'utility.embed.components.description': {
de: 'Schnelle Components-V2-Nachricht senden',
en: 'Send a quick Components V2 message'
},
'utility.embed.components.options.channel': {
de: 'Zielkanal',
en: 'Target channel'
},
'utility.embed.components.options.text': {
de: 'Textinhalt',
en: 'Text content'
},
'utility.embed.components.options.image_url': {
de: 'Optionale Bild-URL',
en: 'Optional image URL'
},
'utility.embed.components.options.button_label': {
de: 'Optionaler Button-Text',
en: 'Optional button label'
},
'utility.embed.components.options.button_url': {
de: 'Optionale Button-URL',
en: 'Optional button URL'
},
'fun.8ball.description': { 'fun.8ball.description': {
de: 'Magische 8-Ball-Antwort auf deine Frage', de: 'Magische 8-Ball-Antwort auf deine Frage',
en: 'Magic 8-ball answer to your question' en: 'Magic 8-ball answer to your question'

View File

@@ -0,0 +1,147 @@
import { describe, expect, it } from 'vitest';
import {
countComponentsV2,
decodeComponentCustomId,
encodeComponentCustomId,
emptyComponentsV2,
mapComponentsV2TextFields,
MessageComponentsV2Schema
} from './components-v2.js';
describe('MessageComponentsV2Schema', () => {
it('accepts a minimal container + text display', () => {
const parsed = MessageComponentsV2Schema.parse(emptyComponentsV2());
expect(parsed.components).toHaveLength(1);
expect(parsed.components[0]?.type).toBe('container');
});
it('accepts interactive button with matching action', () => {
const parsed = MessageComponentsV2Schema.parse({
components: [
{
type: 'action_row',
components: [
{
type: 'button',
style: 'Primary',
label: 'Toggle',
actionId: 'toggle_vip'
}
]
}
],
actions: {
toggle_vip: { type: 'TOGGLE_ROLE', roleId: '123456789012345678' }
}
});
expect(parsed.actions.toggle_vip?.type).toBe('TOGGLE_ROLE');
});
it('accepts link buttons without action', () => {
const parsed = MessageComponentsV2Schema.parse({
components: [
{
type: 'action_row',
components: [
{
type: 'button',
style: 'Link',
label: 'Docs',
url: 'https://example.com'
}
]
}
],
actions: {}
});
expect(parsed.components[0]?.type).toBe('action_row');
});
it('rejects missing action definitions', () => {
const result = MessageComponentsV2Schema.safeParse({
components: [
{
type: 'action_row',
components: [{ type: 'button', style: 'Success', label: 'Go', actionId: 'missing' }]
}
],
actions: {}
});
expect(result.success).toBe(false);
});
it('rejects trees above 40 components', () => {
const texts = Array.from({ length: 41 }, (_, i) => ({
type: 'text_display' as const,
content: `Line ${i}`
}));
const result = MessageComponentsV2Schema.safeParse({
components: texts,
actions: {}
});
expect(result.success).toBe(false);
});
it('counts nested container children', () => {
const count = countComponentsV2([
{
type: 'container',
components: [
{ type: 'text_display', content: 'a' },
{ type: 'separator' },
{
type: 'section',
text: 'hello',
accessory: { type: 'thumbnail', url: 'https://example.com/a.png' }
}
]
}
]);
// container + 3 children + section accessory
expect(count).toBe(5);
});
});
describe('custom_id helpers', () => {
it('encodes and decodes cv2 custom ids', () => {
const customId = encodeComponentCustomId('t', 'tag_abc', 'reply1');
expect(customId).toBe('cv2:t:tag_abc:reply1');
expect(decodeComponentCustomId(customId)).toEqual({
source: 't',
ref: 'tag_abc',
actionId: 'reply1'
});
});
it('returns null for unrelated custom ids', () => {
expect(decodeComponentCustomId('sr:btn:1:2')).toBeNull();
});
});
describe('mapComponentsV2TextFields', () => {
it('maps text and action reply content', () => {
const mapped = mapComponentsV2TextFields(
{
components: [
{
type: 'container',
components: [{ type: 'text_display', content: 'Hi {user}' }]
}
],
actions: {
a1: { type: 'EPHEMERAL_REPLY', content: 'Welcome {user}' }
}
},
(value) => value.replaceAll('{user}', '@Mace')
);
const container = mapped.components[0];
expect(container?.type).toBe('container');
if (container?.type === 'container') {
expect(container.components[0]).toMatchObject({
type: 'text_display',
content: 'Hi @Mace'
});
}
expect(mapped.actions.a1?.content).toBe('Welcome @Mace');
});
});

View File

@@ -0,0 +1,562 @@
import { z } from 'zod';
/** Discord MessageFlags.IsComponentsV2 */
export const COMPONENTS_V2_FLAG = 32768 as const;
export const ComponentV2SourceSchema = z.enum(['w', 'l', 't', 's', 'm']);
export type ComponentV2Source = z.infer<typeof ComponentV2SourceSchema>;
export const ComponentActionTypeSchema = z.enum([
'EPHEMERAL_REPLY',
'PUBLIC_REPLY',
'ASSIGN_ROLE',
'REMOVE_ROLE',
'TOGGLE_ROLE',
'ASSIGN_SELECTED_ROLES',
'NONE'
]);
export type ComponentActionType = z.infer<typeof ComponentActionTypeSchema>;
export const ComponentActionSchema = z
.object({
type: ComponentActionTypeSchema,
content: z.string().max(2000).optional(),
roleId: z.string().max(32).optional()
})
.superRefine((value, ctx) => {
if (
(value.type === 'EPHEMERAL_REPLY' || value.type === 'PUBLIC_REPLY') &&
!value.content?.trim()
) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
message: 'Reply actions require content',
path: ['content']
});
}
if (
(value.type === 'ASSIGN_ROLE' ||
value.type === 'REMOVE_ROLE' ||
value.type === 'TOGGLE_ROLE') &&
!value.roleId?.trim()
) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
message: 'Role actions require roleId',
path: ['roleId']
});
}
});
export type ComponentAction = z.infer<typeof ComponentActionSchema>;
export const ComponentButtonStyleSchema = z.enum([
'Primary',
'Secondary',
'Success',
'Danger',
'Link'
]);
export type ComponentButtonStyle = z.infer<typeof ComponentButtonStyleSchema>;
export const SeparatorSpacingSchema = z.enum(['Small', 'Large']);
export type SeparatorSpacing = z.infer<typeof SeparatorSpacingSchema>;
export const MediaGalleryItemSchema = z.object({
url: z.string().max(2048),
description: z.string().max(1024).optional(),
spoiler: z.boolean().optional()
});
export type MediaGalleryItem = z.infer<typeof MediaGalleryItemSchema>;
/** Plain object (no effects) so it can sit in Zod discriminated unions. */
export const ComponentButtonSchema = z.object({
type: z.literal('button'),
id: z.string().min(1).max(40).optional(),
style: ComponentButtonStyleSchema.default('Secondary'),
label: z.string().min(1).max(80),
emoji: z.string().max(64).optional(),
url: z.string().max(2048).optional(),
actionId: z.string().min(1).max(40).optional(),
disabled: z.boolean().optional()
});
export type ComponentButton = z.infer<typeof ComponentButtonSchema>;
export const ComponentThumbnailSchema = z.object({
type: z.literal('thumbnail'),
id: z.string().min(1).max(40).optional(),
url: z.string().max(2048),
description: z.string().max(1024).optional(),
spoiler: z.boolean().optional()
});
export type ComponentThumbnail = z.infer<typeof ComponentThumbnailSchema>;
export const StringSelectOptionSchema = z.object({
label: z.string().min(1).max(100),
value: z.string().min(1).max(100),
description: z.string().max(100).optional(),
emoji: z.string().max(64).optional(),
actionId: z.string().min(1).max(40).optional()
});
export type StringSelectOption = z.infer<typeof StringSelectOptionSchema>;
export const ComponentStringSelectSchema = z.object({
type: z.literal('string_select'),
id: z.string().min(1).max(40).optional(),
placeholder: z.string().max(150).optional(),
minValues: z.number().int().min(0).max(25).optional(),
maxValues: z.number().int().min(1).max(25).optional(),
options: z.array(StringSelectOptionSchema).min(1).max(25),
actionId: z.string().min(1).max(40).optional(),
disabled: z.boolean().optional()
});
export type ComponentStringSelect = z.infer<typeof ComponentStringSelectSchema>;
const EntitySelectBase = {
id: z.string().min(1).max(40).optional(),
placeholder: z.string().max(150).optional(),
minValues: z.number().int().min(0).max(25).optional(),
maxValues: z.number().int().min(1).max(25).optional(),
actionId: z.string().min(1).max(40),
disabled: z.boolean().optional()
};
export const ComponentUserSelectSchema = z.object({
type: z.literal('user_select'),
...EntitySelectBase
});
export type ComponentUserSelect = z.infer<typeof ComponentUserSelectSchema>;
export const ComponentRoleSelectSchema = z.object({
type: z.literal('role_select'),
...EntitySelectBase
});
export type ComponentRoleSelect = z.infer<typeof ComponentRoleSelectSchema>;
export const ComponentChannelSelectSchema = z.object({
type: z.literal('channel_select'),
...EntitySelectBase
});
export type ComponentChannelSelect = z.infer<typeof ComponentChannelSelectSchema>;
export const ComponentMentionableSelectSchema = z.object({
type: z.literal('mentionable_select'),
...EntitySelectBase
});
export type ComponentMentionableSelect = z.infer<typeof ComponentMentionableSelectSchema>;
export const ComponentSelectSchema = z.discriminatedUnion('type', [
ComponentStringSelectSchema,
ComponentUserSelectSchema,
ComponentRoleSelectSchema,
ComponentChannelSelectSchema,
ComponentMentionableSelectSchema
]);
export type ComponentSelect = z.infer<typeof ComponentSelectSchema>;
export const ComponentTextDisplaySchema = z.object({
type: z.literal('text_display'),
id: z.string().min(1).max(40).optional(),
content: z.string().min(1).max(4000)
});
export type ComponentTextDisplay = z.infer<typeof ComponentTextDisplaySchema>;
export const ComponentSeparatorSchema = z.object({
type: z.literal('separator'),
id: z.string().min(1).max(40).optional(),
divider: z.boolean().optional(),
spacing: SeparatorSpacingSchema.optional()
});
export type ComponentSeparator = z.infer<typeof ComponentSeparatorSchema>;
export const ComponentMediaGallerySchema = z.object({
type: z.literal('media_gallery'),
id: z.string().min(1).max(40).optional(),
items: z.array(MediaGalleryItemSchema).min(1).max(10)
});
export type ComponentMediaGallery = z.infer<typeof ComponentMediaGallerySchema>;
export const ComponentSectionAccessorySchema = z.discriminatedUnion('type', [
ComponentThumbnailSchema,
ComponentButtonSchema
]);
export const ComponentSectionSchema = z.object({
type: z.literal('section'),
id: z.string().min(1).max(40).optional(),
text: z.union([z.string().min(1).max(4000), z.array(z.string().min(1).max(4000)).min(1).max(3)]),
accessory: ComponentSectionAccessorySchema
});
export type ComponentSection = z.infer<typeof ComponentSectionSchema>;
export const ComponentActionRowChildSchema = z.union([
ComponentButtonSchema,
ComponentStringSelectSchema,
ComponentUserSelectSchema,
ComponentRoleSelectSchema,
ComponentChannelSelectSchema,
ComponentMentionableSelectSchema
]);
export const ComponentActionRowSchema = z.object({
type: z.literal('action_row'),
id: z.string().min(1).max(40).optional(),
components: z.array(ComponentActionRowChildSchema).min(1).max(5)
});
export type ComponentActionRow = z.infer<typeof ComponentActionRowSchema>;
export type ComponentV2Node =
| ComponentTextDisplay
| ComponentSeparator
| ComponentThumbnail
| ComponentMediaGallery
| ComponentSection
| ComponentActionRow
| ComponentButton
| ComponentSelect
| {
type: 'container';
id?: string;
accentColor?: number;
spoiler?: boolean;
components: ComponentV2Node[];
};
type ComponentContainer = Extract<ComponentV2Node, { type: 'container' }>;
const leafComponentSchemas = [
ComponentTextDisplaySchema,
ComponentSeparatorSchema,
ComponentThumbnailSchema,
ComponentMediaGallerySchema,
ComponentSectionSchema,
ComponentActionRowSchema,
ComponentButtonSchema,
ComponentStringSelectSchema,
ComponentUserSelectSchema,
ComponentRoleSelectSchema,
ComponentChannelSelectSchema,
ComponentMentionableSelectSchema
] as const;
export const ComponentContainerSchema: z.ZodType<ComponentContainer> = z.lazy(() =>
z.object({
type: z.literal('container'),
id: z.string().min(1).max(40).optional(),
accentColor: z.number().int().min(0).max(0xffffff).optional(),
spoiler: z.boolean().optional(),
components: z.array(ComponentV2NodeSchema).min(1).max(40)
})
) as z.ZodType<ComponentContainer>;
export const ComponentV2NodeSchema: z.ZodType<ComponentV2Node> = z.lazy(() =>
z.union([...leafComponentSchemas, ComponentContainerSchema])
) as z.ZodType<ComponentV2Node>;
const MAX_COMPONENTS = 40;
/** Counts every node in the tree (including nested container children). */
export function countComponentsV2(nodes: ComponentV2Node[]): number {
let count = 0;
for (const node of nodes) {
count += 1;
if (node.type === 'container') {
count += countComponentsV2(node.components);
} else if (node.type === 'action_row') {
count += node.components.length;
} else if (node.type === 'section') {
count += 1;
} else if (node.type === 'media_gallery') {
count += node.items.length;
}
}
return count;
}
function collectActionIds(nodes: ComponentV2Node[], ids: Set<string>): void {
for (const node of nodes) {
switch (node.type) {
case 'button':
if (node.actionId) {
ids.add(node.actionId);
}
break;
case 'string_select':
if (node.actionId) {
ids.add(node.actionId);
}
for (const option of node.options) {
if (option.actionId) {
ids.add(option.actionId);
}
}
break;
case 'user_select':
case 'role_select':
case 'channel_select':
case 'mentionable_select':
ids.add(node.actionId);
break;
case 'action_row':
collectActionIds(node.components as ComponentV2Node[], ids);
break;
case 'section':
if (node.accessory.type === 'button' && node.accessory.actionId) {
ids.add(node.accessory.actionId);
}
break;
case 'container':
collectActionIds(node.components, ids);
break;
default:
break;
}
}
}
function validateButtonRules(
button: ComponentButton,
ctx: z.RefinementCtx,
path: (string | number)[]
): void {
if (button.style === 'Link') {
if (!button.url?.trim()) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
message: 'Link buttons require url',
path: [...path, 'url']
});
}
} else if (!button.actionId?.trim()) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
message: 'Interactive buttons require actionId',
path: [...path, 'actionId']
});
}
}
function validateNodeRules(
nodes: ComponentV2Node[],
ctx: z.RefinementCtx,
path: (string | number)[]
): void {
nodes.forEach((node, index) => {
const nodePath = [...path, index];
if (node.type === 'button') {
validateButtonRules(node, ctx, nodePath);
} else if (node.type === 'action_row') {
node.components.forEach((child, childIndex) => {
if (child.type === 'button') {
validateButtonRules(child, ctx, [...nodePath, 'components', childIndex]);
}
});
} else if (node.type === 'section' && node.accessory.type === 'button') {
validateButtonRules(node.accessory, ctx, [...nodePath, 'accessory']);
} else if (node.type === 'container') {
validateNodeRules(node.components, ctx, [...nodePath, 'components']);
}
});
}
export const MessageComponentsV2Schema = z
.object({
components: z.array(ComponentV2NodeSchema).min(1).max(MAX_COMPONENTS),
actions: z.record(z.string().min(1).max(40), ComponentActionSchema).default({})
})
.superRefine((value, ctx) => {
const total = countComponentsV2(value.components);
if (total > MAX_COMPONENTS) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
message: `At most ${MAX_COMPONENTS} components allowed (got ${total})`,
path: ['components']
});
}
validateNodeRules(value.components, ctx, ['components']);
const referenced = new Set<string>();
collectActionIds(value.components, referenced);
for (const actionId of referenced) {
if (!value.actions[actionId]) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
message: `Missing action definition for "${actionId}"`,
path: ['actions', actionId]
});
}
}
});
export type MessageComponentsV2 = z.infer<typeof MessageComponentsV2Schema>;
export function componentsV2HasContent(
payload: MessageComponentsV2 | null | undefined
): boolean {
return Boolean(payload && payload.components.length > 0);
}
/**
* Applies a string renderer to every text/URL field in a Components V2 tree.
* Used by Welcome/Tags/Scheduler before handing data to discord.js.
*/
export function mapComponentsV2TextFields(
payload: MessageComponentsV2,
render: (value: string) => string
): MessageComponentsV2 {
const mapOptional = (value: string | undefined): string | undefined => {
if (!value?.trim()) {
return undefined;
}
return render(value);
};
const mapNode = (node: ComponentV2Node): ComponentV2Node => {
switch (node.type) {
case 'text_display':
return { ...node, content: render(node.content) };
case 'thumbnail':
return {
...node,
url: render(node.url),
description: mapOptional(node.description)
};
case 'media_gallery':
return {
...node,
items: node.items.map((item) => ({
...item,
url: render(item.url),
description: mapOptional(item.description)
}))
};
case 'section': {
const text = Array.isArray(node.text)
? node.text.map((part) => render(part))
: render(node.text);
const accessory =
node.accessory.type === 'thumbnail'
? {
...node.accessory,
url: render(node.accessory.url),
description: mapOptional(node.accessory.description)
}
: {
...node.accessory,
label: render(node.accessory.label),
url: mapOptional(node.accessory.url)
};
return { ...node, text, accessory };
}
case 'button':
return {
...node,
label: render(node.label),
url: mapOptional(node.url)
};
case 'string_select':
return {
...node,
placeholder: mapOptional(node.placeholder),
options: node.options.map((option) => ({
...option,
label: render(option.label),
description: mapOptional(option.description)
}))
};
case 'user_select':
case 'role_select':
case 'channel_select':
case 'mentionable_select':
return { ...node, placeholder: mapOptional(node.placeholder) };
case 'action_row':
return {
...node,
components: node.components.map((child) =>
mapNode(child as ComponentV2Node)
) as typeof node.components
};
case 'container':
return {
...node,
components: node.components.map(mapNode)
};
case 'separator':
default:
return node;
}
};
const actions: MessageComponentsV2['actions'] = {};
for (const [id, action] of Object.entries(payload.actions)) {
actions[id] = {
...action,
content: mapOptional(action.content)
};
}
return {
components: payload.components.map(mapNode),
actions
};
}
const CUSTOM_ID_PREFIX = 'cv2';
export function encodeComponentCustomId(
source: ComponentV2Source,
ref: string,
actionId: string
): string {
const customId = `${CUSTOM_ID_PREFIX}:${source}:${ref}:${actionId}`;
if (customId.length > 100) {
throw new Error(`custom_id exceeds 100 characters (${customId.length})`);
}
return customId;
}
export function decodeComponentCustomId(customId: string): {
source: ComponentV2Source;
ref: string;
actionId: string;
} | null {
if (!customId.startsWith(`${CUSTOM_ID_PREFIX}:`)) {
return null;
}
const parts = customId.split(':');
if (parts.length < 4) {
return null;
}
const source = ComponentV2SourceSchema.safeParse(parts[1]);
if (!source.success) {
return null;
}
const actionId = parts[parts.length - 1]!;
const ref = parts.slice(2, -1).join(':');
if (!ref || !actionId) {
return null;
}
return { source: source.data, ref, actionId };
}
export function isComponentV2CustomId(customId: string): boolean {
return decodeComponentCustomId(customId) !== null;
}
/** Empty payload helper for UI builders. */
export function emptyComponentsV2(): MessageComponentsV2 {
return {
components: [
{
type: 'container',
components: [{ type: 'text_display', content: 'Hello from Nexumi!' }]
}
],
actions: {}
};
}
export function parseMessageComponentsV2(raw: unknown): MessageComponentsV2 | null {
const parsed = MessageComponentsV2Schema.safeParse(raw);
return parsed.success ? parsed.data : null;
}

View File

@@ -1,5 +1,13 @@
import { z } from 'zod'; import { z } from 'zod';
import { embedHasContent, WelcomeEmbedSchema } from './phase2.js'; import {
componentsV2HasContent,
MessageComponentsV2Schema
} from './components-v2.js';
import {
embedHasContent,
LeaveMessageTypeSchema,
WelcomeEmbedSchema
} from './phase2.js';
import { import {
SelfRoleBehaviorSchema, SelfRoleBehaviorSchema,
SelfRoleEntrySchema, SelfRoleEntrySchema,
@@ -188,7 +196,13 @@ export type LoggingConfigDashboardPatch = z.infer<typeof LoggingConfigDashboardP
// Welcome & Leave // Welcome & Leave
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
export const WelcomeMessageTypeDashboardSchema = z.enum(['TEXT', 'EMBED', 'IMAGE']); export const WelcomeMessageTypeDashboardSchema = z.enum([
'TEXT',
'EMBED',
'IMAGE',
'COMPONENTS_V2'
]);
export const LeaveMessageTypeDashboardSchema = LeaveMessageTypeSchema;
export const WelcomeConfigDashboardSchema = z.object({ export const WelcomeConfigDashboardSchema = z.object({
welcomeEnabled: z.boolean(), welcomeEnabled: z.boolean(),
@@ -198,8 +212,11 @@ export const WelcomeConfigDashboardSchema = z.object({
welcomeType: WelcomeMessageTypeDashboardSchema, welcomeType: WelcomeMessageTypeDashboardSchema,
welcomeContent: z.string().max(2000).nullable().optional(), welcomeContent: z.string().max(2000).nullable().optional(),
welcomeEmbed: DashboardEmbedSchema.nullable().optional(), welcomeEmbed: DashboardEmbedSchema.nullable().optional(),
welcomeComponents: MessageComponentsV2Schema.nullable().optional(),
leaveType: LeaveMessageTypeDashboardSchema.default('TEXT'),
leaveContent: z.string().max(2000).nullable().optional(), leaveContent: z.string().max(2000).nullable().optional(),
leaveEmbed: DashboardEmbedSchema.nullable().optional(), leaveEmbed: DashboardEmbedSchema.nullable().optional(),
leaveComponents: MessageComponentsV2Schema.nullable().optional(),
welcomeDmEnabled: z.boolean(), welcomeDmEnabled: z.boolean(),
welcomeDmContent: z.string().max(2000).nullable().optional(), welcomeDmContent: z.string().max(2000).nullable().optional(),
userAutoroleIds: z.array(z.string()), userAutoroleIds: z.array(z.string()),
@@ -396,6 +413,7 @@ export const TagDashboardSchema = z.object({
name: z.string().min(1).max(50), name: z.string().min(1).max(50),
content: z.string().max(2000).nullable().optional(), content: z.string().max(2000).nullable().optional(),
embed: DashboardEmbedSchema.nullable().optional(), embed: DashboardEmbedSchema.nullable().optional(),
components: MessageComponentsV2Schema.nullable().optional(),
responseType: TagResponseTypeSchema, responseType: TagResponseTypeSchema,
triggerWord: z.string().max(100).nullable().optional(), triggerWord: z.string().max(100).nullable().optional(),
allowedRoleIds: z.array(z.string()), allowedRoleIds: z.array(z.string()),
@@ -403,12 +421,24 @@ export const TagDashboardSchema = z.object({
}); });
export type TagDashboard = z.infer<typeof TagDashboardSchema>; export type TagDashboard = z.infer<typeof TagDashboardSchema>;
export const TagDashboardCreateSchema = TagDashboardSchema.omit({ id: true }).refine( export const TagDashboardCreateSchema = TagDashboardSchema.omit({ id: true })
.refine(
(value) => (value) =>
value.responseType !== 'EMBED' || value.responseType !== 'EMBED' ||
embedHasContent(value.embed) || embedHasContent(value.embed) ||
value.embed?.color !== undefined, value.embed?.color !== undefined,
{ message: 'Embed content is required for EMBED tags', path: ['embed'] } { message: 'Embed content is required for EMBED tags', path: ['embed'] }
)
.refine(
(value) =>
value.responseType !== 'COMPONENTS_V2' || componentsV2HasContent(value.components),
{ message: 'Components are required for COMPONENTS_V2 tags', path: ['components'] }
)
.refine(
(value) =>
value.responseType !== 'COMPONENTS_V2' ||
(!embedHasContent(value.embed) && value.embed?.color === undefined),
{ message: 'Embed and Components V2 are mutually exclusive', path: ['embed'] }
); );
export type TagDashboardCreate = z.infer<typeof TagDashboardCreateSchema>; export type TagDashboardCreate = z.infer<typeof TagDashboardCreateSchema>;
@@ -560,6 +590,7 @@ export const ScheduledMessageDashboardSchema = z.object({
creatorId: z.string(), creatorId: z.string(),
content: z.string().nullable(), content: z.string().nullable(),
embed: DashboardEmbedSchema.nullable().optional(), embed: DashboardEmbedSchema.nullable().optional(),
components: MessageComponentsV2Schema.nullable().optional(),
rolePingId: z.string().nullable(), rolePingId: z.string().nullable(),
cron: z.string().nullable(), cron: z.string().nullable(),
runAt: z.string().nullable(), runAt: z.string().nullable(),
@@ -573,6 +604,7 @@ export const ScheduledMessageDashboardCreateSchema = z
channelId: SnowflakeSchema, channelId: SnowflakeSchema,
content: z.string().max(2000).nullable().optional(), content: z.string().max(2000).nullable().optional(),
embed: DashboardEmbedSchema.nullable().optional(), embed: DashboardEmbedSchema.nullable().optional(),
components: MessageComponentsV2Schema.nullable().optional(),
rolePingId: OptionalSnowflakeSchema.optional(), rolePingId: OptionalSnowflakeSchema.optional(),
cron: z.string().min(1).max(100).nullable().optional(), cron: z.string().min(1).max(100).nullable().optional(),
runAt: z runAt: z
@@ -585,14 +617,69 @@ export const ScheduledMessageDashboardCreateSchema = z
message: 'Either cron or runAt is required' message: 'Either cron or runAt is required'
}) })
.refine( .refine(
(value) => (value) => {
Boolean(value.content?.trim()) || const hasComponents = componentsV2HasContent(value.components);
embedHasContent(value.embed) || const hasEmbed = embedHasContent(value.embed) || value.embed?.color !== undefined;
value.embed?.color !== undefined, if (hasComponents && hasEmbed) {
{ message: 'Content or embed is required' } return false;
}
if (hasComponents) {
return true;
}
return Boolean(value.content?.trim()) || hasEmbed;
},
{ message: 'Content, embed, or Components V2 is required (embed and V2 are exclusive)' }
); );
export type ScheduledMessageDashboardCreate = z.infer<typeof ScheduledMessageDashboardCreateSchema>; export type ScheduledMessageDashboardCreate = z.infer<typeof ScheduledMessageDashboardCreateSchema>;
// ---------------------------------------------------------------------------
// Dashboard message sender (Embed | Components V2)
// ---------------------------------------------------------------------------
export const DashboardMessageModeSchema = z.enum(['EMBED', 'COMPONENTS_V2']);
export type DashboardMessageMode = z.infer<typeof DashboardMessageModeSchema>;
export const DashboardMessageSendSchema = z
.object({
channelId: SnowflakeSchema,
mode: DashboardMessageModeSchema,
embed: DashboardEmbedSchema.nullable().optional(),
components: MessageComponentsV2Schema.nullable().optional()
})
.superRefine((value, ctx) => {
if (value.mode === 'EMBED') {
if (!embedHasContent(value.embed) && value.embed?.color === undefined) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
message: 'Embed content is required',
path: ['embed']
});
}
if (componentsV2HasContent(value.components)) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
message: 'Components V2 not allowed in EMBED mode',
path: ['components']
});
}
} else if (!componentsV2HasContent(value.components)) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
message: 'Components are required for COMPONENTS_V2 mode',
path: ['components']
});
}
});
export type DashboardMessageSend = z.infer<typeof DashboardMessageSendSchema>;
export const DashboardMessageSendResultSchema = z.object({
bindingId: z.string().nullable(),
messageId: z.string().nullable(),
channelId: z.string(),
confirmed: z.boolean()
});
export type DashboardMessageSendResult = z.infer<typeof DashboardMessageSendResultSchema>;
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
// Guild Backups // Guild Backups
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------

View File

@@ -12,6 +12,7 @@ export * from './dashboard.js';
export * from './owner.js'; export * from './owner.js';
export * from './public.js'; export * from './public.js';
export * from './premium.js'; export * from './premium.js';
export * from './components-v2.js';
export const LocaleSchema = z.enum(['de', 'en']); export const LocaleSchema = z.enum(['de', 'en']);
export type Locale = z.infer<typeof LocaleSchema>; export type Locale = z.infer<typeof LocaleSchema>;
@@ -575,13 +576,29 @@ const de: Dictionary = {
'scheduler.list.line': '`{id}` — {channel} — {timing} — {preview}', 'scheduler.list.line': '`{id}` — {channel} — {timing} — {preview}',
'scheduler.list.cron': 'Cron `{cron}`', 'scheduler.list.cron': 'Cron `{cron}`',
'scheduler.list.embedPreview': '[Embed]', 'scheduler.list.embedPreview': '[Embed]',
'scheduler.list.componentsPreview': '[Components V2]',
'scheduler.error.missing_schedule_time': 'Gib `when` oder `cron` an.', 'scheduler.error.missing_schedule_time': 'Gib `when` oder `cron` an.',
'scheduler.error.invalid_when': 'Ungültige Zeit. Nutze z. B. 1h, 2d oder ein ISO-Datum.', 'scheduler.error.invalid_when': 'Ungültige Zeit. Nutze z. B. 1h, 2d oder ein ISO-Datum.',
'scheduler.error.invalid_cron': 'Ungültiger Cron-Ausdruck (56 Felder).', 'scheduler.error.invalid_cron': 'Ungültiger Cron-Ausdruck (56 Felder).',
'scheduler.error.invalid_channel': 'Ungültiger Kanal.', 'scheduler.error.invalid_channel': 'Ungültiger Kanal.',
'scheduler.error.channel_unavailable': 'Der Bot kann in diesem Kanal nicht senden.', 'scheduler.error.channel_unavailable': 'Der Bot kann in diesem Kanal nicht senden.',
'scheduler.error.not_found': 'Geplante Nachricht nicht gefunden.', 'scheduler.error.not_found': 'Geplante Nachricht nicht gefunden.',
'scheduler.error.content_required': 'Inhalt oder Embed-Titel/Beschreibung erforderlich.' 'scheduler.error.content_required': 'Inhalt oder Embed-Titel/Beschreibung erforderlich.',
'componentsV2.error.notFound': 'Diese Komponenten-Nachricht ist nicht mehr verfügbar.',
'componentsV2.error.actionMissing': 'Für diese Interaktion ist keine Aktion konfiguriert.',
'componentsV2.error.botMissingManageRoles': 'Dem Bot fehlt die Berechtigung Rollen zu verwalten.',
'componentsV2.error.roleNotFound': 'Rolle nicht gefunden.',
'componentsV2.error.roleManaged': 'Integrierte Rollen können nicht vergeben werden.',
'componentsV2.error.roleHierarchy': 'Die Rolle liegt über der höchsten Bot-Rolle.',
'componentsV2.error.noRolesSelected': 'Keine Rollen ausgewählt.',
'componentsV2.role.assigned': 'Rolle vergeben.',
'componentsV2.role.removed': 'Rolle entfernt.',
'componentsV2.role.assignedSelected': 'Ausgewählte Rollen vergeben.',
'utility.embed.components.sent': 'Components-V2-Nachricht gesendet.',
'utility.embed.components.dashboardHint':
'Für den vollen Builder inkl. Interaktionen: {url}',
'utility.embed.components.missingText': 'Text ist erforderlich.',
'tag.error.components_required': 'Components-V2-Tags benötigen Komponenten.'
}; };
const en: Dictionary = { const en: Dictionary = {
'generic.noPermission': 'You do not have permission for this action.', 'generic.noPermission': 'You do not have permission for this action.',
@@ -1111,13 +1128,29 @@ const en: Dictionary = {
'scheduler.list.line': '`{id}` — {channel} — {timing} — {preview}', 'scheduler.list.line': '`{id}` — {channel} — {timing} — {preview}',
'scheduler.list.cron': 'Cron `{cron}`', 'scheduler.list.cron': 'Cron `{cron}`',
'scheduler.list.embedPreview': '[Embed]', 'scheduler.list.embedPreview': '[Embed]',
'scheduler.list.componentsPreview': '[Components V2]',
'scheduler.error.missing_schedule_time': 'Provide `when` or `cron`.', 'scheduler.error.missing_schedule_time': 'Provide `when` or `cron`.',
'scheduler.error.invalid_when': 'Invalid time. Use e.g. 1h, 2d, or an ISO date.', 'scheduler.error.invalid_when': 'Invalid time. Use e.g. 1h, 2d, or an ISO date.',
'scheduler.error.invalid_cron': 'Invalid cron expression (56 fields).', 'scheduler.error.invalid_cron': 'Invalid cron expression (56 fields).',
'scheduler.error.invalid_channel': 'Invalid channel.', 'scheduler.error.invalid_channel': 'Invalid channel.',
'scheduler.error.channel_unavailable': 'The bot cannot send messages in that channel.', 'scheduler.error.channel_unavailable': 'The bot cannot send messages in that channel.',
'scheduler.error.not_found': 'Scheduled message not found.', 'scheduler.error.not_found': 'Scheduled message not found.',
'scheduler.error.content_required': 'Content or embed title/description is required.' 'scheduler.error.content_required': 'Content or embed title/description is required.',
'componentsV2.error.notFound': 'This component message is no longer available.',
'componentsV2.error.actionMissing': 'No action is configured for this interaction.',
'componentsV2.error.botMissingManageRoles': 'The bot lacks Manage Roles permission.',
'componentsV2.error.roleNotFound': 'Role not found.',
'componentsV2.error.roleManaged': 'Managed roles cannot be assigned.',
'componentsV2.error.roleHierarchy': 'The role is above the bot\'s highest role.',
'componentsV2.error.noRolesSelected': 'No roles selected.',
'componentsV2.role.assigned': 'Role assigned.',
'componentsV2.role.removed': 'Role removed.',
'componentsV2.role.assignedSelected': 'Selected roles assigned.',
'utility.embed.components.sent': 'Components V2 message sent.',
'utility.embed.components.dashboardHint':
'For the full builder including interactions: {url}',
'utility.embed.components.missingText': 'Text is required.',
'tag.error.components_required': 'Components V2 tags require components.'
}; };
const locales: Record<Locale, Dictionary> = { de, en }; const locales: Record<Locale, Dictionary> = { de, en };

View File

@@ -127,9 +127,12 @@ export const LogEventTypeSchema = z.enum([
]); ]);
export type LogEventType = z.infer<typeof LogEventTypeSchema>; export type LogEventType = z.infer<typeof LogEventTypeSchema>;
export const WelcomeMessageTypeSchema = z.enum(['TEXT', 'EMBED', 'IMAGE']); export const WelcomeMessageTypeSchema = z.enum(['TEXT', 'EMBED', 'IMAGE', 'COMPONENTS_V2']);
export type WelcomeMessageType = z.infer<typeof WelcomeMessageTypeSchema>; export type WelcomeMessageType = z.infer<typeof WelcomeMessageTypeSchema>;
export const LeaveMessageTypeSchema = z.enum(['TEXT', 'EMBED', 'COMPONENTS_V2']);
export type LeaveMessageType = z.infer<typeof LeaveMessageTypeSchema>;
export const WelcomeEmbedSchema = z.object({ export const WelcomeEmbedSchema = z.object({
title: z.string().max(256).optional(), title: z.string().max(256).optional(),
description: z.string().max(4096).optional(), description: z.string().max(4096).optional(),

View File

@@ -26,7 +26,7 @@ export const SelfRoleEntrySchema = z.object({
}); });
export type SelfRoleEntry = z.infer<typeof SelfRoleEntrySchema>; export type SelfRoleEntry = z.infer<typeof SelfRoleEntrySchema>;
export const TagResponseTypeSchema = z.enum(['TEXT', 'EMBED']); export const TagResponseTypeSchema = z.enum(['TEXT', 'EMBED', 'COMPONENTS_V2']);
export type TagResponseType = z.infer<typeof TagResponseTypeSchema>; export type TagResponseType = z.infer<typeof TagResponseTypeSchema>;
export const SuggestionStatusSchema = z.enum([ export const SuggestionStatusSchema = z.enum([

View File

@@ -22,7 +22,8 @@ export const DashboardModuleIdSchema = z.enum([
'stats', 'stats',
'feeds', 'feeds',
'scheduler', 'scheduler',
'guildbackup' 'guildbackup',
'messages'
]); ]);
export type DashboardModuleId = z.infer<typeof DashboardModuleIdSchema>; export type DashboardModuleId = z.infer<typeof DashboardModuleIdSchema>;
@@ -50,6 +51,7 @@ export const DASHBOARD_MODULES = [
{ id: 'tickets', group: 'community', href: 'tickets' }, { id: 'tickets', group: 'community', href: 'tickets' },
{ id: 'selfroles', group: 'community', href: 'selfroles' }, { id: 'selfroles', group: 'community', href: 'selfroles' },
{ id: 'tags', group: 'utility', href: 'tags' }, { id: 'tags', group: 'utility', href: 'tags' },
{ id: 'messages', group: 'utility', href: 'messages' },
{ id: 'starboard', group: 'community', href: 'starboard' }, { id: 'starboard', group: 'community', href: 'starboard' },
{ id: 'suggestions', group: 'community', href: 'suggestions' }, { id: 'suggestions', group: 'community', href: 'suggestions' },
{ id: 'birthdays', group: 'community', href: 'birthdays' }, { id: 'birthdays', group: 'community', href: 'birthdays' },