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:
@@ -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) =>
|
||||
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'
|
||||
)
|
||||
)
|
||||
);
|
||||
|
||||
@@ -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
|
||||
});
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
@@ -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 });
|
||||
}
|
||||
};
|
||||
|
||||
@@ -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';
|
||||
|
||||
Reference in New Issue
Block a user