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