Enhance component message handling and introduce new features

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

View File

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

View File

@@ -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,

View File

@@ -0,0 +1,426 @@
import {
ActionRowBuilder,
ButtonBuilder,
ButtonStyle,
ChannelSelectMenuBuilder,
ContainerBuilder,
MediaGalleryBuilder,
MediaGalleryItemBuilder,
MentionableSelectMenuBuilder,
MessageFlags,
RoleSelectMenuBuilder,
SectionBuilder,
SeparatorBuilder,
SeparatorSpacingSize,
StringSelectMenuBuilder,
TextDisplayBuilder,
ThumbnailBuilder,
UserSelectMenuBuilder,
type MessageActionRowComponentBuilder,
type MessageCreateOptions
} from 'discord.js';
import {
encodeComponentCustomId,
mapComponentsV2TextFields,
MessageComponentsV2Schema,
type ComponentButton,
type ComponentButtonStyle,
type ComponentSelect,
type ComponentV2Node,
type ComponentV2Source,
type MessageComponentsV2,
type SeparatorSpacing
} from '@nexumi/shared';
function isHttpUrl(value: string | undefined): value is string {
if (!value) {
return false;
}
try {
const url = new URL(value);
return url.protocol === 'http:' || url.protocol === 'https:';
} catch {
return false;
}
}
const BUTTON_STYLE_MAP: Record<ComponentButtonStyle, ButtonStyle> = {
Primary: ButtonStyle.Primary,
Secondary: ButtonStyle.Secondary,
Success: ButtonStyle.Success,
Danger: ButtonStyle.Danger,
Link: ButtonStyle.Link
};
const SPACING_MAP: Record<SeparatorSpacing, SeparatorSpacingSize> = {
Small: SeparatorSpacingSize.Small,
Large: SeparatorSpacingSize.Large
};
export interface BuildComponentsV2Options {
source: ComponentV2Source;
ref: string;
renderText?: (value: string) => string;
}
function buildButton(
button: ComponentButton,
options: BuildComponentsV2Options
): ButtonBuilder | null {
const builder = new ButtonBuilder()
.setLabel(button.label.slice(0, 80))
.setDisabled(Boolean(button.disabled));
if (button.emoji) {
builder.setEmoji(button.emoji);
}
if (button.style === 'Link') {
if (!isHttpUrl(button.url)) {
return null;
}
builder.setStyle(ButtonStyle.Link).setURL(button.url);
return builder;
}
if (!button.actionId) {
return null;
}
builder
.setStyle(BUTTON_STYLE_MAP[button.style] ?? ButtonStyle.Secondary)
.setCustomId(encodeComponentCustomId(options.source, options.ref, button.actionId));
return builder;
}
function buildSelect(
select: ComponentSelect,
options: BuildComponentsV2Options
): MessageActionRowComponentBuilder | null {
const placeholder = select.placeholder?.slice(0, 150);
const minValues = select.minValues;
const maxValues = select.maxValues;
if (select.type === 'string_select') {
const menu = new StringSelectMenuBuilder()
.setCustomId(
encodeComponentCustomId(
options.source,
options.ref,
select.actionId ?? select.id ?? 'select'
)
)
.setDisabled(Boolean(select.disabled))
.addOptions(
select.options.slice(0, 25).map((option) => {
const entry: {
label: string;
value: string;
description?: string;
emoji?: string;
} = {
label: option.label.slice(0, 100),
value: option.value.slice(0, 100)
};
if (option.description) {
entry.description = option.description.slice(0, 100);
}
if (option.emoji) {
entry.emoji = option.emoji;
}
return entry;
})
);
if (placeholder) {
menu.setPlaceholder(placeholder);
}
if (minValues !== undefined) {
menu.setMinValues(minValues);
}
if (maxValues !== undefined) {
menu.setMaxValues(maxValues);
}
return menu;
}
const customId = encodeComponentCustomId(options.source, options.ref, select.actionId);
let menu:
| UserSelectMenuBuilder
| RoleSelectMenuBuilder
| ChannelSelectMenuBuilder
| MentionableSelectMenuBuilder;
switch (select.type) {
case 'user_select':
menu = new UserSelectMenuBuilder().setCustomId(customId);
break;
case 'role_select':
menu = new RoleSelectMenuBuilder().setCustomId(customId);
break;
case 'channel_select':
menu = new ChannelSelectMenuBuilder().setCustomId(customId);
break;
case 'mentionable_select':
menu = new MentionableSelectMenuBuilder().setCustomId(customId);
break;
default:
return null;
}
menu.setDisabled(Boolean(select.disabled));
if (placeholder) {
menu.setPlaceholder(placeholder);
}
if (minValues !== undefined) {
menu.setMinValues(minValues);
}
if (maxValues !== undefined) {
menu.setMaxValues(maxValues);
}
return menu;
}
function buildThumbnail(url: string, description?: string, spoiler?: boolean): ThumbnailBuilder | null {
if (!isHttpUrl(url)) {
return null;
}
const thumb = new ThumbnailBuilder().setURL(url);
if (description) {
thumb.setDescription(description.slice(0, 1024));
}
if (spoiler) {
thumb.setSpoiler(true);
}
return thumb;
}
type TopLevelBuilder =
| ContainerBuilder
| TextDisplayBuilder
| SeparatorBuilder
| MediaGalleryBuilder
| SectionBuilder
| ActionRowBuilder<MessageActionRowComponentBuilder>;
function buildNode(node: ComponentV2Node, options: BuildComponentsV2Options): TopLevelBuilder | null {
switch (node.type) {
case 'text_display':
return new TextDisplayBuilder().setContent(node.content.slice(0, 4000));
case 'separator': {
const sep = new SeparatorBuilder();
if (node.divider !== undefined) {
sep.setDivider(node.divider);
}
if (node.spacing) {
sep.setSpacing(SPACING_MAP[node.spacing]);
}
return sep;
}
case 'media_gallery': {
const gallery = new MediaGalleryBuilder();
for (const item of node.items) {
if (!isHttpUrl(item.url)) {
continue;
}
const media = new MediaGalleryItemBuilder().setURL(item.url);
if (item.description) {
media.setDescription(item.description.slice(0, 1024));
}
if (item.spoiler) {
media.setSpoiler(true);
}
gallery.addItems(media);
}
return gallery;
}
case 'section': {
const section = new SectionBuilder();
const texts = Array.isArray(node.text) ? node.text : [node.text];
for (const text of texts.slice(0, 3)) {
section.addTextDisplayComponents(new TextDisplayBuilder().setContent(text.slice(0, 4000)));
}
if (node.accessory.type === 'thumbnail') {
const thumb = buildThumbnail(
node.accessory.url,
node.accessory.description,
node.accessory.spoiler
);
if (thumb) {
section.setThumbnailAccessory(thumb);
}
} else {
const button = buildButton(node.accessory, options);
if (button) {
section.setButtonAccessory(button);
}
}
return section;
}
case 'action_row': {
const row = new ActionRowBuilder<MessageActionRowComponentBuilder>();
for (const child of node.components) {
if (child.type === 'button') {
const button = buildButton(child, options);
if (button) {
row.addComponents(button);
}
} else {
const select = buildSelect(child, options);
if (select) {
row.addComponents(select);
}
}
}
return row.components.length > 0 ? row : null;
}
case 'button': {
const button = buildButton(node, options);
if (!button) {
return null;
}
return new ActionRowBuilder<MessageActionRowComponentBuilder>().addComponents(button);
}
case 'string_select':
case 'user_select':
case 'role_select':
case 'channel_select':
case 'mentionable_select': {
const select = buildSelect(node, options);
if (!select) {
return null;
}
return new ActionRowBuilder<MessageActionRowComponentBuilder>().addComponents(select);
}
case 'thumbnail':
// Thumbnails must be section accessories; skip top-level.
return null;
case 'container': {
const container = new ContainerBuilder();
if (node.accentColor !== undefined) {
container.setAccentColor(node.accentColor);
}
if (node.spoiler) {
container.setSpoiler(true);
}
for (const child of node.components) {
switch (child.type) {
case 'text_display':
container.addTextDisplayComponents(
new TextDisplayBuilder().setContent(child.content.slice(0, 4000))
);
break;
case 'separator': {
const sep = new SeparatorBuilder();
if (child.divider !== undefined) {
sep.setDivider(child.divider);
}
if (child.spacing) {
sep.setSpacing(SPACING_MAP[child.spacing]);
}
container.addSeparatorComponents(sep);
break;
}
case 'media_gallery': {
const gallery = new MediaGalleryBuilder();
for (const item of child.items) {
if (!isHttpUrl(item.url)) {
continue;
}
const media = new MediaGalleryItemBuilder().setURL(item.url);
if (item.description) {
media.setDescription(item.description.slice(0, 1024));
}
if (item.spoiler) {
media.setSpoiler(true);
}
gallery.addItems(media);
}
container.addMediaGalleryComponents(gallery);
break;
}
case 'section': {
const section = buildNode(child, options);
if (section instanceof SectionBuilder) {
container.addSectionComponents(section);
}
break;
}
case 'action_row':
case 'button':
case 'string_select':
case 'user_select':
case 'role_select':
case 'channel_select':
case 'mentionable_select': {
const row = buildNode(child, options);
if (row instanceof ActionRowBuilder) {
container.addActionRowComponents(row);
}
break;
}
case 'container':
for (const nested of child.components) {
const sibling = buildNode(nested, options);
if (sibling instanceof TextDisplayBuilder) {
container.addTextDisplayComponents(sibling);
} else if (sibling instanceof SeparatorBuilder) {
container.addSeparatorComponents(sibling);
} else if (sibling instanceof MediaGalleryBuilder) {
container.addMediaGalleryComponents(sibling);
} else if (sibling instanceof SectionBuilder) {
container.addSectionComponents(sibling);
} else if (sibling instanceof ActionRowBuilder) {
container.addActionRowComponents(sibling);
}
}
break;
default:
break;
}
}
return container;
}
default:
return null;
}
}
/**
* Builds a discord.js Components V2 message payload from shared JSON.
* Sets MessageFlags.IsComponentsV2; never includes content/embeds.
*/
export function buildComponentsV2Payload(
raw: MessageComponentsV2 | null | undefined,
options: BuildComponentsV2Options
): MessageCreateOptions | null {
const parsed = MessageComponentsV2Schema.safeParse(raw);
if (!parsed.success) {
return null;
}
const data = options.renderText
? mapComponentsV2TextFields(parsed.data, options.renderText)
: parsed.data;
const components: TopLevelBuilder[] = [];
for (const node of data.components) {
const built = buildNode(node, options);
if (built) {
components.push(built);
}
}
if (components.length === 0) {
return null;
}
return {
components,
flags: MessageFlags.IsComponentsV2
};
}
export function parseStoredComponentsV2(raw: unknown): MessageComponentsV2 | null {
const parsed = MessageComponentsV2Schema.safeParse(raw);
return parsed.success ? parsed.data : null;
}

View File

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

View File

@@ -0,0 +1,335 @@
import {
PermissionFlagsBits,
type ButtonInteraction,
type GuildMember,
type Interaction,
type MessageComponentInteraction,
type RoleSelectMenuInteraction,
type StringSelectMenuInteraction
} from 'discord.js';
import {
decodeComponentCustomId,
mapComponentsV2TextFields,
parseMessageComponentsV2,
t,
type ComponentAction,
type ComponentV2Source,
type Locale,
type MessageComponentsV2
} from '@nexumi/shared';
import type { BotContext } from '../../types.js';
import { getGuildLocale } from '../../i18n.js';
import { logger } from '../../logger.js';
export function isComponentsV2Interaction(customId: string): boolean {
return decodeComponentCustomId(customId) !== null;
}
async function loadPayload(
context: BotContext,
source: ComponentV2Source,
ref: string,
guildId: string
): Promise<MessageComponentsV2 | null> {
switch (source) {
case 'w': {
const config = await context.prisma.welcomeConfig.findUnique({ where: { guildId: ref } });
if (!config || config.guildId !== guildId) {
return null;
}
return parseMessageComponentsV2(config.welcomeComponents);
}
case 'l': {
const config = await context.prisma.welcomeConfig.findUnique({ where: { guildId: ref } });
if (!config || config.guildId !== guildId) {
return null;
}
return parseMessageComponentsV2(config.leaveComponents);
}
case 't': {
const tag = await context.prisma.tag.findUnique({ where: { id: ref } });
if (!tag || tag.guildId !== guildId) {
return null;
}
return parseMessageComponentsV2(tag.components);
}
case 's': {
const schedule = await context.prisma.scheduledMessage.findUnique({ where: { id: ref } });
if (!schedule || schedule.guildId !== guildId) {
return null;
}
return parseMessageComponentsV2(schedule.components);
}
case 'm': {
const binding = await context.prisma.componentMessageBinding.findUnique({ where: { id: ref } });
if (!binding || binding.guildId !== guildId) {
return null;
}
return parseMessageComponentsV2(binding.payload);
}
default:
return null;
}
}
function resolveStringSelectAction(
payload: MessageComponentsV2,
actionId: string,
selectedValues: string[]
): ComponentAction | null {
const direct = payload.actions[actionId];
if (direct && selectedValues.length === 0) {
return direct;
}
// Prefer per-option actionId when present.
const findOptionAction = (nodes: MessageComponentsV2['components']): ComponentAction | null => {
for (const node of nodes) {
if (node.type === 'string_select') {
for (const option of node.options) {
if (selectedValues.includes(option.value) && option.actionId) {
return payload.actions[option.actionId] ?? null;
}
}
}
if (node.type === 'action_row') {
const nested = findOptionAction(node.components as MessageComponentsV2['components']);
if (nested) {
return nested;
}
}
if (node.type === 'container') {
const nested = findOptionAction(node.components);
if (nested) {
return nested;
}
}
}
return null;
};
return findOptionAction(payload.components) ?? payload.actions[actionId] ?? null;
}
async function assertRoleAssignable(member: GuildMember, roleId: string, locale: Locale): Promise<string | null> {
const me = member.guild.members.me;
if (!me?.permissions.has(PermissionFlagsBits.ManageRoles)) {
return t(locale, 'componentsV2.error.botMissingManageRoles');
}
const role = member.guild.roles.cache.get(roleId) ?? (await member.guild.roles.fetch(roleId).catch(() => null));
if (!role) {
return t(locale, 'componentsV2.error.roleNotFound');
}
if (role.managed) {
return t(locale, 'componentsV2.error.roleManaged');
}
if (role.position >= me.roles.highest.position) {
return t(locale, 'componentsV2.error.roleHierarchy');
}
return null;
}
async function executeAction(
interaction: MessageComponentInteraction,
action: ComponentAction,
locale: Locale,
selectedRoleIds: string[]
): Promise<void> {
const member = interaction.member;
if (!member || !interaction.guild || typeof member === 'string') {
await interaction.reply({ content: t(locale, 'generic.guildOnly'), ephemeral: true });
return;
}
const guildMember =
'roles' in member && typeof (member as GuildMember).roles?.add === 'function'
? (member as GuildMember)
: await interaction.guild.members.fetch(interaction.user.id);
switch (action.type) {
case 'NONE':
await interaction.deferUpdate();
return;
case 'EPHEMERAL_REPLY':
await interaction.reply({ content: action.content ?? '—', ephemeral: true });
return;
case 'PUBLIC_REPLY':
await interaction.reply({ content: action.content ?? '—' });
return;
case 'ASSIGN_ROLE': {
const err = await assertRoleAssignable(guildMember, action.roleId!, locale);
if (err) {
await interaction.reply({ content: err, ephemeral: true });
return;
}
await guildMember.roles.add(action.roleId!);
logger.info(
{ guildId: interaction.guildId, userId: interaction.user.id, roleId: action.roleId, action: 'ASSIGN_ROLE' },
'Components V2 role action'
);
await interaction.reply({
content: t(locale, 'componentsV2.role.assigned'),
ephemeral: true
});
return;
}
case 'REMOVE_ROLE': {
const err = await assertRoleAssignable(guildMember, action.roleId!, locale);
if (err) {
await interaction.reply({ content: err, ephemeral: true });
return;
}
await guildMember.roles.remove(action.roleId!);
logger.info(
{ guildId: interaction.guildId, userId: interaction.user.id, roleId: action.roleId, action: 'REMOVE_ROLE' },
'Components V2 role action'
);
await interaction.reply({
content: t(locale, 'componentsV2.role.removed'),
ephemeral: true
});
return;
}
case 'TOGGLE_ROLE': {
const err = await assertRoleAssignable(guildMember, action.roleId!, locale);
if (err) {
await interaction.reply({ content: err, ephemeral: true });
return;
}
const hasRole = guildMember.roles.cache.has(action.roleId!);
if (hasRole) {
await guildMember.roles.remove(action.roleId!);
await interaction.reply({
content: t(locale, 'componentsV2.role.removed'),
ephemeral: true
});
} else {
await guildMember.roles.add(action.roleId!);
await interaction.reply({
content: t(locale, 'componentsV2.role.assigned'),
ephemeral: true
});
}
logger.info(
{
guildId: interaction.guildId,
userId: interaction.user.id,
roleId: action.roleId,
action: 'TOGGLE_ROLE',
removed: hasRole
},
'Components V2 role action'
);
return;
}
case 'ASSIGN_SELECTED_ROLES': {
if (selectedRoleIds.length === 0) {
await interaction.reply({
content: t(locale, 'componentsV2.error.noRolesSelected'),
ephemeral: true
});
return;
}
const assigned: string[] = [];
for (const roleId of selectedRoleIds) {
const err = await assertRoleAssignable(guildMember, roleId, locale);
if (err) {
continue;
}
await guildMember.roles.add(roleId);
assigned.push(roleId);
}
logger.info(
{ guildId: interaction.guildId, userId: interaction.user.id, roleIds: assigned, action: 'ASSIGN_SELECTED_ROLES' },
'Components V2 role action'
);
if (assigned.length === 0) {
await interaction.reply({
content: t(locale, 'componentsV2.error.roleNotFound'),
ephemeral: true
});
return;
}
await interaction.reply({
content: t(locale, 'componentsV2.role.assignedSelected'),
ephemeral: true
});
return;
}
default:
await interaction.reply({ content: t(locale, 'generic.error'), ephemeral: true });
}
}
export async function handleComponentsV2Interaction(
interaction: Interaction,
context: BotContext
): Promise<void> {
if (
!interaction.isButton() &&
!interaction.isStringSelectMenu() &&
!interaction.isUserSelectMenu() &&
!interaction.isRoleSelectMenu() &&
!interaction.isChannelSelectMenu() &&
!interaction.isMentionableSelectMenu()
) {
return;
}
const locale = await getGuildLocale(context.prisma, interaction.guildId);
if (!interaction.inGuild() || !interaction.guildId) {
await interaction.reply({ content: t(locale, 'generic.guildOnly'), ephemeral: true });
return;
}
const decoded = decodeComponentCustomId(interaction.customId);
if (!decoded) {
return;
}
const payload = await loadPayload(context, decoded.source, decoded.ref, interaction.guildId);
if (!payload) {
await interaction.reply({
content: t(locale, 'componentsV2.error.notFound'),
ephemeral: true
});
return;
}
let action: ComponentAction | null = null;
let selectedRoleIds: string[] = [];
if (interaction.isStringSelectMenu()) {
action = resolveStringSelectAction(payload, decoded.actionId, interaction.values);
} else if (interaction.isRoleSelectMenu()) {
action = payload.actions[decoded.actionId] ?? null;
selectedRoleIds = (interaction as RoleSelectMenuInteraction).values;
} else if (interaction.isButton()) {
action = payload.actions[decoded.actionId] ?? null;
} else {
action = payload.actions[decoded.actionId] ?? null;
}
if (!action) {
await interaction.reply({
content: t(locale, 'componentsV2.error.actionMissing'),
ephemeral: true
});
return;
}
// Apply placeholders for reply content when possible.
const renderedActions = mapComponentsV2TextFields(payload, (value) => value).actions;
const rendered = renderedActions[decoded.actionId] ?? action;
await executeAction(
interaction as MessageComponentInteraction,
interaction.isStringSelectMenu() ? action : rendered,
locale,
selectedRoleIds
);
}
// Re-export for typing convenience in index routing.
export type ComponentsV2ButtonInteraction = ButtonInteraction;
export type ComponentsV2StringSelectInteraction = StringSelectMenuInteraction;

View File

@@ -0,0 +1,120 @@
import {
PermissionFlagsBits,
type GuildTextBasedChannel,
type Message,
type TextChannel
} from 'discord.js';
import {
DashboardMessageSendSchema,
MessageComponentsV2Schema,
WelcomeEmbedSchema,
type MessageComponentsV2
} from '@nexumi/shared';
import { ensureGuild } from '../../guild.js';
import { buildEmbedFromPayload } from '../../lib/embed-payload.js';
import { buildComponentsV2Payload } from '../../lib/components-v2-payload.js';
import { logger } from '../../logger.js';
import type { BotContext } from '../../types.js';
export class MessageSendError extends Error {
constructor(public readonly code: string) {
super(code);
}
}
async function assertSendableChannel(
context: BotContext,
channelId: string
): Promise<GuildTextBasedChannel> {
const channel = await context.client.channels.fetch(channelId);
if (!channel?.isTextBased() || channel.isDMBased()) {
throw new MessageSendError('invalid_channel');
}
const me = channel.guild.members.me;
if (
!me?.permissionsIn(channel).has(PermissionFlagsBits.SendMessages) ||
!me.permissionsIn(channel).has(PermissionFlagsBits.ViewChannel)
) {
throw new MessageSendError('channel_unavailable');
}
return channel;
}
export type DashboardMessageSendJob = {
guildId: string;
channelId: string;
createdById: string;
mode: 'EMBED' | 'COMPONENTS_V2';
embed?: unknown;
components?: unknown;
bindingId?: string | null;
};
export type DashboardMessageSendResult = {
bindingId: string | null;
messageId: string | null;
channelId: string;
};
export async function sendDashboardMessage(
context: BotContext,
job: DashboardMessageSendJob
): Promise<DashboardMessageSendResult> {
await ensureGuild(context.prisma, job.guildId);
const input = DashboardMessageSendSchema.parse({
channelId: job.channelId,
mode: job.mode,
embed: job.embed ?? null,
components: job.components ?? null
});
const channel = await assertSendableChannel(context, input.channelId);
if (input.mode === 'EMBED') {
const embedData = WelcomeEmbedSchema.parse(input.embed);
const embed = buildEmbedFromPayload(embedData, { renderText: (value) => value });
if (!embed) {
throw new MessageSendError('content_required');
}
const message = await (channel as TextChannel).send({ embeds: [embed] });
return { bindingId: null, messageId: message.id, channelId: channel.id };
}
const components = MessageComponentsV2Schema.parse(input.components) as MessageComponentsV2;
let bindingId = job.bindingId ?? null;
if (!bindingId) {
const binding = await context.prisma.componentMessageBinding.create({
data: {
guildId: job.guildId,
channelId: input.channelId,
payload: components,
createdById: job.createdById
}
});
bindingId = binding.id;
}
const payload = buildComponentsV2Payload(components, {
source: 'm',
ref: bindingId
});
if (!payload) {
throw new MessageSendError('content_required');
}
const message: Message = await (channel as TextChannel).send(payload);
await context.prisma.componentMessageBinding.update({
where: { id: bindingId },
data: { messageId: message.id }
});
logger.info(
{ guildId: job.guildId, channelId: channel.id, messageId: message.id, bindingId },
'Dashboard Components V2 message sent'
);
return { bindingId, messageId: message.id, channelId: channel.id };
}

View File

@@ -4,10 +4,11 @@ import {
type MessageCreateOptions,
type 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);

View File

@@ -1,4 +1,4 @@
import { PermissionFlagsBits } from 'discord.js';
import { PermissionFlagsBits, type InteractionReplyOptions } from 'discord.js';
import { TagResponseTypeSchema, t, tf } from '@nexumi/shared';
import 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;
}

View File

@@ -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,

View File

@@ -180,6 +180,40 @@ export const editsnipeCommandData = applyCommandDescription(
export const embedCommandData = applyCommandDescription(
new SlashCommandBuilder().setName('embed'),
'utility.embed.description'
).addSubcommand((s) =>
applyCommandDescription(s.setName('builder'), 'utility.embed.builder.description')
);
)
.addSubcommand((s) =>
applyCommandDescription(s.setName('builder'), 'utility.embed.builder.description')
)
.addSubcommand((s) =>
applyCommandDescription(s.setName('components'), 'utility.embed.components.description')
.addChannelOption((o) =>
applyOptionDescription(
o.setName('channel').setRequired(true),
'utility.embed.components.options.channel'
)
)
.addStringOption((o) =>
applyOptionDescription(
o.setName('text').setRequired(true).setMaxLength(2000),
'utility.embed.components.options.text'
)
)
.addStringOption((o) =>
applyOptionDescription(
o.setName('image_url').setMaxLength(2048),
'utility.embed.components.options.image_url'
)
)
.addStringOption((o) =>
applyOptionDescription(
o.setName('button_label').setMaxLength(80),
'utility.embed.components.options.button_label'
)
)
.addStringOption((o) =>
applyOptionDescription(
o.setName('button_url').setMaxLength(2048),
'utility.embed.components.options.button_url'
)
)
);

View File

@@ -422,7 +422,123 @@ const embedCommand: SlashCommand = {
if (!(await requirePermission(interaction, PermissionFlagsBits.ManageMessages, locale))) {
return;
}
await showEmbedBuilderModal(interaction);
const sub = interaction.options.getSubcommand();
if (sub === 'builder') {
await showEmbedBuilderModal(interaction);
return;
}
if (sub === 'components') {
const { ChannelType } = await import('discord.js');
const channel = interaction.options.getChannel('channel', true);
const text = interaction.options.getString('text', true);
const imageUrl = interaction.options.getString('image_url');
const buttonLabel = interaction.options.getString('button_label');
const buttonUrl = interaction.options.getString('button_url');
if (!text.trim()) {
await interaction.reply({
content: t(locale, 'utility.embed.components.missingText'),
ephemeral: true
});
return;
}
if (
!channel ||
(channel.type !== ChannelType.GuildText &&
channel.type !== ChannelType.GuildAnnouncement &&
channel.type !== ChannelType.PublicThread &&
channel.type !== ChannelType.PrivateThread)
) {
await interaction.reply({
content: t(locale, 'utility.error.channel_not_found'),
ephemeral: true
});
return;
}
const containerChildren: import('@nexumi/shared').ComponentV2Node[] = [
{ type: 'text_display', content: text.trim() }
];
if (imageUrl?.trim()) {
containerChildren.push({
type: 'media_gallery',
items: [{ url: imageUrl.trim() }]
});
}
const components: import('@nexumi/shared').MessageComponentsV2 = {
components: [
{
type: 'container',
components: containerChildren
}
],
actions: {}
};
if (buttonLabel?.trim() && buttonUrl?.trim()) {
components.components.push({
type: 'action_row',
components: [
{
type: 'button',
style: 'Link',
label: buttonLabel.trim(),
url: buttonUrl.trim()
}
]
});
}
const binding = await context.prisma.componentMessageBinding.create({
data: {
guildId: interaction.guildId!,
channelId: channel.id,
payload: components,
createdById: interaction.user.id
}
});
const { buildComponentsV2Payload } = await import('../../lib/components-v2-payload.js');
const payload = buildComponentsV2Payload(components, {
source: 'm',
ref: binding.id
});
if (!payload) {
await interaction.reply({ content: t(locale, 'generic.error'), ephemeral: true });
return;
}
const textChannel = await context.client.channels.fetch(channel.id);
if (!textChannel?.isTextBased() || textChannel.isDMBased()) {
await interaction.reply({
content: t(locale, 'utility.error.channel_not_found'),
ephemeral: true
});
return;
}
const sent = await textChannel.send(payload);
await context.prisma.componentMessageBinding.update({
where: { id: binding.id },
data: { messageId: sent.id }
});
const webui = (await import('../../env.js')).env.WEBUI_URL;
const hint = webui
? tf(locale, 'utility.embed.components.dashboardHint', {
url: `${webui}/dashboard/${interaction.guildId}/messages`
})
: '';
await interaction.reply({
content: [t(locale, 'utility.embed.components.sent'), hint].filter(Boolean).join('\n'),
ephemeral: true
});
}
}
};

View File

@@ -1,4 +1,4 @@
import { PermissionFlagsBits } from 'discord.js';
import { MessageFlags, PermissionFlagsBits, type InteractionReplyOptions } from 'discord.js';
import { t } from '@nexumi/shared';
import 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!);
await channel.send(message);
if ('send' in channel) {
await channel.send(message);
}
await interaction.reply({ content: t(locale, 'welcome.testSent'), ephemeral: true });
}
};

View File

@@ -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 {

View File

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

View File

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

View File

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