deploy #1
@@ -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 $$;
|
||||
@@ -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 $$;
|
||||
@@ -51,6 +51,7 @@ model Guild {
|
||||
socialFeeds SocialFeed[]
|
||||
scheduledMessages ScheduledMessage[]
|
||||
guildBackups GuildBackup[]
|
||||
componentMessageBindings ComponentMessageBinding[]
|
||||
dashboardAccessRules DashboardAccessRule[]
|
||||
dashboardAuditLogs DashboardAuditLog[]
|
||||
commandOverrides CommandOverride[]
|
||||
@@ -245,8 +246,11 @@ model WelcomeConfig {
|
||||
welcomeType String @default("TEXT")
|
||||
welcomeContent String?
|
||||
welcomeEmbed Json?
|
||||
welcomeComponents Json?
|
||||
leaveType String @default("TEXT")
|
||||
leaveContent String?
|
||||
leaveEmbed Json?
|
||||
leaveComponents Json?
|
||||
welcomeDmEnabled Boolean @default(false)
|
||||
welcomeDmContent String?
|
||||
userAutoroleIds String[] @default([])
|
||||
@@ -555,6 +559,7 @@ model Tag {
|
||||
name String
|
||||
content String?
|
||||
embed Json?
|
||||
components Json?
|
||||
responseType String @default("TEXT")
|
||||
triggerWord String?
|
||||
allowedRoleIds String[] @default([])
|
||||
@@ -774,6 +779,7 @@ model ScheduledMessage {
|
||||
creatorId String
|
||||
content String?
|
||||
embed Json?
|
||||
components Json?
|
||||
rolePingId String?
|
||||
cron String?
|
||||
runAt DateTime?
|
||||
@@ -786,6 +792,20 @@ model ScheduledMessage {
|
||||
@@index([guildId, enabled])
|
||||
}
|
||||
|
||||
model ComponentMessageBinding {
|
||||
id String @id @default(cuid())
|
||||
guildId String
|
||||
channelId String
|
||||
messageId String?
|
||||
payload Json
|
||||
createdById String
|
||||
createdAt DateTime @default(now())
|
||||
guild Guild @relation(fields: [guildId], references: [id], onDelete: Cascade)
|
||||
|
||||
@@index([guildId, createdAt])
|
||||
@@index([messageId])
|
||||
}
|
||||
|
||||
model GuildBackup {
|
||||
id String @id @default(cuid())
|
||||
guildId String
|
||||
|
||||
@@ -64,6 +64,10 @@ import {
|
||||
} from './modules/tempvoice/index.js';
|
||||
import { registerStatsEvents } from './modules/stats/index.js';
|
||||
import { handleGuildBackupButton, isGuildBackupButton } from './modules/guildbackup/index.js';
|
||||
import {
|
||||
handleComponentsV2Interaction,
|
||||
isComponentsV2Interaction
|
||||
} from './modules/components-v2/index.js';
|
||||
|
||||
const isShard = process.argv.includes('--shard');
|
||||
|
||||
@@ -220,6 +224,19 @@ if (!isShard) {
|
||||
await handleEmbedModal(interaction, context);
|
||||
return;
|
||||
}
|
||||
|
||||
if (
|
||||
(interaction.isButton() ||
|
||||
interaction.isStringSelectMenu() ||
|
||||
interaction.isUserSelectMenu() ||
|
||||
interaction.isRoleSelectMenu() ||
|
||||
interaction.isChannelSelectMenu() ||
|
||||
interaction.isMentionableSelectMenu()) &&
|
||||
isComponentsV2Interaction(interaction.customId)
|
||||
) {
|
||||
await handleComponentsV2Interaction(interaction, context);
|
||||
return;
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error({ error }, 'Interaction handling failed');
|
||||
const locale = await getGuildLocale(context.prisma, interaction.guildId);
|
||||
|
||||
@@ -18,6 +18,7 @@ import { expireBirthdayRole, runBirthdayCheck } from './modules/birthdays/index.
|
||||
import { ensureStatsRecurringJobs, startStatsWorker } from './modules/stats/index.js';
|
||||
import { pollAllFeeds } from './modules/feeds/index.js';
|
||||
import { sendScheduledMessage } from './modules/scheduler/index.js';
|
||||
import { sendDashboardMessage, type DashboardMessageSendJob } from './modules/components-v2/send.js';
|
||||
import { runGuildBackupRestoreJob } from './modules/guildbackup/index.js';
|
||||
import { runSuggestionStatusUpdateJob } from './modules/suggestions/index.js';
|
||||
import { runPresenceRefreshJob } from './presence.js';
|
||||
@@ -32,6 +33,7 @@ import {
|
||||
feedsQueueName,
|
||||
giveawayQueueName,
|
||||
guildBackupQueueName,
|
||||
messagesQueueName,
|
||||
moderationQueueName,
|
||||
presenceQueue,
|
||||
presenceQueueName,
|
||||
@@ -281,6 +283,20 @@ export function startWorkers(context: BotContext): Worker[] {
|
||||
logger.error({ jobId: job?.id, error }, 'Schedule job failed');
|
||||
});
|
||||
|
||||
const messagesWorker = new Worker<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 }>(
|
||||
guildBackupQueueName,
|
||||
async (job) => {
|
||||
@@ -349,6 +365,7 @@ export function startWorkers(context: BotContext): Worker[] {
|
||||
statsWorker,
|
||||
feedsWorker,
|
||||
scheduleWorker,
|
||||
messagesWorker,
|
||||
guildBackupWorker,
|
||||
suggestionsWorker,
|
||||
presenceWorker,
|
||||
|
||||
426
apps/bot/src/lib/components-v2-payload.ts
Normal file
426
apps/bot/src/lib/components-v2-payload.ts
Normal 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;
|
||||
}
|
||||
4
apps/bot/src/modules/components-v2/index.ts
Normal file
4
apps/bot/src/modules/components-v2/index.ts
Normal file
@@ -0,0 +1,4 @@
|
||||
export {
|
||||
handleComponentsV2Interaction,
|
||||
isComponentsV2Interaction
|
||||
} from './interactions.js';
|
||||
335
apps/bot/src/modules/components-v2/interactions.ts
Normal file
335
apps/bot/src/modules/components-v2/interactions.ts
Normal 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;
|
||||
120
apps/bot/src/modules/components-v2/send.ts
Normal file
120
apps/bot/src/modules/components-v2/send.ts
Normal 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 };
|
||||
}
|
||||
@@ -4,10 +4,11 @@ import {
|
||||
type MessageCreateOptions,
|
||||
type TextChannel
|
||||
} from 'discord.js';
|
||||
import { WelcomeEmbedSchema, expandBracketChannelMentions, t, tf } from '@nexumi/shared';
|
||||
import { WelcomeEmbedSchema, expandBracketChannelMentions, parseMessageComponentsV2, t, tf } from '@nexumi/shared';
|
||||
import type { ScheduledMessage } from '@prisma/client';
|
||||
import { ensureGuild } from '../../guild.js';
|
||||
import { buildEmbedFromPayload } from '../../lib/embed-payload.js';
|
||||
import { buildComponentsV2Payload } from '../../lib/components-v2-payload.js';
|
||||
import { logger } from '../../logger.js';
|
||||
import { scheduleQueue } from '../../queues.js';
|
||||
import type { BotContext } from '../../types.js';
|
||||
@@ -91,8 +92,25 @@ function parseStoredEmbed(raw: unknown) {
|
||||
export function buildAnnouncementPayload(schedule: {
|
||||
content: string | null;
|
||||
embed: unknown;
|
||||
components?: unknown;
|
||||
rolePingId: string | null;
|
||||
id?: string;
|
||||
}): MessageCreateOptions {
|
||||
const componentsData = parseMessageComponentsV2(schedule.components);
|
||||
if (componentsData && schedule.id) {
|
||||
const componentsPayload = buildComponentsV2Payload(componentsData, {
|
||||
source: 's',
|
||||
ref: schedule.id,
|
||||
renderText: expandBracketChannelMentions
|
||||
});
|
||||
if (componentsPayload) {
|
||||
if (schedule.rolePingId) {
|
||||
// Components V2 cannot mix classic content; role ping is skipped.
|
||||
}
|
||||
return componentsPayload;
|
||||
}
|
||||
}
|
||||
|
||||
const embedData = parseStoredEmbed(schedule.embed);
|
||||
const embed = buildEmbedFromPayload(embedData, {
|
||||
renderText: expandBracketChannelMentions
|
||||
@@ -255,7 +273,11 @@ export async function listScheduledMessages(
|
||||
.map((schedule) => {
|
||||
const preview =
|
||||
schedule.content?.slice(0, 60) ??
|
||||
(schedule.embed ? t(locale, 'scheduler.list.embedPreview') : '—');
|
||||
(schedule.components
|
||||
? t(locale, 'scheduler.list.componentsPreview')
|
||||
: schedule.embed
|
||||
? t(locale, 'scheduler.list.embedPreview')
|
||||
: '—');
|
||||
const timing = schedule.cron
|
||||
? tf(locale, 'scheduler.list.cron', { cron: schedule.cron })
|
||||
: schedule.runAt
|
||||
@@ -313,7 +335,7 @@ export async function sendScheduledMessage(context: BotContext, scheduleId: stri
|
||||
try {
|
||||
const channel = await assertSendableChannel(context, schedule.channelId);
|
||||
const payload = buildAnnouncementPayload(schedule);
|
||||
if (!payload.content && !payload.embeds?.length) {
|
||||
if (!payload.content && !payload.embeds?.length && !payload.components?.length) {
|
||||
throw new SchedulerError('content_required');
|
||||
}
|
||||
await (channel as TextChannel).send(payload);
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { PermissionFlagsBits } from 'discord.js';
|
||||
import { PermissionFlagsBits, type InteractionReplyOptions } from 'discord.js';
|
||||
import { TagResponseTypeSchema, t, tf } from '@nexumi/shared';
|
||||
import type { SlashCommand } from '../../types.js';
|
||||
import { getGuildLocale } from '../../i18n.js';
|
||||
@@ -67,7 +67,7 @@ const tagCommand: SlashCommand = {
|
||||
interaction.channelId!,
|
||||
args
|
||||
);
|
||||
await interaction.reply(payload);
|
||||
await interaction.reply(payload as InteractionReplyOptions);
|
||||
return;
|
||||
}
|
||||
|
||||
|
||||
@@ -1,15 +1,19 @@
|
||||
import type { EmbedBuilder, Guild, GuildMember } from 'discord.js';
|
||||
import type { Guild, GuildMember, MessageCreateOptions } from 'discord.js';
|
||||
import {
|
||||
applyTagPlaceholders,
|
||||
componentsV2HasContent,
|
||||
embedHasContent,
|
||||
parseMessageComponentsV2,
|
||||
TagResponseTypeSchema,
|
||||
WelcomeEmbedSchema,
|
||||
type MessageComponentsV2,
|
||||
type TagResponseType,
|
||||
type WelcomeEmbed
|
||||
} from '@nexumi/shared';
|
||||
import type { Tag } from '@prisma/client';
|
||||
import { ensureGuild } from '../../guild.js';
|
||||
import { buildEmbedFromPayload } from '../../lib/embed-payload.js';
|
||||
import { buildComponentsV2Payload } from '../../lib/components-v2-payload.js';
|
||||
import type { BotContext } from '../../types.js';
|
||||
import { assertPremiumLimit, PremiumLimitError } from '../../premium.js';
|
||||
|
||||
@@ -70,10 +74,7 @@ export function parseTagEmbed(raw: unknown) {
|
||||
return parsed.success ? parsed.data : null;
|
||||
}
|
||||
|
||||
export type TagReplyPayload = {
|
||||
content?: string;
|
||||
embeds?: EmbedBuilder[];
|
||||
};
|
||||
export type TagReplyPayload = MessageCreateOptions;
|
||||
|
||||
export function buildTagReply(
|
||||
tag: Tag,
|
||||
@@ -84,6 +85,16 @@ export function buildTagReply(
|
||||
const vars = buildTagPlaceholderVars(member, guild, args);
|
||||
const responseType = TagResponseTypeSchema.parse(tag.responseType);
|
||||
|
||||
if (responseType === 'COMPONENTS_V2') {
|
||||
const components = parseMessageComponentsV2(tag.components);
|
||||
const payload = buildComponentsV2Payload(components, {
|
||||
source: 't',
|
||||
ref: tag.id,
|
||||
renderText: (value) => renderTagContent(value, vars)
|
||||
});
|
||||
return payload ?? { content: '—' };
|
||||
}
|
||||
|
||||
if (responseType === 'EMBED') {
|
||||
const embedData = parseTagEmbed(tag.embed);
|
||||
const embed = buildEmbedFromPayload(embedData, {
|
||||
@@ -134,6 +145,7 @@ export async function createTag(
|
||||
responseType: TagResponseType;
|
||||
content?: string | null;
|
||||
embed?: WelcomeEmbed | null;
|
||||
components?: MessageComponentsV2 | null;
|
||||
triggerWord?: string | null;
|
||||
allowedRoleIds?: string[];
|
||||
allowedChannelIds?: string[];
|
||||
@@ -155,6 +167,10 @@ export async function createTag(
|
||||
throw new TagError('embed_required');
|
||||
}
|
||||
|
||||
if (params.responseType === 'COMPONENTS_V2' && !componentsV2HasContent(params.components)) {
|
||||
throw new TagError('components_required');
|
||||
}
|
||||
|
||||
const currentCount = await context.prisma.tag.count({ where: { guildId: params.guildId } });
|
||||
try {
|
||||
await assertPremiumLimit(context.prisma, params.guildId, 'tags', currentCount);
|
||||
@@ -172,6 +188,7 @@ export async function createTag(
|
||||
responseType: params.responseType,
|
||||
content: params.content ?? null,
|
||||
embed: params.embed ?? undefined,
|
||||
components: params.components ?? undefined,
|
||||
triggerWord: params.triggerWord?.trim() || null,
|
||||
allowedRoleIds: params.allowedRoleIds ?? [],
|
||||
allowedChannelIds: params.allowedChannelIds ?? [],
|
||||
@@ -189,6 +206,7 @@ export async function updateTag(
|
||||
responseType?: TagResponseType;
|
||||
content?: string | null;
|
||||
embed?: WelcomeEmbed | null;
|
||||
components?: MessageComponentsV2 | null;
|
||||
triggerWord?: string | null;
|
||||
allowedRoleIds?: string[];
|
||||
allowedChannelIds?: string[];
|
||||
@@ -202,6 +220,10 @@ export async function updateTag(
|
||||
const responseType = updates.responseType ?? TagResponseTypeSchema.parse(existing.responseType);
|
||||
const content = updates.content !== undefined ? updates.content : existing.content;
|
||||
const embed = updates.embed !== undefined ? updates.embed : parseTagEmbed(existing.embed);
|
||||
const components =
|
||||
updates.components !== undefined
|
||||
? updates.components
|
||||
: parseMessageComponentsV2(existing.components);
|
||||
|
||||
if (responseType === 'TEXT' && !content?.trim()) {
|
||||
throw new TagError('content_required');
|
||||
@@ -211,6 +233,10 @@ export async function updateTag(
|
||||
throw new TagError('embed_required');
|
||||
}
|
||||
|
||||
if (responseType === 'COMPONENTS_V2' && !componentsV2HasContent(components)) {
|
||||
throw new TagError('components_required');
|
||||
}
|
||||
|
||||
let newName: string | undefined;
|
||||
if (updates.newName) {
|
||||
newName = normalizeTagName(updates.newName);
|
||||
@@ -226,6 +252,7 @@ export async function updateTag(
|
||||
responseType,
|
||||
content,
|
||||
embed: embed ?? undefined,
|
||||
components: components ?? undefined,
|
||||
triggerWord:
|
||||
updates.triggerWord !== undefined ? updates.triggerWord?.trim() || null : existing.triggerWord,
|
||||
allowedRoleIds: updates.allowedRoleIds ?? existing.allowedRoleIds,
|
||||
|
||||
@@ -180,6 +180,40 @@ export const editsnipeCommandData = applyCommandDescription(
|
||||
export const embedCommandData = applyCommandDescription(
|
||||
new SlashCommandBuilder().setName('embed'),
|
||||
'utility.embed.description'
|
||||
).addSubcommand((s) =>
|
||||
)
|
||||
.addSubcommand((s) =>
|
||||
applyCommandDescription(s.setName('builder'), 'utility.embed.builder.description')
|
||||
)
|
||||
.addSubcommand((s) =>
|
||||
applyCommandDescription(s.setName('components'), 'utility.embed.components.description')
|
||||
.addChannelOption((o) =>
|
||||
applyOptionDescription(
|
||||
o.setName('channel').setRequired(true),
|
||||
'utility.embed.components.options.channel'
|
||||
)
|
||||
)
|
||||
.addStringOption((o) =>
|
||||
applyOptionDescription(
|
||||
o.setName('text').setRequired(true).setMaxLength(2000),
|
||||
'utility.embed.components.options.text'
|
||||
)
|
||||
)
|
||||
.addStringOption((o) =>
|
||||
applyOptionDescription(
|
||||
o.setName('image_url').setMaxLength(2048),
|
||||
'utility.embed.components.options.image_url'
|
||||
)
|
||||
)
|
||||
.addStringOption((o) =>
|
||||
applyOptionDescription(
|
||||
o.setName('button_label').setMaxLength(80),
|
||||
'utility.embed.components.options.button_label'
|
||||
)
|
||||
)
|
||||
.addStringOption((o) =>
|
||||
applyOptionDescription(
|
||||
o.setName('button_url').setMaxLength(2048),
|
||||
'utility.embed.components.options.button_url'
|
||||
)
|
||||
)
|
||||
);
|
||||
|
||||
@@ -422,7 +422,123 @@ const embedCommand: SlashCommand = {
|
||||
if (!(await requirePermission(interaction, PermissionFlagsBits.ManageMessages, locale))) {
|
||||
return;
|
||||
}
|
||||
|
||||
const sub = interaction.options.getSubcommand();
|
||||
if (sub === 'builder') {
|
||||
await showEmbedBuilderModal(interaction);
|
||||
return;
|
||||
}
|
||||
|
||||
if (sub === 'components') {
|
||||
const { ChannelType } = await import('discord.js');
|
||||
const channel = interaction.options.getChannel('channel', true);
|
||||
const text = interaction.options.getString('text', true);
|
||||
const imageUrl = interaction.options.getString('image_url');
|
||||
const buttonLabel = interaction.options.getString('button_label');
|
||||
const buttonUrl = interaction.options.getString('button_url');
|
||||
|
||||
if (!text.trim()) {
|
||||
await interaction.reply({
|
||||
content: t(locale, 'utility.embed.components.missingText'),
|
||||
ephemeral: true
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (
|
||||
!channel ||
|
||||
(channel.type !== ChannelType.GuildText &&
|
||||
channel.type !== ChannelType.GuildAnnouncement &&
|
||||
channel.type !== ChannelType.PublicThread &&
|
||||
channel.type !== ChannelType.PrivateThread)
|
||||
) {
|
||||
await interaction.reply({
|
||||
content: t(locale, 'utility.error.channel_not_found'),
|
||||
ephemeral: true
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const containerChildren: import('@nexumi/shared').ComponentV2Node[] = [
|
||||
{ type: 'text_display', content: text.trim() }
|
||||
];
|
||||
if (imageUrl?.trim()) {
|
||||
containerChildren.push({
|
||||
type: 'media_gallery',
|
||||
items: [{ url: imageUrl.trim() }]
|
||||
});
|
||||
}
|
||||
|
||||
const components: import('@nexumi/shared').MessageComponentsV2 = {
|
||||
components: [
|
||||
{
|
||||
type: 'container',
|
||||
components: containerChildren
|
||||
}
|
||||
],
|
||||
actions: {}
|
||||
};
|
||||
|
||||
if (buttonLabel?.trim() && buttonUrl?.trim()) {
|
||||
components.components.push({
|
||||
type: 'action_row',
|
||||
components: [
|
||||
{
|
||||
type: 'button',
|
||||
style: 'Link',
|
||||
label: buttonLabel.trim(),
|
||||
url: buttonUrl.trim()
|
||||
}
|
||||
]
|
||||
});
|
||||
}
|
||||
|
||||
const binding = await context.prisma.componentMessageBinding.create({
|
||||
data: {
|
||||
guildId: interaction.guildId!,
|
||||
channelId: channel.id,
|
||||
payload: components,
|
||||
createdById: interaction.user.id
|
||||
}
|
||||
});
|
||||
|
||||
const { buildComponentsV2Payload } = await import('../../lib/components-v2-payload.js');
|
||||
const payload = buildComponentsV2Payload(components, {
|
||||
source: 'm',
|
||||
ref: binding.id
|
||||
});
|
||||
if (!payload) {
|
||||
await interaction.reply({ content: t(locale, 'generic.error'), ephemeral: true });
|
||||
return;
|
||||
}
|
||||
|
||||
const textChannel = await context.client.channels.fetch(channel.id);
|
||||
if (!textChannel?.isTextBased() || textChannel.isDMBased()) {
|
||||
await interaction.reply({
|
||||
content: t(locale, 'utility.error.channel_not_found'),
|
||||
ephemeral: true
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const sent = await textChannel.send(payload);
|
||||
await context.prisma.componentMessageBinding.update({
|
||||
where: { id: binding.id },
|
||||
data: { messageId: sent.id }
|
||||
});
|
||||
|
||||
const webui = (await import('../../env.js')).env.WEBUI_URL;
|
||||
const hint = webui
|
||||
? tf(locale, 'utility.embed.components.dashboardHint', {
|
||||
url: `${webui}/dashboard/${interaction.guildId}/messages`
|
||||
})
|
||||
: '';
|
||||
|
||||
await interaction.reply({
|
||||
content: [t(locale, 'utility.embed.components.sent'), hint].filter(Boolean).join('\n'),
|
||||
ephemeral: true
|
||||
});
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { PermissionFlagsBits } from 'discord.js';
|
||||
import { MessageFlags, PermissionFlagsBits, type InteractionReplyOptions } from 'discord.js';
|
||||
import { t } from '@nexumi/shared';
|
||||
import type { SlashCommand } from '../../types.js';
|
||||
import { getGuildLocale } from '../../i18n.js';
|
||||
@@ -7,6 +7,26 @@ import { welcomeCommandData } from './command-definitions.js';
|
||||
import { getWelcomeConfig, resolveWelcomePayload } from './service.js';
|
||||
import { buildWelcomeMessage } from './renderer.js';
|
||||
|
||||
function toInteractionReply(
|
||||
message: Awaited<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 = {
|
||||
data: welcomeCommandData,
|
||||
async execute(interaction, context) {
|
||||
@@ -32,7 +52,7 @@ const welcomeCommand: SlashCommand = {
|
||||
|
||||
if (sub === 'preview') {
|
||||
const message = await buildWelcomeMessage(payload, guildMember, interaction.guild!);
|
||||
await interaction.reply({ ...message, ephemeral: true });
|
||||
await interaction.reply(toInteractionReply(message, true));
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -48,7 +68,9 @@ const welcomeCommand: SlashCommand = {
|
||||
}
|
||||
|
||||
const message = await buildWelcomeMessage(payload, guildMember, interaction.guild!);
|
||||
if ('send' in channel) {
|
||||
await channel.send(message);
|
||||
}
|
||||
await interaction.reply({ content: t(locale, 'welcome.testSent'), ephemeral: true });
|
||||
}
|
||||
};
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { PermissionFlagsBits, type GuildMember } from 'discord.js';
|
||||
import type { BotContext } from '../../types.js';
|
||||
import { getWelcomeConfig, resolveWelcomePayload, renderWelcomeText } from './service.js';
|
||||
import { getWelcomeConfig, resolveWelcomePayload, resolveLeavePayload, renderWelcomeText } from './service.js';
|
||||
import { sendLeaveMessage, sendWelcomeMessage } from './renderer.js';
|
||||
import { logger } from '../../logger.js';
|
||||
|
||||
@@ -55,8 +55,7 @@ export async function handleMemberLeave(context: BotContext, member: GuildMember
|
||||
if (!channel?.isTextBased() || channel.isDMBased()) {
|
||||
return;
|
||||
}
|
||||
const content = config.leaveContent ?? '{user.tag} left {server}.';
|
||||
await sendLeaveMessage(channel, content, member, member.guild);
|
||||
await sendLeaveMessage(channel, resolveLeavePayload(config), member, member.guild);
|
||||
}
|
||||
|
||||
export function registerWelcomeEvents(context: BotContext): void {
|
||||
|
||||
@@ -1,25 +1,37 @@
|
||||
import {
|
||||
AttachmentBuilder,
|
||||
EmbedBuilder,
|
||||
type Guild,
|
||||
type GuildMember,
|
||||
type MessageCreateOptions,
|
||||
type SendableChannels,
|
||||
type TextBasedChannel
|
||||
} from 'discord.js';
|
||||
import { createCanvas, loadImage } from '@napi-rs/canvas';
|
||||
import { buildEmbedFromPayload } from '../../lib/embed-payload.js';
|
||||
import type { WelcomePayload } from './service.js';
|
||||
import { buildComponentsV2Payload } from '../../lib/components-v2-payload.js';
|
||||
import type { LeavePayload, WelcomePayload } from './service.js';
|
||||
import { renderWelcomeText } from './service.js';
|
||||
|
||||
export async function buildWelcomeMessage(
|
||||
payload: WelcomePayload,
|
||||
member: GuildMember,
|
||||
guild: Guild
|
||||
): Promise<{ content?: string; embeds?: EmbedBuilder[]; files?: AttachmentBuilder[] }> {
|
||||
): Promise<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') {
|
||||
const embed = buildEmbedFromPayload(payload.embed, {
|
||||
renderText: (value) => renderWelcomeText(value, member, guild),
|
||||
// Keep previous Welcome behavior when no custom thumbnail is set.
|
||||
defaultThumbnailUrl: member.user.displayAvatarURL({ size: 256 })
|
||||
});
|
||||
return embed ? { embeds: [embed] } : { content: renderWelcomeText('{user}', member, guild) };
|
||||
@@ -89,13 +101,47 @@ export async function sendWelcomeMessage(
|
||||
}
|
||||
}
|
||||
|
||||
export async function buildLeaveMessage(
|
||||
payload: LeavePayload,
|
||||
member: GuildMember,
|
||||
guild: Guild
|
||||
): Promise<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(
|
||||
channel: TextBasedChannel,
|
||||
content: string,
|
||||
payload: LeavePayload | string,
|
||||
member: GuildMember,
|
||||
guild: Guild
|
||||
): Promise<void> {
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import type { PrismaClient } from '@prisma/client';
|
||||
import type { WelcomeEmbed, WelcomeMessageType } from '@nexumi/shared';
|
||||
import { applyWelcomePlaceholders } from '@nexumi/shared';
|
||||
import { applyWelcomePlaceholders, parseMessageComponentsV2 } from '@nexumi/shared';
|
||||
import type { Guild, GuildMember } from 'discord.js';
|
||||
import { ensureGuild } from '../../guild.js';
|
||||
|
||||
@@ -41,6 +41,7 @@ export type WelcomePayload = {
|
||||
type: WelcomeMessageType;
|
||||
content?: string | null;
|
||||
embed?: WelcomeEmbed | null;
|
||||
components?: import('@nexumi/shared').MessageComponentsV2 | null;
|
||||
imageTitle?: string | null;
|
||||
imageSubtitle?: string | null;
|
||||
};
|
||||
@@ -52,7 +53,26 @@ export function resolveWelcomePayload(
|
||||
type: config.welcomeType as WelcomeMessageType,
|
||||
content: config.welcomeContent,
|
||||
embed: parseWelcomeEmbed(config.welcomeEmbed),
|
||||
components: parseMessageComponentsV2(config.welcomeComponents),
|
||||
imageTitle: config.imageTitle,
|
||||
imageSubtitle: config.imageSubtitle
|
||||
};
|
||||
}
|
||||
|
||||
export type LeavePayload = {
|
||||
type: import('@nexumi/shared').LeaveMessageType;
|
||||
content?: string | null;
|
||||
embed?: WelcomeEmbed | null;
|
||||
components?: import('@nexumi/shared').MessageComponentsV2 | null;
|
||||
};
|
||||
|
||||
export function resolveLeavePayload(
|
||||
config: Awaited<ReturnType<typeof getWelcomeConfig>>
|
||||
): LeavePayload {
|
||||
return {
|
||||
type: (config.leaveType as LeavePayload['type']) || 'TEXT',
|
||||
content: config.leaveContent,
|
||||
embed: parseWelcomeEmbed(config.leaveEmbed),
|
||||
components: parseMessageComponentsV2(config.leaveComponents)
|
||||
};
|
||||
}
|
||||
|
||||
@@ -23,6 +23,8 @@ export const feedsQueueName = 'feeds';
|
||||
export const feedsQueue = new Queue(feedsQueueName, { connection: redis });
|
||||
export const scheduleQueueName = 'schedules';
|
||||
export const scheduleQueue = new Queue(scheduleQueueName, { connection: redis });
|
||||
export const messagesQueueName = 'messages';
|
||||
export const messagesQueue = new Queue(messagesQueueName, { connection: redis });
|
||||
export const guildBackupQueueName = 'guild-backups';
|
||||
export const guildBackupQueue = new Queue(guildBackupQueueName, { connection: redis });
|
||||
export const suggestionsQueueName = 'suggestions';
|
||||
|
||||
41
apps/webui/src/app/api/guilds/[guildId]/messages/route.ts
Normal file
41
apps/webui/src/app/api/guilds/[guildId]/messages/route.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
13
apps/webui/src/app/dashboard/[guildId]/messages/loading.tsx
Normal file
13
apps/webui/src/app/dashboard/[guildId]/messages/loading.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
21
apps/webui/src/app/dashboard/[guildId]/messages/page.tsx
Normal file
21
apps/webui/src/app/dashboard/[guildId]/messages/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -11,6 +11,7 @@ import {
|
||||
Gift,
|
||||
Hash,
|
||||
LayoutGrid,
|
||||
MessageSquare,
|
||||
MessageSquareQuote,
|
||||
MessagesSquare,
|
||||
PartyPopper,
|
||||
@@ -63,7 +64,8 @@ const MODULE_ICONS: Record<DashboardModuleId, LucideIcon> = {
|
||||
stats: BarChart3,
|
||||
feeds: Rss,
|
||||
scheduler: CalendarClock,
|
||||
guildbackup: Archive
|
||||
guildbackup: Archive,
|
||||
messages: MessageSquare
|
||||
};
|
||||
|
||||
interface SidebarNavProps {
|
||||
|
||||
147
apps/webui/src/components/modules/messages-manager.tsx
Normal file
147
apps/webui/src/components/modules/messages-manager.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -1,6 +1,12 @@
|
||||
'use client';
|
||||
|
||||
import { embedHasContent, type ScheduledMessageDashboard, type WelcomeEmbed } from '@nexumi/shared';
|
||||
import {
|
||||
componentsV2HasContent,
|
||||
embedHasContent,
|
||||
type MessageComponentsV2,
|
||||
type ScheduledMessageDashboard,
|
||||
type WelcomeEmbed
|
||||
} from '@nexumi/shared';
|
||||
import { Plus, Trash2 } from 'lucide-react';
|
||||
import { useState } from 'react';
|
||||
import { toast } from 'sonner';
|
||||
@@ -9,17 +15,27 @@ import { useTranslations } from '@/components/locale-provider';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import { DiscordChannelSelect } from '@/components/ui/discord-channel-select';
|
||||
import {
|
||||
DiscordComponentsV2Builder,
|
||||
emptyComponentsV2,
|
||||
normalizeComponentsV2
|
||||
} from '@/components/ui/discord-components-v2-builder';
|
||||
import { DiscordEmbedBuilder, normalizeEmbed } from '@/components/ui/discord-embed-builder';
|
||||
import { DiscordRoleSelect } from '@/components/ui/discord-role-select';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import { PlaceholderHelp } from '@/components/ui/placeholder-help';
|
||||
import { Textarea } from '@/components/ui/textarea';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
type ScheduleContentMode = 'text' | 'embed' | 'components_v2';
|
||||
|
||||
const EMPTY_DRAFT = {
|
||||
channelId: '',
|
||||
contentMode: 'text' as ScheduleContentMode,
|
||||
content: '',
|
||||
embed: null as WelcomeEmbed | null,
|
||||
components: null as MessageComponentsV2 | null,
|
||||
rolePingId: '',
|
||||
cron: '',
|
||||
runAt: ''
|
||||
@@ -30,7 +46,7 @@ function formatTiming(schedule: ScheduledMessageDashboard, t: (key: string) => s
|
||||
return `${t('modulePages.scheduler.cron')}: ${schedule.cron}`;
|
||||
}
|
||||
if (schedule.runAt) {
|
||||
return `${t('modulePages.scheduler.runAt')}: ${new Date(schedule.runAt).toLocaleString(undefined, {
|
||||
return `${new Date(schedule.runAt).toLocaleString(undefined, {
|
||||
dateStyle: 'medium',
|
||||
timeStyle: 'short'
|
||||
})}`;
|
||||
@@ -42,6 +58,9 @@ function schedulePreview(schedule: ScheduledMessageDashboard, t: (key: string) =
|
||||
if (schedule.content?.trim()) {
|
||||
return schedule.content;
|
||||
}
|
||||
if (componentsV2HasContent(schedule.components)) {
|
||||
return t('modulePages.scheduler.componentsPreview');
|
||||
}
|
||||
if (schedule.embed?.title?.trim()) {
|
||||
return schedule.embed.title;
|
||||
}
|
||||
@@ -69,13 +88,21 @@ export function SchedulerManager({ guildId, initialSchedules }: SchedulerManager
|
||||
const [creating, setCreating] = useState(false);
|
||||
|
||||
async function handleCreate() {
|
||||
const embed = normalizeEmbed(draft.embed);
|
||||
const embed = draft.contentMode === 'embed' ? normalizeEmbed(draft.embed) : null;
|
||||
const components =
|
||||
draft.contentMode === 'components_v2' ? normalizeComponentsV2(draft.components ?? emptyComponentsV2()) : null;
|
||||
const content = draft.contentMode === 'text' ? draft.content.trim() || null : null;
|
||||
|
||||
const hasContent =
|
||||
Boolean(draft.content.trim()) || embedHasContent(embed) || embed?.color !== undefined;
|
||||
Boolean(content) ||
|
||||
(draft.contentMode === 'embed' && (embedHasContent(embed) || embed?.color !== undefined)) ||
|
||||
(draft.contentMode === 'components_v2' && componentsV2HasContent(components));
|
||||
|
||||
if (!draft.channelId.trim() || !hasContent || (!draft.cron.trim() && !draft.runAt)) {
|
||||
toast.error(t('modulePages.scheduler.formIncomplete'));
|
||||
return;
|
||||
}
|
||||
|
||||
setCreating(true);
|
||||
try {
|
||||
const response = await fetch(`/api/guilds/${guildId}/scheduler`, {
|
||||
@@ -83,8 +110,9 @@ export function SchedulerManager({ guildId, initialSchedules }: SchedulerManager
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
channelId: draft.channelId.trim(),
|
||||
content: draft.content.trim() || null,
|
||||
content,
|
||||
embed,
|
||||
components,
|
||||
rolePingId: draft.rolePingId.trim() || undefined,
|
||||
cron: draft.cron.trim() || null,
|
||||
runAt: draft.cron.trim() ? null : draft.runAt ? new Date(draft.runAt).toISOString() : null
|
||||
@@ -169,6 +197,26 @@ export function SchedulerManager({ guildId, initialSchedules }: SchedulerManager
|
||||
/>
|
||||
</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">
|
||||
<Label>{t('modulePages.scheduler.content')}</Label>
|
||||
<Textarea
|
||||
@@ -178,6 +226,9 @@ export function SchedulerManager({ guildId, initialSchedules }: SchedulerManager
|
||||
/>
|
||||
<PlaceholderHelp preset="scheduler" />
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{draft.contentMode === 'embed' ? (
|
||||
<div className="space-y-2">
|
||||
<Label>{t('modulePages.scheduler.embed')}</Label>
|
||||
<DiscordEmbedBuilder
|
||||
@@ -186,6 +237,20 @@ export function SchedulerManager({ guildId, initialSchedules }: SchedulerManager
|
||||
placeholderPreset="scheduler"
|
||||
/>
|
||||
</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>
|
||||
<Button type="button" onClick={handleCreate} disabled={creating}>
|
||||
<Plus className="size-4" />
|
||||
|
||||
@@ -1,6 +1,13 @@
|
||||
'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 { useState } from 'react';
|
||||
import { toast } from 'sonner';
|
||||
@@ -9,6 +16,10 @@ import { useTranslations } from '@/components/locale-provider';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
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 { DiscordRoleMultiSelect } from '@/components/ui/discord-role-select';
|
||||
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 { Textarea } from '@/components/ui/textarea';
|
||||
|
||||
const RESPONSE_TYPES: TagResponseType[] = ['TEXT', 'EMBED'];
|
||||
const RESPONSE_TYPES: TagResponseType[] = ['TEXT', 'EMBED', 'COMPONENTS_V2'];
|
||||
|
||||
interface LocalTag extends TagDashboard {
|
||||
clientKey: string;
|
||||
@@ -28,6 +39,7 @@ const EMPTY_DRAFT = {
|
||||
name: '',
|
||||
content: '',
|
||||
embed: null as WelcomeEmbed | null,
|
||||
components: null as MessageComponentsV2 | null,
|
||||
responseType: 'TEXT' as TagResponseType,
|
||||
triggerWord: '',
|
||||
allowedRoleIds: [] as string[],
|
||||
@@ -45,20 +57,35 @@ export function TagsManager({ guildId, initialTags }: TagsManagerProps) {
|
||||
initialTags.map((tag, index) => ({
|
||||
...tag,
|
||||
embed: tag.embed ?? null,
|
||||
components: tag.components ?? null,
|
||||
clientKey: tag.id ?? `existing-${index}`
|
||||
}))
|
||||
);
|
||||
const [draft, setDraft] = useState(EMPTY_DRAFT);
|
||||
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() {
|
||||
if (!draft.name.trim()) {
|
||||
toast.error(t('modulePages.tags.nameRequired'));
|
||||
return;
|
||||
}
|
||||
const embed = draft.responseType === 'EMBED' ? normalizeEmbed(draft.embed) : null;
|
||||
if (draft.responseType === 'EMBED' && !embedHasContent(embed) && embed?.color === undefined) {
|
||||
toast.error(t('modulePages.tags.embedRequired'));
|
||||
const validated = validateTagPayload(draft.responseType, draft.embed, draft.components);
|
||||
if (!validated) {
|
||||
return;
|
||||
}
|
||||
setCreating(true);
|
||||
@@ -68,8 +95,9 @@ export function TagsManager({ guildId, initialTags }: TagsManagerProps) {
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
name: draft.name.trim(),
|
||||
content: draft.content.trim() || null,
|
||||
embed,
|
||||
content: draft.responseType === 'TEXT' ? draft.content.trim() || null : null,
|
||||
embed: validated.embed,
|
||||
components: validated.components,
|
||||
responseType: draft.responseType,
|
||||
triggerWord: draft.triggerWord.trim() || null,
|
||||
allowedRoleIds: draft.allowedRoleIds,
|
||||
@@ -98,9 +126,8 @@ export function TagsManager({ guildId, initialTags }: TagsManagerProps) {
|
||||
}
|
||||
|
||||
async function handleSave(tag: LocalTag) {
|
||||
const embed = tag.responseType === 'EMBED' ? normalizeEmbed(tag.embed ?? null) : null;
|
||||
if (tag.responseType === 'EMBED' && !embedHasContent(embed) && embed?.color === undefined) {
|
||||
toast.error(t('modulePages.tags.embedRequired'));
|
||||
const validated = validateTagPayload(tag.responseType, tag.embed ?? null, tag.components ?? null);
|
||||
if (!validated) {
|
||||
return;
|
||||
}
|
||||
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' },
|
||||
body: JSON.stringify({
|
||||
name: tag.name,
|
||||
content: tag.content,
|
||||
embed,
|
||||
content: tag.responseType === 'TEXT' ? tag.content : null,
|
||||
embed: validated.embed,
|
||||
components: validated.components,
|
||||
responseType: tag.responseType,
|
||||
triggerWord: tag.triggerWord,
|
||||
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 (
|
||||
<div className="space-y-6">
|
||||
<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')} />
|
||||
</div>
|
||||
</div>
|
||||
{draft.responseType === 'TEXT' ? (
|
||||
<div className="space-y-2">
|
||||
<Label>{t('modulePages.tags.content')}</Label>
|
||||
<Textarea rows={3} value={draft.content} onChange={(event) => setDraft((prev) => ({ ...prev, content: event.target.value }))} />
|
||||
<PlaceholderHelp preset="tags" />
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-2">
|
||||
<Label>{t('modulePages.tags.embed')}</Label>
|
||||
<DiscordEmbedBuilder
|
||||
value={draft.embed}
|
||||
onChange={(embed) => setDraft((prev) => ({ ...prev, embed }))}
|
||||
placeholderPreset="tags"
|
||||
/>
|
||||
</div>
|
||||
{renderResponseEditor(
|
||||
draft.responseType,
|
||||
draft.embed,
|
||||
draft.components,
|
||||
draft.content,
|
||||
(embed) => setDraft((prev) => ({ ...prev, embed })),
|
||||
(components) => setDraft((prev) => ({ ...prev, components })),
|
||||
(content) => setDraft((prev) => ({ ...prev, content }))
|
||||
)}
|
||||
<div className="grid gap-4 sm:grid-cols-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)))} />
|
||||
</div>
|
||||
</div>
|
||||
{tag.responseType === 'TEXT' ? (
|
||||
<div className="space-y-2">
|
||||
<Label>{t('modulePages.tags.content')}</Label>
|
||||
<Textarea rows={3} value={tag.content ?? ''} onChange={(event) => setTags((prev) => prev.map((entry) => (entry.clientKey === tag.clientKey ? { ...entry, content: event.target.value } : entry)))} />
|
||||
<PlaceholderHelp preset="tags" />
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-2">
|
||||
<Label>{t('modulePages.tags.embed')}</Label>
|
||||
<DiscordEmbedBuilder
|
||||
value={tag.embed ?? null}
|
||||
onChange={(embed) =>
|
||||
{renderResponseEditor(
|
||||
tag.responseType,
|
||||
tag.embed ?? null,
|
||||
tag.components ?? null,
|
||||
tag.content ?? '',
|
||||
(embed) =>
|
||||
setTags((prev) =>
|
||||
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="space-y-2">
|
||||
|
||||
@@ -1,10 +1,15 @@
|
||||
'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 { useTranslations } from '@/components/locale-provider';
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import { DiscordChannelSelect } from '@/components/ui/discord-channel-select';
|
||||
import {
|
||||
DiscordComponentsV2Builder,
|
||||
componentsV2FromUnknown,
|
||||
normalizeComponentsV2
|
||||
} from '@/components/ui/discord-components-v2-builder';
|
||||
import {
|
||||
DiscordEmbedBuilder,
|
||||
embedFromUnknown,
|
||||
@@ -31,16 +36,24 @@ const WELCOME_PREVIEW_VARS = {
|
||||
memberCount: '128'
|
||||
};
|
||||
|
||||
interface LocalWelcomeValue extends Omit<WelcomeConfigDashboard, 'welcomeEmbed' | 'leaveEmbed'> {
|
||||
interface LocalWelcomeValue extends Omit<
|
||||
WelcomeConfigDashboard,
|
||||
'welcomeEmbed' | 'leaveEmbed' | 'welcomeComponents' | 'leaveComponents'
|
||||
> {
|
||||
welcomeEmbed: WelcomeEmbed | null;
|
||||
leaveEmbed: WelcomeEmbed | null;
|
||||
welcomeComponents: MessageComponentsV2 | null;
|
||||
leaveComponents: MessageComponentsV2 | null;
|
||||
}
|
||||
|
||||
function toLocal(config: WelcomeConfigDashboard): LocalWelcomeValue {
|
||||
return {
|
||||
...config,
|
||||
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 = {
|
||||
...value,
|
||||
welcomeEmbed: normalizeEmbed(value.welcomeEmbed),
|
||||
leaveEmbed: normalizeEmbed(value.leaveEmbed)
|
||||
leaveEmbed: normalizeEmbed(value.leaveEmbed),
|
||||
welcomeComponents: normalizeComponentsV2(value.welcomeComponents),
|
||||
leaveComponents: normalizeComponentsV2(value.leaveComponents)
|
||||
};
|
||||
|
||||
try {
|
||||
@@ -121,11 +136,13 @@ export function WelcomeForm({ guildId, initialValue }: WelcomeFormProps) {
|
||||
<SelectItem value="TEXT">{t('modulePages.welcome.type.TEXT')}</SelectItem>
|
||||
<SelectItem value="EMBED">{t('modulePages.welcome.type.EMBED')}</SelectItem>
|
||||
<SelectItem value="IMAGE">{t('modulePages.welcome.type.IMAGE')}</SelectItem>
|
||||
<SelectItem value="COMPONENTS_V2">{t('modulePages.welcome.type.COMPONENTS_V2')}</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
</FieldAnchor>
|
||||
</div>
|
||||
{value.welcomeType === 'TEXT' ? (
|
||||
<FieldAnchor id="field-welcome-content">
|
||||
<div className="space-y-2">
|
||||
<Label>{t('modulePages.welcome.welcomeContent')}</Label>
|
||||
@@ -137,6 +154,8 @@ export function WelcomeForm({ guildId, initialValue }: WelcomeFormProps) {
|
||||
<PlaceholderHelp preset="welcome" />
|
||||
</div>
|
||||
</FieldAnchor>
|
||||
) : null}
|
||||
{value.welcomeType === 'EMBED' ? (
|
||||
<FieldAnchor id="field-welcome-embed">
|
||||
<div className="space-y-2">
|
||||
<Label>{t('modulePages.welcome.welcomeEmbed')}</Label>
|
||||
@@ -148,6 +167,21 @@ export function WelcomeForm({ guildId, initialValue }: WelcomeFormProps) {
|
||||
/>
|
||||
</div>
|
||||
</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">
|
||||
<div className="space-y-2">
|
||||
<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>
|
||||
</FieldAnchor>
|
||||
{value.welcomeType === 'IMAGE' ? (
|
||||
<>
|
||||
<div className="grid gap-4 sm:grid-cols-2">
|
||||
<FieldAnchor id="field-welcome-image-title">
|
||||
<div className="space-y-2">
|
||||
@@ -206,6 +242,8 @@ export function WelcomeForm({ guildId, initialValue }: WelcomeFormProps) {
|
||||
</FieldAnchor>
|
||||
</div>
|
||||
<PlaceholderHelp preset="welcome" />
|
||||
</>
|
||||
) : null}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
@@ -234,6 +272,27 @@ export function WelcomeForm({ guildId, initialValue }: WelcomeFormProps) {
|
||||
/>
|
||||
</div>
|
||||
</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">
|
||||
<div className="space-y-2">
|
||||
<Label>{t('modulePages.welcome.leaveContent')}</Label>
|
||||
@@ -245,6 +304,8 @@ export function WelcomeForm({ guildId, initialValue }: WelcomeFormProps) {
|
||||
<PlaceholderHelp preset="welcome" />
|
||||
</div>
|
||||
</FieldAnchor>
|
||||
) : null}
|
||||
{value.leaveType === 'EMBED' ? (
|
||||
<FieldAnchor id="field-leave-embed">
|
||||
<div className="space-y-2">
|
||||
<Label>{t('modulePages.welcome.leaveEmbed')}</Label>
|
||||
@@ -258,6 +319,21 @@ export function WelcomeForm({ guildId, initialValue }: WelcomeFormProps) {
|
||||
/>
|
||||
</div>
|
||||
</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>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
1300
apps/webui/src/components/ui/discord-components-v2-builder.tsx
Normal file
1300
apps/webui/src/components/ui/discord-components-v2-builder.tsx
Normal file
File diff suppressed because it is too large
Load Diff
@@ -242,6 +242,14 @@ export const SETTINGS_SEARCH_FIELDS: SearchIndexEntry[] = [
|
||||
href: 'welcome',
|
||||
hash: 'field-welcome-embed'
|
||||
},
|
||||
{
|
||||
id: 'setting-welcome-components',
|
||||
kind: 'setting',
|
||||
labelKey: 'modulePages.welcome.welcomeComponents',
|
||||
href: 'welcome',
|
||||
hash: 'field-welcome-components',
|
||||
keywords: ['components v2', 'layout']
|
||||
},
|
||||
{
|
||||
id: 'setting-welcome-dm',
|
||||
kind: 'setting',
|
||||
@@ -292,6 +300,13 @@ export const SETTINGS_SEARCH_FIELDS: SearchIndexEntry[] = [
|
||||
href: 'welcome',
|
||||
hash: 'field-leave-channel'
|
||||
},
|
||||
{
|
||||
id: 'setting-leave-type',
|
||||
kind: 'setting',
|
||||
labelKey: 'modulePages.welcome.leaveType',
|
||||
href: 'welcome',
|
||||
hash: 'field-leave-type'
|
||||
},
|
||||
{
|
||||
id: 'setting-leave-content',
|
||||
kind: 'setting',
|
||||
@@ -306,6 +321,14 @@ export const SETTINGS_SEARCH_FIELDS: SearchIndexEntry[] = [
|
||||
href: 'welcome',
|
||||
hash: 'field-leave-embed'
|
||||
},
|
||||
{
|
||||
id: 'setting-leave-components',
|
||||
kind: 'setting',
|
||||
labelKey: 'modulePages.welcome.leaveComponents',
|
||||
href: 'welcome',
|
||||
hash: 'field-leave-components',
|
||||
keywords: ['components v2', 'layout']
|
||||
},
|
||||
// Verification
|
||||
{
|
||||
id: 'setting-verify-enabled',
|
||||
@@ -825,6 +848,15 @@ export const SETTINGS_SEARCH_FIELDS: SearchIndexEntry[] = [
|
||||
href: 'scheduler',
|
||||
hash: 'field-scheduler-create'
|
||||
},
|
||||
// Messages
|
||||
{
|
||||
id: 'setting-messages-send',
|
||||
kind: 'setting',
|
||||
labelKey: 'modulePages.messages.sendTitle',
|
||||
href: 'messages',
|
||||
hash: 'field-messages-send',
|
||||
keywords: ['send', 'post', 'embed', 'components v2']
|
||||
},
|
||||
// Backup
|
||||
{
|
||||
id: 'setting-backup',
|
||||
|
||||
73
apps/webui/src/lib/module-configs/messages.ts
Normal file
73
apps/webui/src/lib/module-configs/messages.ts
Normal 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
|
||||
});
|
||||
}
|
||||
@@ -1,5 +1,7 @@
|
||||
import {
|
||||
MessageComponentsV2Schema,
|
||||
WelcomeEmbedSchema,
|
||||
type MessageComponentsV2,
|
||||
type ScheduledMessageDashboard,
|
||||
type ScheduledMessageDashboardCreate,
|
||||
type WelcomeEmbed
|
||||
@@ -19,6 +21,11 @@ function parseEmbed(raw: unknown): WelcomeEmbed | null {
|
||||
return parsed.success ? parsed.data : null;
|
||||
}
|
||||
|
||||
function parseComponents(raw: unknown): MessageComponentsV2 | null {
|
||||
const parsed = MessageComponentsV2Schema.safeParse(raw);
|
||||
return parsed.success ? parsed.data : null;
|
||||
}
|
||||
|
||||
function toDashboard(schedule: ScheduledMessage): ScheduledMessageDashboard {
|
||||
return {
|
||||
id: schedule.id,
|
||||
@@ -26,6 +33,7 @@ function toDashboard(schedule: ScheduledMessage): ScheduledMessageDashboard {
|
||||
creatorId: schedule.creatorId,
|
||||
content: schedule.content,
|
||||
embed: parseEmbed(schedule.embed),
|
||||
components: parseComponents(schedule.components),
|
||||
rolePingId: schedule.rolePingId,
|
||||
cron: schedule.cron,
|
||||
runAt: schedule.runAt?.toISOString() ?? null,
|
||||
@@ -84,6 +92,10 @@ export async function createScheduledMessageDashboard(
|
||||
input.embed === undefined || input.embed === null
|
||||
? Prisma.JsonNull
|
||||
: (input.embed as Prisma.InputJsonValue),
|
||||
components:
|
||||
input.components === undefined || input.components === null
|
||||
? Prisma.JsonNull
|
||||
: (input.components as Prisma.InputJsonValue),
|
||||
rolePingId: input.rolePingId || null,
|
||||
cron,
|
||||
runAt
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
import {
|
||||
MessageComponentsV2Schema,
|
||||
WelcomeEmbedSchema,
|
||||
type MessageComponentsV2,
|
||||
type TagDashboard,
|
||||
type TagDashboardCreate,
|
||||
type TagDashboardUpdate,
|
||||
@@ -26,12 +28,18 @@ function parseEmbed(raw: unknown): WelcomeEmbed | null {
|
||||
return parsed.success ? parsed.data : null;
|
||||
}
|
||||
|
||||
function parseComponents(raw: unknown): MessageComponentsV2 | null {
|
||||
const parsed = MessageComponentsV2Schema.safeParse(raw);
|
||||
return parsed.success ? parsed.data : null;
|
||||
}
|
||||
|
||||
function toDashboard(tag: Tag): TagDashboard {
|
||||
return {
|
||||
id: tag.id,
|
||||
name: tag.name,
|
||||
content: tag.content,
|
||||
embed: parseEmbed(tag.embed),
|
||||
components: parseComponents(tag.components),
|
||||
responseType: tag.responseType as TagDashboard['responseType'],
|
||||
triggerWord: tag.triggerWord,
|
||||
allowedRoleIds: tag.allowedRoleIds,
|
||||
@@ -69,6 +77,10 @@ export async function createTag(
|
||||
input.embed === undefined || input.embed === null
|
||||
? Prisma.JsonNull
|
||||
: (input.embed as Prisma.InputJsonValue),
|
||||
components:
|
||||
input.components === undefined || input.components === null
|
||||
? Prisma.JsonNull
|
||||
: (input.components as Prisma.InputJsonValue),
|
||||
responseType: input.responseType,
|
||||
triggerWord: input.triggerWord ?? null,
|
||||
allowedRoleIds: input.allowedRoleIds,
|
||||
@@ -104,6 +116,12 @@ export async function updateTag(
|
||||
...(patch.embed !== undefined
|
||||
? { embed: patch.embed === null ? Prisma.JsonNull : (patch.embed as Prisma.InputJsonValue) }
|
||||
: {}),
|
||||
...(patch.components !== undefined
|
||||
? {
|
||||
components:
|
||||
patch.components === null ? Prisma.JsonNull : (patch.components as Prisma.InputJsonValue)
|
||||
}
|
||||
: {}),
|
||||
...(patch.responseType !== undefined ? { responseType: patch.responseType } : {}),
|
||||
...(patch.triggerWord !== undefined ? { triggerWord: patch.triggerWord } : {}),
|
||||
...(patch.allowedRoleIds !== undefined ? { allowedRoleIds: patch.allowedRoleIds } : {}),
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
import {
|
||||
MessageComponentsV2Schema,
|
||||
WelcomeEmbedSchema,
|
||||
type MessageComponentsV2,
|
||||
type WelcomeConfigDashboard,
|
||||
type WelcomeConfigDashboardPatch,
|
||||
type WelcomeEmbed
|
||||
@@ -21,6 +23,11 @@ function parseEmbed(raw: unknown): WelcomeEmbed | null {
|
||||
return parsed.success ? parsed.data : null;
|
||||
}
|
||||
|
||||
function parseComponents(raw: unknown): MessageComponentsV2 | null {
|
||||
const parsed = MessageComponentsV2Schema.safeParse(raw);
|
||||
return parsed.success ? parsed.data : null;
|
||||
}
|
||||
|
||||
function toDashboard(config: Awaited<ReturnType<typeof ensureWelcomeConfig>>): WelcomeConfigDashboard {
|
||||
return {
|
||||
welcomeEnabled: config.welcomeEnabled,
|
||||
@@ -30,8 +37,11 @@ function toDashboard(config: Awaited<ReturnType<typeof ensureWelcomeConfig>>): W
|
||||
welcomeType: config.welcomeType as WelcomeConfigDashboard['welcomeType'],
|
||||
welcomeContent: config.welcomeContent,
|
||||
welcomeEmbed: parseEmbed(config.welcomeEmbed),
|
||||
welcomeComponents: parseComponents(config.welcomeComponents),
|
||||
leaveType: config.leaveType as WelcomeConfigDashboard['leaveType'],
|
||||
leaveContent: config.leaveContent,
|
||||
leaveEmbed: parseEmbed(config.leaveEmbed),
|
||||
leaveComponents: parseComponents(config.leaveComponents),
|
||||
welcomeDmEnabled: config.welcomeDmEnabled,
|
||||
welcomeDmContent: config.welcomeDmContent,
|
||||
userAutoroleIds: config.userAutoroleIds,
|
||||
@@ -52,7 +62,15 @@ export async function updateWelcomeDashboard(
|
||||
): Promise<WelcomeConfigDashboard> {
|
||||
await ensureWelcomeConfig(guildId);
|
||||
|
||||
const { welcomeChannelId, leaveChannelId, welcomeEmbed, leaveEmbed, ...rest } = patch;
|
||||
const {
|
||||
welcomeChannelId,
|
||||
leaveChannelId,
|
||||
welcomeEmbed,
|
||||
leaveEmbed,
|
||||
welcomeComponents,
|
||||
leaveComponents,
|
||||
...rest
|
||||
} = patch;
|
||||
|
||||
const data: Prisma.WelcomeConfigUpdateInput = { ...rest };
|
||||
if (welcomeChannelId !== undefined) {
|
||||
@@ -67,6 +85,14 @@ export async function updateWelcomeDashboard(
|
||||
if (leaveEmbed !== undefined) {
|
||||
data.leaveEmbed = leaveEmbed === null ? Prisma.JsonNull : (leaveEmbed as Prisma.InputJsonValue);
|
||||
}
|
||||
if (welcomeComponents !== undefined) {
|
||||
data.welcomeComponents =
|
||||
welcomeComponents === null ? Prisma.JsonNull : (welcomeComponents as Prisma.InputJsonValue);
|
||||
}
|
||||
if (leaveComponents !== undefined) {
|
||||
data.leaveComponents =
|
||||
leaveComponents === null ? Prisma.JsonNull : (leaveComponents as Prisma.InputJsonValue);
|
||||
}
|
||||
|
||||
const updated = await prisma.welcomeConfig.update({ where: { guildId }, data });
|
||||
return toDashboard(updated);
|
||||
|
||||
@@ -12,7 +12,8 @@ const REDIS_ONLY_MODULES = new Set<DashboardModuleId>([
|
||||
'selfroles',
|
||||
'feeds',
|
||||
'scheduler',
|
||||
'guildbackup'
|
||||
'guildbackup',
|
||||
'messages'
|
||||
]);
|
||||
|
||||
function redisModulesKey(guildId: string): string {
|
||||
@@ -121,7 +122,8 @@ export async function getModuleStatuses(guildId: string): Promise<ModuleStatus[]
|
||||
stats: statsConfig?.enabled ?? false,
|
||||
feeds: redisFlags.feeds ?? true,
|
||||
scheduler: redisFlags.scheduler ?? true,
|
||||
guildbackup: redisFlags.guildbackup ?? true
|
||||
guildbackup: redisFlags.guildbackup ?? true,
|
||||
messages: redisFlags.messages ?? true
|
||||
};
|
||||
|
||||
return DASHBOARD_MODULES.map((module) => ({
|
||||
|
||||
@@ -18,15 +18,18 @@ export const giveawayQueueName = 'giveaways';
|
||||
export const scheduleQueueName = 'schedules';
|
||||
export const guildBackupQueueName = 'guild-backups';
|
||||
export const suggestionsQueueName = 'suggestions';
|
||||
export const messagesQueueName = 'messages';
|
||||
|
||||
const globalForQueues = globalThis as unknown as {
|
||||
__nexumiGiveawayQueue?: Queue;
|
||||
__nexumiScheduleQueue?: Queue;
|
||||
__nexumiGuildBackupQueue?: Queue;
|
||||
__nexumiSuggestionsQueue?: Queue;
|
||||
__nexumiMessagesQueue?: Queue;
|
||||
__nexumiGiveawayQueueEvents?: QueueEvents;
|
||||
__nexumiGuildBackupQueueEvents?: QueueEvents;
|
||||
__nexumiSuggestionsQueueEvents?: QueueEvents;
|
||||
__nexumiMessagesQueueEvents?: QueueEvents;
|
||||
};
|
||||
|
||||
function queueConnection() {
|
||||
@@ -66,6 +69,13 @@ export function getSuggestionsQueue(): Queue {
|
||||
return globalForQueues.__nexumiSuggestionsQueue;
|
||||
}
|
||||
|
||||
export function getMessagesQueue(): Queue {
|
||||
globalForQueues.__nexumiMessagesQueue ??= new Queue(messagesQueueName, {
|
||||
connection: queueConnection()
|
||||
});
|
||||
return globalForQueues.__nexumiMessagesQueue;
|
||||
}
|
||||
|
||||
/**
|
||||
* QueueEvents instances are only needed for jobs where the dashboard needs
|
||||
* to wait for the bot to finish (e.g. ending a giveaway so the winners list
|
||||
@@ -92,6 +102,13 @@ export function getSuggestionsQueueEvents(): QueueEvents {
|
||||
return globalForQueues.__nexumiSuggestionsQueueEvents;
|
||||
}
|
||||
|
||||
export function getMessagesQueueEvents(): QueueEvents {
|
||||
globalForQueues.__nexumiMessagesQueueEvents ??= new QueueEvents(messagesQueueName, {
|
||||
connection: queueEventsConnection()
|
||||
});
|
||||
return globalForQueues.__nexumiMessagesQueueEvents;
|
||||
}
|
||||
|
||||
const DEFAULT_WAIT_TIMEOUT_MS = 15_000;
|
||||
|
||||
/**
|
||||
|
||||
@@ -50,6 +50,78 @@
|
||||
"timestampPreview": "gerade eben",
|
||||
"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": {
|
||||
"toggle": "Verfügbare Platzhalter anzeigen",
|
||||
"hint": "Zum Einfügen auf Kopieren tippen. Nur die hier gelisteten Platzhalter funktionieren in diesem Feld.",
|
||||
@@ -174,6 +246,10 @@
|
||||
"guildbackup": {
|
||||
"label": "Backups",
|
||||
"description": "Server-Backup und -Wiederherstellung"
|
||||
},
|
||||
"messages": {
|
||||
"label": "Nachrichten",
|
||||
"description": "Embeds oder Components-V2-Layouts in einen Kanal senden"
|
||||
}
|
||||
},
|
||||
"login": {
|
||||
@@ -361,12 +437,14 @@
|
||||
"type": {
|
||||
"TEXT": "Text",
|
||||
"EMBED": "Embed",
|
||||
"IMAGE": "Bild-Karte"
|
||||
"IMAGE": "Bild-Karte",
|
||||
"COMPONENTS_V2": "Components V2"
|
||||
},
|
||||
"welcomeContent": "Nachrichtentext",
|
||||
"contentPlaceholder": "Willkommen {user} auf {server}!",
|
||||
"placeholdersHint": "Verfügbare Platzhalter: siehe Liste unten.",
|
||||
"welcomeEmbed": "Embed",
|
||||
"welcomeComponents": "Components-V2-Layout",
|
||||
"welcomeDmEnabled": "Willkommens-DM aktiviert",
|
||||
"dmContentPlaceholder": "Optionale private Nachricht an neue Mitglieder",
|
||||
"userAutoroles": "Autoroles für Nutzer",
|
||||
@@ -377,9 +455,16 @@
|
||||
"leaveDescription": "Nachricht und Kanal, wenn ein Mitglied den Server verlässt.",
|
||||
"leaveEnabledLabel": "Abschiedsnachricht aktiviert",
|
||||
"leaveChannelId": "Abschieds-Kanal-ID",
|
||||
"leaveType": "Nachrichtentyp",
|
||||
"leaveTypes": {
|
||||
"TEXT": "Text",
|
||||
"EMBED": "Embed",
|
||||
"COMPONENTS_V2": "Components V2"
|
||||
},
|
||||
"leaveContent": "Nachrichtentext",
|
||||
"leaveContentPlaceholder": "Tschüss {user.tag} — bis bald auf {server}!",
|
||||
"leaveEmbed": "Embed",
|
||||
"leaveComponents": "Components-V2-Layout",
|
||||
"leaveEmbedTitlePlaceholder": "Auf Wiedersehen",
|
||||
"leaveEmbedDescriptionPlaceholder": "{user.tag} hat {server} verlassen."
|
||||
},
|
||||
@@ -550,12 +635,15 @@
|
||||
"responseType": "Antworttyp",
|
||||
"responseTypes": {
|
||||
"TEXT": "Text",
|
||||
"EMBED": "Embed"
|
||||
"EMBED": "Embed",
|
||||
"COMPONENTS_V2": "Components V2"
|
||||
},
|
||||
"triggerWord": "Auslöse-Wort (optional)",
|
||||
"content": "Inhalt",
|
||||
"embed": "Embed",
|
||||
"components": "Components-V2-Layout",
|
||||
"embedRequired": "Für Embed-Tags sind Titel oder Beschreibung erforderlich.",
|
||||
"componentsRequired": "Components-V2-Tags benötigen mindestens einen Block.",
|
||||
"allowedRoleIds": "Erlaubte Rollen",
|
||||
"allowedChannelIds": "Erlaubte Kanal-IDs",
|
||||
"idsPlaceholder": "Eine oder mehrere IDs, kommagetrennt",
|
||||
@@ -658,10 +746,18 @@
|
||||
"runAt": "Ausführen am",
|
||||
"content": "Nachrichtentext",
|
||||
"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",
|
||||
"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?",
|
||||
"listTitle": "Geplante Nachrichten",
|
||||
"listDescription": "Anstehende und wiederkehrende geplante Nachrichten.",
|
||||
@@ -692,6 +788,23 @@
|
||||
"confirmDelete": "Dieses Backup wirklich löschen?",
|
||||
"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": {
|
||||
"title": "Commands",
|
||||
"description": "Einzelne Slash-Commands aktivieren/deaktivieren und per Rolle, Kanal oder Cooldown einschränken.",
|
||||
|
||||
@@ -50,6 +50,78 @@
|
||||
"timestampPreview": "just now",
|
||||
"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": {
|
||||
"toggle": "Show available placeholders",
|
||||
"hint": "Tap copy to insert. Only the placeholders listed here work in this field.",
|
||||
@@ -174,6 +246,10 @@
|
||||
"guildbackup": {
|
||||
"label": "Backups",
|
||||
"description": "Server backup and restore"
|
||||
},
|
||||
"messages": {
|
||||
"label": "Messages",
|
||||
"description": "Send embeds or Components V2 layouts to a channel"
|
||||
}
|
||||
},
|
||||
"login": {
|
||||
@@ -361,12 +437,14 @@
|
||||
"type": {
|
||||
"TEXT": "Text",
|
||||
"EMBED": "Embed",
|
||||
"IMAGE": "Image card"
|
||||
"IMAGE": "Image card",
|
||||
"COMPONENTS_V2": "Components V2"
|
||||
},
|
||||
"welcomeContent": "Message content",
|
||||
"contentPlaceholder": "Welcome {user} to {server}!",
|
||||
"placeholdersHint": "Available placeholders: see the list below.",
|
||||
"welcomeEmbed": "Embed",
|
||||
"welcomeComponents": "Components V2 layout",
|
||||
"welcomeDmEnabled": "Welcome DM enabled",
|
||||
"dmContentPlaceholder": "Optional private message to new members",
|
||||
"userAutoroles": "Autoroles for users",
|
||||
@@ -377,9 +455,16 @@
|
||||
"leaveDescription": "Message and channel when a member leaves the server.",
|
||||
"leaveEnabledLabel": "Leave message enabled",
|
||||
"leaveChannelId": "Leave channel ID",
|
||||
"leaveType": "Message type",
|
||||
"leaveTypes": {
|
||||
"TEXT": "Text",
|
||||
"EMBED": "Embed",
|
||||
"COMPONENTS_V2": "Components V2"
|
||||
},
|
||||
"leaveContent": "Message content",
|
||||
"leaveContentPlaceholder": "Goodbye {user.tag} — see you around on {server}!",
|
||||
"leaveEmbed": "Embed",
|
||||
"leaveComponents": "Components V2 layout",
|
||||
"leaveEmbedTitlePlaceholder": "Goodbye",
|
||||
"leaveEmbedDescriptionPlaceholder": "{user.tag} left {server}."
|
||||
},
|
||||
@@ -550,12 +635,15 @@
|
||||
"responseType": "Response type",
|
||||
"responseTypes": {
|
||||
"TEXT": "Text",
|
||||
"EMBED": "Embed"
|
||||
"EMBED": "Embed",
|
||||
"COMPONENTS_V2": "Components V2"
|
||||
},
|
||||
"triggerWord": "Trigger word (optional)",
|
||||
"content": "Content",
|
||||
"embed": "Embed",
|
||||
"components": "Components V2 layout",
|
||||
"embedRequired": "Embed tags need a title or description.",
|
||||
"componentsRequired": "Components V2 tags need at least one block.",
|
||||
"allowedRoleIds": "Allowed roles",
|
||||
"allowedChannelIds": "Allowed channel IDs",
|
||||
"idsPlaceholder": "One or more IDs, comma-separated",
|
||||
@@ -658,10 +746,18 @@
|
||||
"runAt": "Run at",
|
||||
"content": "Message content",
|
||||
"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",
|
||||
"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?",
|
||||
"listTitle": "Scheduled messages",
|
||||
"listDescription": "Upcoming and recurring scheduled messages.",
|
||||
@@ -692,6 +788,23 @@
|
||||
"confirmDelete": "Really delete this backup?",
|
||||
"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": {
|
||||
"title": "Commands",
|
||||
"description": "Enable/disable individual slash commands and restrict them by role, channel or cooldown.",
|
||||
|
||||
@@ -170,6 +170,31 @@
|
||||
- [ ] Welcome: Liste aufklappen, Platzhalter kopieren, in Embed/Text einfügen
|
||||
- [ ] 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
|
||||
|
||||
- Deploy auf Traefik-Server manuell verifizieren; danach ggf. `deploy` → `main` mergen (nur auf Freigabe).
|
||||
|
||||
@@ -592,13 +592,37 @@ export const commandLocales = {
|
||||
en: 'Show last edited message'
|
||||
},
|
||||
'utility.embed.description': {
|
||||
de: 'Embeds erstellen',
|
||||
en: 'Create embeds'
|
||||
de: 'Embeds und Components V2 erstellen',
|
||||
en: 'Create embeds and Components V2'
|
||||
},
|
||||
'utility.embed.builder.description': {
|
||||
de: 'Embed per Modal bauen und senden',
|
||||
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': {
|
||||
de: 'Magische 8-Ball-Antwort auf deine Frage',
|
||||
en: 'Magic 8-ball answer to your question'
|
||||
|
||||
147
packages/shared/src/components-v2.test.ts
Normal file
147
packages/shared/src/components-v2.test.ts
Normal 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');
|
||||
});
|
||||
});
|
||||
562
packages/shared/src/components-v2.ts
Normal file
562
packages/shared/src/components-v2.ts
Normal 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;
|
||||
}
|
||||
@@ -1,5 +1,13 @@
|
||||
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 {
|
||||
SelfRoleBehaviorSchema,
|
||||
SelfRoleEntrySchema,
|
||||
@@ -188,7 +196,13 @@ export type LoggingConfigDashboardPatch = z.infer<typeof LoggingConfigDashboardP
|
||||
// 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({
|
||||
welcomeEnabled: z.boolean(),
|
||||
@@ -198,8 +212,11 @@ export const WelcomeConfigDashboardSchema = z.object({
|
||||
welcomeType: WelcomeMessageTypeDashboardSchema,
|
||||
welcomeContent: z.string().max(2000).nullable().optional(),
|
||||
welcomeEmbed: DashboardEmbedSchema.nullable().optional(),
|
||||
welcomeComponents: MessageComponentsV2Schema.nullable().optional(),
|
||||
leaveType: LeaveMessageTypeDashboardSchema.default('TEXT'),
|
||||
leaveContent: z.string().max(2000).nullable().optional(),
|
||||
leaveEmbed: DashboardEmbedSchema.nullable().optional(),
|
||||
leaveComponents: MessageComponentsV2Schema.nullable().optional(),
|
||||
welcomeDmEnabled: z.boolean(),
|
||||
welcomeDmContent: z.string().max(2000).nullable().optional(),
|
||||
userAutoroleIds: z.array(z.string()),
|
||||
@@ -396,6 +413,7 @@ export const TagDashboardSchema = z.object({
|
||||
name: z.string().min(1).max(50),
|
||||
content: z.string().max(2000).nullable().optional(),
|
||||
embed: DashboardEmbedSchema.nullable().optional(),
|
||||
components: MessageComponentsV2Schema.nullable().optional(),
|
||||
responseType: TagResponseTypeSchema,
|
||||
triggerWord: z.string().max(100).nullable().optional(),
|
||||
allowedRoleIds: z.array(z.string()),
|
||||
@@ -403,12 +421,24 @@ export const TagDashboardSchema = z.object({
|
||||
});
|
||||
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.responseType !== 'EMBED' ||
|
||||
embedHasContent(value.embed) ||
|
||||
value.embed?.color !== undefined,
|
||||
{ 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>;
|
||||
|
||||
@@ -560,6 +590,7 @@ export const ScheduledMessageDashboardSchema = z.object({
|
||||
creatorId: z.string(),
|
||||
content: z.string().nullable(),
|
||||
embed: DashboardEmbedSchema.nullable().optional(),
|
||||
components: MessageComponentsV2Schema.nullable().optional(),
|
||||
rolePingId: z.string().nullable(),
|
||||
cron: z.string().nullable(),
|
||||
runAt: z.string().nullable(),
|
||||
@@ -573,6 +604,7 @@ export const ScheduledMessageDashboardCreateSchema = z
|
||||
channelId: SnowflakeSchema,
|
||||
content: z.string().max(2000).nullable().optional(),
|
||||
embed: DashboardEmbedSchema.nullable().optional(),
|
||||
components: MessageComponentsV2Schema.nullable().optional(),
|
||||
rolePingId: OptionalSnowflakeSchema.optional(),
|
||||
cron: z.string().min(1).max(100).nullable().optional(),
|
||||
runAt: z
|
||||
@@ -585,14 +617,69 @@ export const ScheduledMessageDashboardCreateSchema = z
|
||||
message: 'Either cron or runAt is required'
|
||||
})
|
||||
.refine(
|
||||
(value) =>
|
||||
Boolean(value.content?.trim()) ||
|
||||
embedHasContent(value.embed) ||
|
||||
value.embed?.color !== undefined,
|
||||
{ message: 'Content or embed is required' }
|
||||
(value) => {
|
||||
const hasComponents = componentsV2HasContent(value.components);
|
||||
const hasEmbed = embedHasContent(value.embed) || value.embed?.color !== undefined;
|
||||
if (hasComponents && hasEmbed) {
|
||||
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>;
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 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
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
@@ -12,6 +12,7 @@ export * from './dashboard.js';
|
||||
export * from './owner.js';
|
||||
export * from './public.js';
|
||||
export * from './premium.js';
|
||||
export * from './components-v2.js';
|
||||
|
||||
export const LocaleSchema = z.enum(['de', 'en']);
|
||||
export type Locale = z.infer<typeof LocaleSchema>;
|
||||
@@ -575,13 +576,29 @@ const de: Dictionary = {
|
||||
'scheduler.list.line': '`{id}` — {channel} — {timing} — {preview}',
|
||||
'scheduler.list.cron': 'Cron `{cron}`',
|
||||
'scheduler.list.embedPreview': '[Embed]',
|
||||
'scheduler.list.componentsPreview': '[Components V2]',
|
||||
'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_cron': 'Ungültiger Cron-Ausdruck (5–6 Felder).',
|
||||
'scheduler.error.invalid_channel': 'Ungültiger Kanal.',
|
||||
'scheduler.error.channel_unavailable': 'Der Bot kann in diesem Kanal nicht senden.',
|
||||
'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 = {
|
||||
'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.cron': 'Cron `{cron}`',
|
||||
'scheduler.list.embedPreview': '[Embed]',
|
||||
'scheduler.list.componentsPreview': '[Components V2]',
|
||||
'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_cron': 'Invalid cron expression (5–6 fields).',
|
||||
'scheduler.error.invalid_channel': 'Invalid channel.',
|
||||
'scheduler.error.channel_unavailable': 'The bot cannot send messages in that channel.',
|
||||
'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 };
|
||||
|
||||
@@ -127,9 +127,12 @@ export const LogEventTypeSchema = z.enum([
|
||||
]);
|
||||
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 const LeaveMessageTypeSchema = z.enum(['TEXT', 'EMBED', 'COMPONENTS_V2']);
|
||||
export type LeaveMessageType = z.infer<typeof LeaveMessageTypeSchema>;
|
||||
|
||||
export const WelcomeEmbedSchema = z.object({
|
||||
title: z.string().max(256).optional(),
|
||||
description: z.string().max(4096).optional(),
|
||||
|
||||
@@ -26,7 +26,7 @@ export const SelfRoleEntrySchema = z.object({
|
||||
});
|
||||
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 const SuggestionStatusSchema = z.enum([
|
||||
|
||||
@@ -22,7 +22,8 @@ export const DashboardModuleIdSchema = z.enum([
|
||||
'stats',
|
||||
'feeds',
|
||||
'scheduler',
|
||||
'guildbackup'
|
||||
'guildbackup',
|
||||
'messages'
|
||||
]);
|
||||
|
||||
export type DashboardModuleId = z.infer<typeof DashboardModuleIdSchema>;
|
||||
@@ -50,6 +51,7 @@ export const DASHBOARD_MODULES = [
|
||||
{ id: 'tickets', group: 'community', href: 'tickets' },
|
||||
{ id: 'selfroles', group: 'community', href: 'selfroles' },
|
||||
{ id: 'tags', group: 'utility', href: 'tags' },
|
||||
{ id: 'messages', group: 'utility', href: 'messages' },
|
||||
{ id: 'starboard', group: 'community', href: 'starboard' },
|
||||
{ id: 'suggestions', group: 'community', href: 'suggestions' },
|
||||
{ id: 'birthdays', group: 'community', href: 'birthdays' },
|
||||
|
||||
Reference in New Issue
Block a user