diff --git a/Dockerfile b/Dockerfile index 1f380d8..fc9477e 100644 --- a/Dockerfile +++ b/Dockerfile @@ -16,6 +16,8 @@ FROM node:20-alpine AS runner WORKDIR /app ENV NODE_ENV=production +RUN apk add --no-cache fontconfig ttf-dejavu + COPY package.json package-lock.json ./ RUN npm ci --omit=dev diff --git a/database/migrations/0003_member_message_auto_roles.sql b/database/migrations/0003_member_message_auto_roles.sql new file mode 100644 index 0000000..d56949f --- /dev/null +++ b/database/migrations/0003_member_message_auto_roles.sql @@ -0,0 +1,2 @@ +ALTER TABLE bot_member_message_configs + ADD COLUMN IF NOT EXISTS auto_role_ids TEXT NOT NULL DEFAULT '[]'; diff --git a/src/database/stores/memberMessageStore.ts b/src/database/stores/memberMessageStore.ts index 5d07998..55843b6 100644 --- a/src/database/stores/memberMessageStore.ts +++ b/src/database/stores/memberMessageStore.ts @@ -8,6 +8,7 @@ import type { import { createDefaultMemberMessageConfig, isMemberMessageRenderTypeValue, + sanitizeMemberMessageRoleIds, } from "../../validators/memberMessages.js"; import type { MemberMessageRepository } from "../../modules/memberMessages/index.js"; @@ -19,11 +20,30 @@ SELECT enabled, channel_id, message_type, + auto_role_ids, updated_at FROM bot_member_message_configs LIMIT 0; `; +const parseRoleIds = (serialized: string | null): string[] => { + if (!serialized || serialized.trim().length === 0) { + return []; + } + + try { + const parsed = JSON.parse(serialized) as unknown; + if (!Array.isArray(parsed)) { + return []; + } + + const roleIds = parsed.filter((value): value is string => typeof value === "string"); + return sanitizeMemberMessageRoleIds(roleIds); + } catch { + return []; + } +}; + const toConfig = (row: MemberMessageRow): MemberMessageConfig => { const fallback = createDefaultMemberMessageConfig(); @@ -31,6 +51,7 @@ const toConfig = (row: MemberMessageRow): MemberMessageConfig => { enabled: row.enabled, channelId: row.channel_id, messageType: isMemberMessageRenderTypeValue(row.message_type) ? row.message_type : fallback.messageType, + autoRoleIds: parseRoleIds(row.auto_role_ids), }; }; @@ -51,7 +72,7 @@ export class PostgresMemberMessageStore implements MemberMessageRepository { public async getByBotGuildKind(botId: string, guildId: string, kind: MemberMessageKind): Promise { const result = await this.pool.query( ` - SELECT enabled, channel_id, message_type + SELECT enabled, channel_id, message_type, auto_role_ids FROM bot_member_message_configs WHERE bot_id = $1 AND guild_id = $2 AND kind = $3 LIMIT 1 @@ -73,6 +94,8 @@ export class PostgresMemberMessageStore implements MemberMessageRepository { kind: MemberMessageKind, config: MemberMessageConfig, ): Promise { + const autoRoleIds = sanitizeMemberMessageRoleIds(config.autoRoleIds); + await this.pool.query( ` INSERT INTO bot_member_message_configs ( @@ -82,17 +105,19 @@ export class PostgresMemberMessageStore implements MemberMessageRepository { enabled, channel_id, message_type, + auto_role_ids, updated_at ) - VALUES ($1, $2, $3, $4, $5, $6, NOW()) + VALUES ($1, $2, $3, $4, $5, $6, $7, NOW()) ON CONFLICT (bot_id, guild_id, kind) DO UPDATE SET enabled = EXCLUDED.enabled, channel_id = EXCLUDED.channel_id, message_type = EXCLUDED.message_type, + auto_role_ids = EXCLUDED.auto_role_ids, updated_at = NOW() `, - [botId, guildId, kind, config.enabled, config.channelId, config.messageType], + [botId, guildId, kind, config.enabled, config.channelId, config.messageType, JSON.stringify(autoRoleIds)], ); } diff --git a/src/events/guildMemberAdd.ts b/src/events/guildMemberAdd.ts index cce0761..d96a9a4 100644 --- a/src/events/guildMemberAdd.ts +++ b/src/events/guildMemberAdd.ts @@ -12,6 +12,35 @@ export const registerGuildMemberAdd = ( memberMessageService: MemberMessageService, ): void => { client.on(Events.GuildMemberAdd, (member) => { + void memberMessageService.assignWelcomeAutoRoles({ client, member }).then((result) => { + if (result.assigned) { + return; + } + + if (result.reason === "no_roles_configured" || result.reason === "no_assignable_roles") { + return; + } + + log.warn( + { + guildId: member.guild.id, + userId: member.user.id, + reason: result.reason, + configuredRoleIds: result.configuredRoleIds, + }, + "failed to assign welcome auto roles", + ); + }).catch((error) => { + log.error( + { + guildId: member.guild.id, + userId: member.user.id, + err: error, + }, + "welcome auto-role dispatch crashed", + ); + }); + void memberMessageService.dispatch({ client, i18n, diff --git a/src/features/memberMessages/commandPanel.ts b/src/features/memberMessages/commandPanel.ts index 2b7ac5d..814a79a 100644 --- a/src/features/memberMessages/commandPanel.ts +++ b/src/features/memberMessages/commandPanel.ts @@ -6,6 +6,7 @@ import { ChannelType, ContainerBuilder, MessageFlags, + RoleSelectMenuBuilder, StringSelectMenuBuilder, TextDisplayBuilder, } from "discord.js"; @@ -26,6 +27,7 @@ import type { import { MEMBER_MESSAGE_RENDER_TYPES, isMemberMessageRenderTypeValue, + sanitizeMemberMessageRoleIds, } from "../../validators/memberMessages.js"; import type { MemberMessageService } from "./service.js"; @@ -44,8 +46,12 @@ const createCustomIds = (kind: MemberMessageKind): MemberMessageCustomIds => { toggleButton: `${kind}:toggle:${nonce}`, channelButton: `${kind}:channel:${nonce}`, channelCancelButton: `${kind}:channel-cancel:${nonce}`, + roleButton: `${kind}:roles:${nonce}`, + roleCancelButton: `${kind}:roles-cancel:${nonce}`, + roleClearButton: `${kind}:roles-clear:${nonce}`, typeSelect: `${kind}:type:${nonce}`, channelSelect: `${kind}:channel-select:${nonce}`, + roleSelect: `${kind}:role-select:${nonce}`, testButton: `${kind}:test:${nonce}`, }; }; @@ -58,8 +64,18 @@ const messageTypeLabel = (ctx: CommandExecutionContext, messageType: MemberMessa return ctx.ct(`ui.type.options.${messageType}.label`); }; +const autoRolesLabel = (ctx: CommandExecutionContext, config: MemberMessageConfig): string => { + const roleIds = sanitizeMemberMessageRoleIds(config.autoRoleIds); + if (roleIds.length === 0) { + return ctx.ct("ui.autoRolesNotConfigured"); + } + + return roleIds.map((roleId) => `<@&${roleId}>`).join(", "); +}; + const panelContent = ( ctx: CommandExecutionContext, + kind: MemberMessageKind, config: MemberMessageConfig, uiState: MemberMessagePanelUiState, ): string => { @@ -73,15 +89,24 @@ const panelContent = ( `${ctx.ct("ui.embed.fields.type")}: ${messageTypeLabel(ctx, config.messageType)}`, ]; + if (kind === "welcome") { + lines.push(`${ctx.ct("ui.embed.fields.autoRoles")}: ${autoRolesLabel(ctx, config)}`); + } + if (uiState.channelPickerOpen) { lines.push("", `${ctx.ct("ui.embed.fields.channelPicker")}: ${ctx.ct("ui.channelPickerHint")}`); } + if (kind === "welcome" && uiState.rolePickerOpen) { + lines.push("", `${ctx.ct("ui.embed.fields.autoRoles")}: ${ctx.ct("ui.rolePickerHint")}`); + } + return lines.join("\n"); }; const buildContainer = ( ctx: CommandExecutionContext, + kind: MemberMessageKind, customIds: MemberMessageCustomIds, config: MemberMessageConfig, uiState: MemberMessagePanelUiState, @@ -99,6 +124,18 @@ const buildContainer = ( .setStyle(ButtonStyle.Secondary) .setDisabled(disabled); + const roleButton = new ButtonBuilder() + .setCustomId(uiState.rolePickerOpen ? customIds.roleCancelButton : customIds.roleButton) + .setLabel(uiState.rolePickerOpen ? ctx.ct("ui.buttons.rolesCancel") : ctx.ct("ui.buttons.roles")) + .setStyle(ButtonStyle.Secondary) + .setDisabled(disabled); + + const clearRolesButton = new ButtonBuilder() + .setCustomId(customIds.roleClearButton) + .setLabel(ctx.ct("ui.buttons.rolesClear")) + .setStyle(ButtonStyle.Danger) + .setDisabled(disabled || config.autoRoleIds.length === 0); + const testButton = new ButtonBuilder() .setCustomId(customIds.testButton) .setLabel(ctx.ct("ui.buttons.test")) @@ -122,11 +159,25 @@ const buildContainer = ( const container = new ContainerBuilder(); container.addTextDisplayComponents( - new TextDisplayBuilder().setContent(panelContent(ctx, config, uiState)), - ); - container.addActionRowComponents( - new ActionRowBuilder().addComponents(toggleButton, channelButton, testButton), + new TextDisplayBuilder().setContent(panelContent(ctx, kind, config, uiState)), ); + + if (kind === "welcome") { + container.addActionRowComponents( + new ActionRowBuilder().addComponents( + toggleButton, + channelButton, + roleButton, + clearRolesButton, + testButton, + ), + ); + } else { + container.addActionRowComponents( + new ActionRowBuilder().addComponents(toggleButton, channelButton, testButton), + ); + } + container.addActionRowComponents( new ActionRowBuilder().addComponents(typeSelect), ); @@ -145,6 +196,19 @@ const buildContainer = ( ); } + if (kind === "welcome" && uiState.rolePickerOpen) { + const roleSelect = new RoleSelectMenuBuilder() + .setCustomId(customIds.roleSelect) + .setPlaceholder(ctx.ct("ui.rolePickerPlaceholder")) + .setMinValues(1) + .setMaxValues(25) + .setDisabled(disabled); + + container.addActionRowComponents( + new ActionRowBuilder().addComponents(roleSelect), + ); + } + return container; }; @@ -186,11 +250,12 @@ export const createMemberMessagePanelExecute = ( const customIds = createCustomIds(kind); const uiState: MemberMessagePanelUiState = { channelPickerOpen: false, + rolePickerOpen: false, }; const replyResult = await ctx.reply({ flags: MessageFlags.IsComponentsV2, - components: [buildContainer(ctx, customIds, config, uiState)], + components: [buildContainer(ctx, kind, customIds, config, uiState)], withResponse: true, }); @@ -210,7 +275,7 @@ export const createMemberMessagePanelExecute = ( await replyMessage .edit({ flags: MessageFlags.IsComponentsV2, - components: [buildContainer(ctx, customIds, config, uiState, true)], + components: [buildContainer(ctx, kind, customIds, config, uiState, true)], }) .catch(() => undefined); }; @@ -237,16 +302,17 @@ export const createMemberMessagePanelExecute = ( await saveConfig(); await interaction.update({ flags: MessageFlags.IsComponentsV2, - components: [buildContainer(ctx, customIds, config, uiState)], + components: [buildContainer(ctx, kind, customIds, config, uiState)], }); return; } if (interaction.customId === customIds.channelButton) { uiState.channelPickerOpen = true; + uiState.rolePickerOpen = false; await interaction.update({ flags: MessageFlags.IsComponentsV2, - components: [buildContainer(ctx, customIds, config, uiState)], + components: [buildContainer(ctx, kind, customIds, config, uiState)], }); return; } @@ -255,7 +321,36 @@ export const createMemberMessagePanelExecute = ( uiState.channelPickerOpen = false; await interaction.update({ flags: MessageFlags.IsComponentsV2, - components: [buildContainer(ctx, customIds, config, uiState)], + components: [buildContainer(ctx, kind, customIds, config, uiState)], + }); + return; + } + + if (kind === "welcome" && interaction.customId === customIds.roleButton) { + uiState.rolePickerOpen = true; + uiState.channelPickerOpen = false; + await interaction.update({ + flags: MessageFlags.IsComponentsV2, + components: [buildContainer(ctx, kind, customIds, config, uiState)], + }); + return; + } + + if (kind === "welcome" && interaction.customId === customIds.roleCancelButton) { + uiState.rolePickerOpen = false; + await interaction.update({ + flags: MessageFlags.IsComponentsV2, + components: [buildContainer(ctx, kind, customIds, config, uiState)], + }); + return; + } + + if (kind === "welcome" && interaction.customId === customIds.roleClearButton) { + config.autoRoleIds = []; + await saveConfig(); + await interaction.update({ + flags: MessageFlags.IsComponentsV2, + components: [buildContainer(ctx, kind, customIds, config, uiState)], }); return; } @@ -307,7 +402,18 @@ export const createMemberMessagePanelExecute = ( await saveConfig(); await interaction.update({ flags: MessageFlags.IsComponentsV2, - components: [buildContainer(ctx, customIds, config, uiState)], + components: [buildContainer(ctx, kind, customIds, config, uiState)], + }); + return; + } + + if (kind === "welcome" && interaction.isRoleSelectMenu() && interaction.customId === customIds.roleSelect) { + config.autoRoleIds = sanitizeMemberMessageRoleIds([...config.autoRoleIds, ...interaction.values]); + uiState.rolePickerOpen = false; + await saveConfig(); + await interaction.update({ + flags: MessageFlags.IsComponentsV2, + components: [buildContainer(ctx, kind, customIds, config, uiState)], }); return; } @@ -327,7 +433,7 @@ export const createMemberMessagePanelExecute = ( await saveConfig(); await interaction.update({ flags: MessageFlags.IsComponentsV2, - components: [buildContainer(ctx, customIds, config, uiState)], + components: [buildContainer(ctx, kind, customIds, config, uiState)], }); return; } diff --git a/src/features/memberMessages/imageRenderer.ts b/src/features/memberMessages/imageRenderer.ts index f311ace..8b34242 100644 --- a/src/features/memberMessages/imageRenderer.ts +++ b/src/features/memberMessages/imageRenderer.ts @@ -9,6 +9,7 @@ const AVATAR_X = 42; const AVATAR_Y = (CARD_HEIGHT - AVATAR_SIZE) / 2; const TEXT_X = AVATAR_X + AVATAR_SIZE + 56; const TEXT_MAX_WIDTH = CARD_WIDTH - TEXT_X - 36; +const FONT_FAMILY = '"DejaVu Sans", sans-serif'; const fitFontSize = ( context: SKRSContext2D, @@ -21,7 +22,7 @@ const fitFontSize = ( let size = startSize; while (size > minSize) { - context.font = `${fontWeight} ${size}px sans-serif`; + context.font = `${fontWeight} ${size}px ${FONT_FAMILY}`; if (context.measureText(text).width <= maxWidth) { break; } @@ -110,7 +111,7 @@ const drawAvatar = async ( } catch { const initial = username.trim().charAt(0).toUpperCase() || "?"; context.fillStyle = "#f8fafc"; - context.font = "700 92px sans-serif"; + context.font = `700 92px ${FONT_FAMILY}`; context.textAlign = "center"; context.textBaseline = "middle"; context.fillText(initial, AVATAR_X + AVATAR_SIZE / 2, AVATAR_Y + AVATAR_SIZE / 2); @@ -153,12 +154,12 @@ export const renderMemberMessageImage = async (input: MemberMessageImageInput): await drawAvatar(context, input.username, input.avatarUrl); const headlineSize = fitFontSize(context, input.title, 84, 52, TEXT_MAX_WIDTH, 700); - context.font = `700 ${headlineSize}px sans-serif`; + context.font = `700 ${headlineSize}px ${FONT_FAMILY}`; context.fillStyle = "#f8fafc"; context.fillText(input.title, TEXT_X, 116); const subtitleSize = fitFontSize(context, input.subtitle, 58, 30, TEXT_MAX_WIDTH, 500); - context.font = `500 ${subtitleSize}px sans-serif`; + context.font = `500 ${subtitleSize}px ${FONT_FAMILY}`; context.fillStyle = "#e5e7eb"; const subtitleLines = wrapText(context, input.subtitle, TEXT_MAX_WIDTH, 2); @@ -169,7 +170,7 @@ export const renderMemberMessageImage = async (input: MemberMessageImageInput): }); const usernameSize = fitFontSize(context, input.username, 54, 28, TEXT_MAX_WIDTH, 700); - context.font = `700 ${usernameSize}px sans-serif`; + context.font = `700 ${usernameSize}px ${FONT_FAMILY}`; context.fillStyle = input.kind === "welcome" ? "#93c5fd" : "#fda4af"; context.fillText(input.username, TEXT_X, CARD_HEIGHT - 34); diff --git a/src/features/memberMessages/service.ts b/src/features/memberMessages/service.ts index e39d634..b2d3b44 100644 --- a/src/features/memberMessages/service.ts +++ b/src/features/memberMessages/service.ts @@ -6,6 +6,7 @@ import { PermissionFlagsBits, TextDisplayBuilder, type Guild, + type GuildMember, type MessageCreateOptions, type User, } from "discord.js"; @@ -13,6 +14,8 @@ import { import type { I18nService } from "../../i18n/index.js"; import type { SupportedLang } from "../../types/command.js"; import type { + AssignWelcomeAutoRolesInput, + AssignWelcomeAutoRolesResult, DispatchMemberMessageInput, DispatchMemberMessageResult, MemberMessageConfig, @@ -21,6 +24,7 @@ import type { SendableChannel, TemplateSuffix, } from "../../types/memberMessages.js"; +import { sanitizeMemberMessageRoleIds } from "../../validators/memberMessages.js"; import { renderMemberMessageImage } from "./imageRenderer.js"; import type { MemberMessageRepository } from "./repository.js"; @@ -117,6 +121,35 @@ const resolveTemplate = ( return translated === key ? defaultTemplate(kind, suffix, vars) : translated; }; +const resolveNonEmptyTemplate = ( + i18n: I18nService | undefined, + lang: SupportedLang, + kind: MemberMessageKind, + suffix: TemplateSuffix, + vars: Record, +): string => { + const resolved = resolveTemplate(i18n, lang, kind, suffix, vars).trim(); + if (resolved.length > 0) { + return resolved; + } + + const fallback = defaultTemplate(kind, suffix, vars).trim(); + return fallback.length > 0 ? fallback : suffix; +}; + +const resolveAssignableRoleId = async (member: GuildMember, roleId: string): Promise => { + if (roleId === member.guild.id) { + return null; + } + + const role = member.guild.roles.cache.get(roleId) ?? await member.guild.roles.fetch(roleId).catch(() => null); + if (!role || !role.editable) { + return null; + } + + return role.id; +}; + const buildMemberMessagePayload = async ( i18n: I18nService | undefined, lang: SupportedLang, @@ -165,10 +198,13 @@ const buildMemberMessagePayload = async ( }; } + const imageTitle = resolveNonEmptyTemplate(i18n, lang, kind, "imageTitle", vars); + const imageSubtitle = resolveNonEmptyTemplate(i18n, lang, kind, "imageDescription", vars); + const imageBuffer = await renderMemberMessageImage({ kind, - title: resolveTemplate(i18n, lang, kind, "imageTitle", vars), - subtitle: resolveTemplate(i18n, lang, kind, "imageDescription", vars), + title: imageTitle, + subtitle: imageSubtitle, username: user.globalName ?? user.username, avatarUrl: user.displayAvatarURL({ extension: "png", size: 512 }), }); @@ -206,6 +242,99 @@ export class MemberMessageService { await this.repository.deleteByBotGuild(botId, guildId); } + public async assignWelcomeAutoRoles(input: AssignWelcomeAutoRolesInput): Promise { + const botId = this.resolveBotId(input.client); + if (!botId) { + return { + assigned: false, + reason: "bot_not_ready", + configuredRoleIds: [], + appliedRoleIds: [], + skippedRoleIds: [], + }; + } + + const config = await this.repository.getByBotGuildKind(botId, input.member.guild.id, "welcome"); + const configuredRoleIds = sanitizeMemberMessageRoleIds(config.autoRoleIds); + + if (configuredRoleIds.length === 0) { + return { + assigned: false, + reason: "no_roles_configured", + configuredRoleIds, + appliedRoleIds: [], + skippedRoleIds: [], + }; + } + + const me = input.member.guild.members.me; + if (!me || !me.permissions.has(PermissionFlagsBits.ManageRoles)) { + return { + assigned: false, + reason: "missing_permissions", + configuredRoleIds, + appliedRoleIds: [], + skippedRoleIds: [...configuredRoleIds], + }; + } + + if (!input.member.manageable) { + return { + assigned: false, + reason: "member_not_manageable", + configuredRoleIds, + appliedRoleIds: [], + skippedRoleIds: [...configuredRoleIds], + }; + } + + const resolvedRoleIds = await Promise.all( + configuredRoleIds.map((roleId) => resolveAssignableRoleId(input.member, roleId)), + ); + + const appliedRoleIds = resolvedRoleIds.filter((roleId): roleId is string => roleId !== null); + const skippedRoleIds = configuredRoleIds.filter((roleId) => !appliedRoleIds.includes(roleId)); + + if (appliedRoleIds.length === 0) { + return { + assigned: false, + reason: "no_assignable_roles", + configuredRoleIds, + appliedRoleIds: [], + skippedRoleIds, + }; + } + + try { + await input.member.roles.add(appliedRoleIds, "welcome auto roles"); + return { + assigned: true, + reason: "assigned", + configuredRoleIds, + appliedRoleIds, + skippedRoleIds, + }; + } catch (error) { + if (hasCode(error) && error.code === 50013) { + return { + assigned: false, + reason: "missing_permissions", + configuredRoleIds, + appliedRoleIds: [], + skippedRoleIds: [...configuredRoleIds], + }; + } + + return { + assigned: false, + reason: "assign_failed", + configuredRoleIds, + appliedRoleIds: [], + skippedRoleIds: [...configuredRoleIds], + }; + } + } + public async dispatch(input: DispatchMemberMessageInput): Promise { const botId = this.resolveBotId(input.client); if (!botId) { diff --git a/src/i18n/en.json b/src/i18n/en.json index 891e78d..6c0b6f2 100644 --- a/src/i18n/en.json +++ b/src/i18n/en.json @@ -209,7 +209,8 @@ "status": "Status", "channel": "Channel", "type": "Type", - "channelPicker": "Channel picker" + "channelPicker": "Channel picker", + "autoRoles": "Auto roles" } }, "status": { @@ -217,12 +218,18 @@ "disabled": "🔴 Disabled" }, "channelNotConfigured": "#not-configured", + "autoRolesNotConfigured": "No auto role configured", "channelPickerPlaceholder": "Select a welcome channel", "channelPickerHint": "Use the select menu below to choose the destination channel.", + "rolePickerPlaceholder": "Select one or more auto roles", + "rolePickerHint": "Use the role select menu below to append roles to the auto-role list.", "buttons": { "toggle": "Toggle status", "channel": "Set channel", "channelCancel": "Close channel picker", + "roles": "Add auto roles", + "rolesCancel": "Close role picker", + "rolesClear": "Clear auto roles", "test": "Test" }, "type": { diff --git a/src/i18n/es.json b/src/i18n/es.json index b0f0975..d646e32 100644 --- a/src/i18n/es.json +++ b/src/i18n/es.json @@ -209,7 +209,8 @@ "status": "Estado", "channel": "Canal", "type": "Tipo", - "channelPicker": "Selector de canal" + "channelPicker": "Selector de canal", + "autoRoles": "Roles automaticos" } }, "status": { @@ -217,12 +218,18 @@ "disabled": "🔴 Desactivado" }, "channelNotConfigured": "#sin-configurar", + "autoRolesNotConfigured": "Ningun rol automatico configurado", "channelPickerPlaceholder": "Selecciona un canal de bienvenida", "channelPickerHint": "Usa el menu de seleccion de abajo para elegir el canal destino.", + "rolePickerPlaceholder": "Selecciona uno o varios roles automaticos", + "rolePickerHint": "Usa el menu de roles de abajo para agregarlos a la lista automatica.", "buttons": { "toggle": "Alternar estado", "channel": "Configurar canal", "channelCancel": "Cerrar selector", + "roles": "Agregar roles auto", + "rolesCancel": "Cerrar selector de roles", + "rolesClear": "Limpiar roles auto", "test": "Probar" }, "type": { diff --git a/src/i18n/fr.json b/src/i18n/fr.json index 386362e..119f2e9 100644 --- a/src/i18n/fr.json +++ b/src/i18n/fr.json @@ -209,7 +209,8 @@ "status": "Statut", "channel": "Salon", "type": "Type", - "channelPicker": "Selection du salon" + "channelPicker": "Selection du salon", + "autoRoles": "Roles auto" } }, "status": { @@ -217,12 +218,18 @@ "disabled": "🔴 Desactive" }, "channelNotConfigured": "#non-configure", + "autoRolesNotConfigured": "Aucun role auto configure", "channelPickerPlaceholder": "Selectionne un salon de bienvenue", "channelPickerHint": "Choisis le salon dans le select menu ci-dessous.", + "rolePickerPlaceholder": "Selectionne un ou plusieurs roles auto", + "rolePickerHint": "Choisis les roles dans le select menu ci-dessous pour les ajouter a la liste auto.", "buttons": { "toggle": "Toggle statut", "channel": "Configurer salon", "channelCancel": "Fermer select salon", + "roles": "Ajouter roles auto", + "rolesCancel": "Fermer select roles", + "rolesClear": "Vider roles auto", "test": "Tester" }, "type": { diff --git a/src/types/memberMessages.ts b/src/types/memberMessages.ts index 7a5b533..6f4be7c 100644 --- a/src/types/memberMessages.ts +++ b/src/types/memberMessages.ts @@ -1,6 +1,7 @@ import type { Client, Guild, + GuildMember, Message, MessageCreateOptions, User, @@ -15,6 +16,7 @@ export interface MemberMessageConfig { enabled: boolean; channelId: string | null; messageType: MemberMessageRenderType; + autoRoleIds: string[]; } export type DispatchMemberMessageFailureReason = @@ -41,6 +43,27 @@ export interface DispatchMemberMessageInput { ignoreEnabled?: boolean; } +export type AssignWelcomeAutoRolesFailureReason = + | "bot_not_ready" + | "missing_permissions" + | "member_not_manageable" + | "no_roles_configured" + | "no_assignable_roles" + | "assign_failed"; + +export interface AssignWelcomeAutoRolesInput { + client: Client; + member: GuildMember; +} + +export interface AssignWelcomeAutoRolesResult { + assigned: boolean; + reason: AssignWelcomeAutoRolesFailureReason | "assigned"; + configuredRoleIds: string[]; + appliedRoleIds: string[]; + skippedRoleIds: string[]; +} + export interface SendableChannel { send: (payload: string | MessageCreateOptions) => Promise; } @@ -66,19 +89,25 @@ export interface MemberMessageRow { enabled: boolean; channel_id: string | null; message_type: string; + auto_role_ids: string | null; } export interface MemberMessageCustomIds { toggleButton: string; channelButton: string; channelCancelButton: string; + roleButton: string; + roleCancelButton: string; + roleClearButton: string; typeSelect: string; channelSelect: string; + roleSelect: string; testButton: string; } export interface MemberMessagePanelUiState { channelPickerOpen: boolean; + rolePickerOpen: boolean; } export interface MemberMessagePanelSession { diff --git a/src/validators/memberMessages.ts b/src/validators/memberMessages.ts index 18844e2..d2bf99e 100644 --- a/src/validators/memberMessages.ts +++ b/src/validators/memberMessages.ts @@ -4,16 +4,34 @@ import type { MemberMessageRenderType, } from "../types/memberMessages.js"; +const DISCORD_SNOWFLAKE_REGEX = /^\d{17,20}$/; + export const MEMBER_MESSAGE_KINDS: readonly MemberMessageKind[] = ["welcome", "goodbye"]; export const MEMBER_MESSAGE_RENDER_TYPES: readonly MemberMessageRenderType[] = ["simple", "embed", "container", "image"]; export const DEFAULT_MEMBER_MESSAGE_RENDER_TYPE: MemberMessageRenderType = "simple"; +export const sanitizeMemberMessageRoleIds = (roleIds: readonly string[]): string[] => { + const uniqueRoleIds = new Set(); + + for (const rawRoleId of roleIds) { + const roleId = rawRoleId.trim(); + if (!DISCORD_SNOWFLAKE_REGEX.test(roleId)) { + continue; + } + + uniqueRoleIds.add(roleId); + } + + return [...uniqueRoleIds]; +}; + export const createDefaultMemberMessageConfig = (): MemberMessageConfig => ({ enabled: false, channelId: null, messageType: DEFAULT_MEMBER_MESSAGE_RENDER_TYPE, + autoRoleIds: [], }); export const isMemberMessageKindValue = (value: string): value is MemberMessageKind => { diff --git a/tests/memberMessagesTypes.test.ts b/tests/memberMessagesTypes.test.ts new file mode 100644 index 0000000..da83ced --- /dev/null +++ b/tests/memberMessagesTypes.test.ts @@ -0,0 +1,28 @@ +import assert from "node:assert/strict"; +import test from "node:test"; + +import { + createDefaultMemberMessageConfig, + sanitizeMemberMessageRoleIds, +} from "../src/validators/memberMessages.js"; + +test("createDefaultMemberMessageConfig initialise les roles auto vides", () => { + const config = createDefaultMemberMessageConfig(); + + assert.equal(config.enabled, false); + assert.equal(config.channelId, null); + assert.equal(config.messageType, "simple"); + assert.deepEqual(config.autoRoleIds, []); +}); + +test("sanitizeMemberMessageRoleIds filtre les valeurs invalides et dedupe", () => { + const roleIds = sanitizeMemberMessageRoleIds([ + "123456789012345678", + "123456789012345678", + " 223456789012345678 ", + "not-a-role", + "", + ]); + + assert.deepEqual(roleIds, ["123456789012345678", "223456789012345678"]); +});