fix bug welcome

This commit is contained in:
Puechberty Arthur
2026-04-17 01:29:30 +02:00
parent 30378f2c91
commit 58376568c7
13 changed files with 414 additions and 24 deletions
+2
View File
@@ -16,6 +16,8 @@ FROM node:20-alpine AS runner
WORKDIR /app WORKDIR /app
ENV NODE_ENV=production ENV NODE_ENV=production
RUN apk add --no-cache fontconfig ttf-dejavu
COPY package.json package-lock.json ./ COPY package.json package-lock.json ./
RUN npm ci --omit=dev RUN npm ci --omit=dev
@@ -0,0 +1,2 @@
ALTER TABLE bot_member_message_configs
ADD COLUMN IF NOT EXISTS auto_role_ids TEXT NOT NULL DEFAULT '[]';
+28 -3
View File
@@ -8,6 +8,7 @@ import type {
import { import {
createDefaultMemberMessageConfig, createDefaultMemberMessageConfig,
isMemberMessageRenderTypeValue, isMemberMessageRenderTypeValue,
sanitizeMemberMessageRoleIds,
} from "../../validators/memberMessages.js"; } from "../../validators/memberMessages.js";
import type { MemberMessageRepository } from "../../modules/memberMessages/index.js"; import type { MemberMessageRepository } from "../../modules/memberMessages/index.js";
@@ -19,11 +20,30 @@ SELECT
enabled, enabled,
channel_id, channel_id,
message_type, message_type,
auto_role_ids,
updated_at updated_at
FROM bot_member_message_configs FROM bot_member_message_configs
LIMIT 0; 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 toConfig = (row: MemberMessageRow): MemberMessageConfig => {
const fallback = createDefaultMemberMessageConfig(); const fallback = createDefaultMemberMessageConfig();
@@ -31,6 +51,7 @@ const toConfig = (row: MemberMessageRow): MemberMessageConfig => {
enabled: row.enabled, enabled: row.enabled,
channelId: row.channel_id, channelId: row.channel_id,
messageType: isMemberMessageRenderTypeValue(row.message_type) ? row.message_type : fallback.messageType, 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<MemberMessageConfig> { public async getByBotGuildKind(botId: string, guildId: string, kind: MemberMessageKind): Promise<MemberMessageConfig> {
const result = await this.pool.query<MemberMessageRow>( const result = await this.pool.query<MemberMessageRow>(
` `
SELECT enabled, channel_id, message_type SELECT enabled, channel_id, message_type, auto_role_ids
FROM bot_member_message_configs FROM bot_member_message_configs
WHERE bot_id = $1 AND guild_id = $2 AND kind = $3 WHERE bot_id = $1 AND guild_id = $2 AND kind = $3
LIMIT 1 LIMIT 1
@@ -73,6 +94,8 @@ export class PostgresMemberMessageStore implements MemberMessageRepository {
kind: MemberMessageKind, kind: MemberMessageKind,
config: MemberMessageConfig, config: MemberMessageConfig,
): Promise<void> { ): Promise<void> {
const autoRoleIds = sanitizeMemberMessageRoleIds(config.autoRoleIds);
await this.pool.query( await this.pool.query(
` `
INSERT INTO bot_member_message_configs ( INSERT INTO bot_member_message_configs (
@@ -82,17 +105,19 @@ export class PostgresMemberMessageStore implements MemberMessageRepository {
enabled, enabled,
channel_id, channel_id,
message_type, message_type,
auto_role_ids,
updated_at 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) ON CONFLICT (bot_id, guild_id, kind)
DO UPDATE SET DO UPDATE SET
enabled = EXCLUDED.enabled, enabled = EXCLUDED.enabled,
channel_id = EXCLUDED.channel_id, channel_id = EXCLUDED.channel_id,
message_type = EXCLUDED.message_type, message_type = EXCLUDED.message_type,
auto_role_ids = EXCLUDED.auto_role_ids,
updated_at = NOW() updated_at = NOW()
`, `,
[botId, guildId, kind, config.enabled, config.channelId, config.messageType], [botId, guildId, kind, config.enabled, config.channelId, config.messageType, JSON.stringify(autoRoleIds)],
); );
} }
+29
View File
@@ -12,6 +12,35 @@ export const registerGuildMemberAdd = (
memberMessageService: MemberMessageService, memberMessageService: MemberMessageService,
): void => { ): void => {
client.on(Events.GuildMemberAdd, (member) => { 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({ void memberMessageService.dispatch({
client, client,
i18n, i18n,
+117 -11
View File
@@ -6,6 +6,7 @@ import {
ChannelType, ChannelType,
ContainerBuilder, ContainerBuilder,
MessageFlags, MessageFlags,
RoleSelectMenuBuilder,
StringSelectMenuBuilder, StringSelectMenuBuilder,
TextDisplayBuilder, TextDisplayBuilder,
} from "discord.js"; } from "discord.js";
@@ -26,6 +27,7 @@ import type {
import { import {
MEMBER_MESSAGE_RENDER_TYPES, MEMBER_MESSAGE_RENDER_TYPES,
isMemberMessageRenderTypeValue, isMemberMessageRenderTypeValue,
sanitizeMemberMessageRoleIds,
} from "../../validators/memberMessages.js"; } from "../../validators/memberMessages.js";
import type { MemberMessageService } from "./service.js"; import type { MemberMessageService } from "./service.js";
@@ -44,8 +46,12 @@ const createCustomIds = (kind: MemberMessageKind): MemberMessageCustomIds => {
toggleButton: `${kind}:toggle:${nonce}`, toggleButton: `${kind}:toggle:${nonce}`,
channelButton: `${kind}:channel:${nonce}`, channelButton: `${kind}:channel:${nonce}`,
channelCancelButton: `${kind}:channel-cancel:${nonce}`, channelCancelButton: `${kind}:channel-cancel:${nonce}`,
roleButton: `${kind}:roles:${nonce}`,
roleCancelButton: `${kind}:roles-cancel:${nonce}`,
roleClearButton: `${kind}:roles-clear:${nonce}`,
typeSelect: `${kind}:type:${nonce}`, typeSelect: `${kind}:type:${nonce}`,
channelSelect: `${kind}:channel-select:${nonce}`, channelSelect: `${kind}:channel-select:${nonce}`,
roleSelect: `${kind}:role-select:${nonce}`,
testButton: `${kind}:test:${nonce}`, testButton: `${kind}:test:${nonce}`,
}; };
}; };
@@ -58,8 +64,18 @@ const messageTypeLabel = (ctx: CommandExecutionContext, messageType: MemberMessa
return ctx.ct(`ui.type.options.${messageType}.label`); 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 = ( const panelContent = (
ctx: CommandExecutionContext, ctx: CommandExecutionContext,
kind: MemberMessageKind,
config: MemberMessageConfig, config: MemberMessageConfig,
uiState: MemberMessagePanelUiState, uiState: MemberMessagePanelUiState,
): string => { ): string => {
@@ -73,15 +89,24 @@ const panelContent = (
`${ctx.ct("ui.embed.fields.type")}: ${messageTypeLabel(ctx, config.messageType)}`, `${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) { if (uiState.channelPickerOpen) {
lines.push("", `${ctx.ct("ui.embed.fields.channelPicker")}: ${ctx.ct("ui.channelPickerHint")}`); 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"); return lines.join("\n");
}; };
const buildContainer = ( const buildContainer = (
ctx: CommandExecutionContext, ctx: CommandExecutionContext,
kind: MemberMessageKind,
customIds: MemberMessageCustomIds, customIds: MemberMessageCustomIds,
config: MemberMessageConfig, config: MemberMessageConfig,
uiState: MemberMessagePanelUiState, uiState: MemberMessagePanelUiState,
@@ -99,6 +124,18 @@ const buildContainer = (
.setStyle(ButtonStyle.Secondary) .setStyle(ButtonStyle.Secondary)
.setDisabled(disabled); .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() const testButton = new ButtonBuilder()
.setCustomId(customIds.testButton) .setCustomId(customIds.testButton)
.setLabel(ctx.ct("ui.buttons.test")) .setLabel(ctx.ct("ui.buttons.test"))
@@ -122,11 +159,25 @@ const buildContainer = (
const container = new ContainerBuilder(); const container = new ContainerBuilder();
container.addTextDisplayComponents( container.addTextDisplayComponents(
new TextDisplayBuilder().setContent(panelContent(ctx, config, uiState)), new TextDisplayBuilder().setContent(panelContent(ctx, kind, config, uiState)),
);
container.addActionRowComponents(
new ActionRowBuilder<ButtonBuilder>().addComponents(toggleButton, channelButton, testButton),
); );
if (kind === "welcome") {
container.addActionRowComponents(
new ActionRowBuilder<ButtonBuilder>().addComponents(
toggleButton,
channelButton,
roleButton,
clearRolesButton,
testButton,
),
);
} else {
container.addActionRowComponents(
new ActionRowBuilder<ButtonBuilder>().addComponents(toggleButton, channelButton, testButton),
);
}
container.addActionRowComponents( container.addActionRowComponents(
new ActionRowBuilder<StringSelectMenuBuilder>().addComponents(typeSelect), new ActionRowBuilder<StringSelectMenuBuilder>().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<RoleSelectMenuBuilder>().addComponents(roleSelect),
);
}
return container; return container;
}; };
@@ -186,11 +250,12 @@ export const createMemberMessagePanelExecute = (
const customIds = createCustomIds(kind); const customIds = createCustomIds(kind);
const uiState: MemberMessagePanelUiState = { const uiState: MemberMessagePanelUiState = {
channelPickerOpen: false, channelPickerOpen: false,
rolePickerOpen: false,
}; };
const replyResult = await ctx.reply({ const replyResult = await ctx.reply({
flags: MessageFlags.IsComponentsV2, flags: MessageFlags.IsComponentsV2,
components: [buildContainer(ctx, customIds, config, uiState)], components: [buildContainer(ctx, kind, customIds, config, uiState)],
withResponse: true, withResponse: true,
}); });
@@ -210,7 +275,7 @@ export const createMemberMessagePanelExecute = (
await replyMessage await replyMessage
.edit({ .edit({
flags: MessageFlags.IsComponentsV2, flags: MessageFlags.IsComponentsV2,
components: [buildContainer(ctx, customIds, config, uiState, true)], components: [buildContainer(ctx, kind, customIds, config, uiState, true)],
}) })
.catch(() => undefined); .catch(() => undefined);
}; };
@@ -237,16 +302,17 @@ export const createMemberMessagePanelExecute = (
await saveConfig(); await saveConfig();
await interaction.update({ await interaction.update({
flags: MessageFlags.IsComponentsV2, flags: MessageFlags.IsComponentsV2,
components: [buildContainer(ctx, customIds, config, uiState)], components: [buildContainer(ctx, kind, customIds, config, uiState)],
}); });
return; return;
} }
if (interaction.customId === customIds.channelButton) { if (interaction.customId === customIds.channelButton) {
uiState.channelPickerOpen = true; uiState.channelPickerOpen = true;
uiState.rolePickerOpen = false;
await interaction.update({ await interaction.update({
flags: MessageFlags.IsComponentsV2, flags: MessageFlags.IsComponentsV2,
components: [buildContainer(ctx, customIds, config, uiState)], components: [buildContainer(ctx, kind, customIds, config, uiState)],
}); });
return; return;
} }
@@ -255,7 +321,36 @@ export const createMemberMessagePanelExecute = (
uiState.channelPickerOpen = false; uiState.channelPickerOpen = false;
await interaction.update({ await interaction.update({
flags: MessageFlags.IsComponentsV2, 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; return;
} }
@@ -307,7 +402,18 @@ export const createMemberMessagePanelExecute = (
await saveConfig(); await saveConfig();
await interaction.update({ await interaction.update({
flags: MessageFlags.IsComponentsV2, 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; return;
} }
@@ -327,7 +433,7 @@ export const createMemberMessagePanelExecute = (
await saveConfig(); await saveConfig();
await interaction.update({ await interaction.update({
flags: MessageFlags.IsComponentsV2, flags: MessageFlags.IsComponentsV2,
components: [buildContainer(ctx, customIds, config, uiState)], components: [buildContainer(ctx, kind, customIds, config, uiState)],
}); });
return; return;
} }
+6 -5
View File
@@ -9,6 +9,7 @@ const AVATAR_X = 42;
const AVATAR_Y = (CARD_HEIGHT - AVATAR_SIZE) / 2; const AVATAR_Y = (CARD_HEIGHT - AVATAR_SIZE) / 2;
const TEXT_X = AVATAR_X + AVATAR_SIZE + 56; const TEXT_X = AVATAR_X + AVATAR_SIZE + 56;
const TEXT_MAX_WIDTH = CARD_WIDTH - TEXT_X - 36; const TEXT_MAX_WIDTH = CARD_WIDTH - TEXT_X - 36;
const FONT_FAMILY = '"DejaVu Sans", sans-serif';
const fitFontSize = ( const fitFontSize = (
context: SKRSContext2D, context: SKRSContext2D,
@@ -21,7 +22,7 @@ const fitFontSize = (
let size = startSize; let size = startSize;
while (size > minSize) { while (size > minSize) {
context.font = `${fontWeight} ${size}px sans-serif`; context.font = `${fontWeight} ${size}px ${FONT_FAMILY}`;
if (context.measureText(text).width <= maxWidth) { if (context.measureText(text).width <= maxWidth) {
break; break;
} }
@@ -110,7 +111,7 @@ const drawAvatar = async (
} catch { } catch {
const initial = username.trim().charAt(0).toUpperCase() || "?"; const initial = username.trim().charAt(0).toUpperCase() || "?";
context.fillStyle = "#f8fafc"; context.fillStyle = "#f8fafc";
context.font = "700 92px sans-serif"; context.font = `700 92px ${FONT_FAMILY}`;
context.textAlign = "center"; context.textAlign = "center";
context.textBaseline = "middle"; context.textBaseline = "middle";
context.fillText(initial, AVATAR_X + AVATAR_SIZE / 2, AVATAR_Y + AVATAR_SIZE / 2); 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); await drawAvatar(context, input.username, input.avatarUrl);
const headlineSize = fitFontSize(context, input.title, 84, 52, TEXT_MAX_WIDTH, 700); 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.fillStyle = "#f8fafc";
context.fillText(input.title, TEXT_X, 116); context.fillText(input.title, TEXT_X, 116);
const subtitleSize = fitFontSize(context, input.subtitle, 58, 30, TEXT_MAX_WIDTH, 500); 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"; context.fillStyle = "#e5e7eb";
const subtitleLines = wrapText(context, input.subtitle, TEXT_MAX_WIDTH, 2); 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); 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.fillStyle = input.kind === "welcome" ? "#93c5fd" : "#fda4af";
context.fillText(input.username, TEXT_X, CARD_HEIGHT - 34); context.fillText(input.username, TEXT_X, CARD_HEIGHT - 34);
+131 -2
View File
@@ -6,6 +6,7 @@ import {
PermissionFlagsBits, PermissionFlagsBits,
TextDisplayBuilder, TextDisplayBuilder,
type Guild, type Guild,
type GuildMember,
type MessageCreateOptions, type MessageCreateOptions,
type User, type User,
} from "discord.js"; } from "discord.js";
@@ -13,6 +14,8 @@ import {
import type { I18nService } from "../../i18n/index.js"; import type { I18nService } from "../../i18n/index.js";
import type { SupportedLang } from "../../types/command.js"; import type { SupportedLang } from "../../types/command.js";
import type { import type {
AssignWelcomeAutoRolesInput,
AssignWelcomeAutoRolesResult,
DispatchMemberMessageInput, DispatchMemberMessageInput,
DispatchMemberMessageResult, DispatchMemberMessageResult,
MemberMessageConfig, MemberMessageConfig,
@@ -21,6 +24,7 @@ import type {
SendableChannel, SendableChannel,
TemplateSuffix, TemplateSuffix,
} from "../../types/memberMessages.js"; } from "../../types/memberMessages.js";
import { sanitizeMemberMessageRoleIds } from "../../validators/memberMessages.js";
import { renderMemberMessageImage } from "./imageRenderer.js"; import { renderMemberMessageImage } from "./imageRenderer.js";
import type { MemberMessageRepository } from "./repository.js"; import type { MemberMessageRepository } from "./repository.js";
@@ -117,6 +121,35 @@ const resolveTemplate = (
return translated === key ? defaultTemplate(kind, suffix, vars) : translated; return translated === key ? defaultTemplate(kind, suffix, vars) : translated;
}; };
const resolveNonEmptyTemplate = (
i18n: I18nService | undefined,
lang: SupportedLang,
kind: MemberMessageKind,
suffix: TemplateSuffix,
vars: Record<string, string>,
): 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<string | null> => {
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 ( const buildMemberMessagePayload = async (
i18n: I18nService | undefined, i18n: I18nService | undefined,
lang: SupportedLang, 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({ const imageBuffer = await renderMemberMessageImage({
kind, kind,
title: resolveTemplate(i18n, lang, kind, "imageTitle", vars), title: imageTitle,
subtitle: resolveTemplate(i18n, lang, kind, "imageDescription", vars), subtitle: imageSubtitle,
username: user.globalName ?? user.username, username: user.globalName ?? user.username,
avatarUrl: user.displayAvatarURL({ extension: "png", size: 512 }), avatarUrl: user.displayAvatarURL({ extension: "png", size: 512 }),
}); });
@@ -206,6 +242,99 @@ export class MemberMessageService {
await this.repository.deleteByBotGuild(botId, guildId); await this.repository.deleteByBotGuild(botId, guildId);
} }
public async assignWelcomeAutoRoles(input: AssignWelcomeAutoRolesInput): Promise<AssignWelcomeAutoRolesResult> {
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<DispatchMemberMessageResult> { public async dispatch(input: DispatchMemberMessageInput): Promise<DispatchMemberMessageResult> {
const botId = this.resolveBotId(input.client); const botId = this.resolveBotId(input.client);
if (!botId) { if (!botId) {
+8 -1
View File
@@ -209,7 +209,8 @@
"status": "Status", "status": "Status",
"channel": "Channel", "channel": "Channel",
"type": "Type", "type": "Type",
"channelPicker": "Channel picker" "channelPicker": "Channel picker",
"autoRoles": "Auto roles"
} }
}, },
"status": { "status": {
@@ -217,12 +218,18 @@
"disabled": "🔴 Disabled" "disabled": "🔴 Disabled"
}, },
"channelNotConfigured": "#not-configured", "channelNotConfigured": "#not-configured",
"autoRolesNotConfigured": "No auto role configured",
"channelPickerPlaceholder": "Select a welcome channel", "channelPickerPlaceholder": "Select a welcome channel",
"channelPickerHint": "Use the select menu below to choose the destination 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": { "buttons": {
"toggle": "Toggle status", "toggle": "Toggle status",
"channel": "Set channel", "channel": "Set channel",
"channelCancel": "Close channel picker", "channelCancel": "Close channel picker",
"roles": "Add auto roles",
"rolesCancel": "Close role picker",
"rolesClear": "Clear auto roles",
"test": "Test" "test": "Test"
}, },
"type": { "type": {
+8 -1
View File
@@ -209,7 +209,8 @@
"status": "Estado", "status": "Estado",
"channel": "Canal", "channel": "Canal",
"type": "Tipo", "type": "Tipo",
"channelPicker": "Selector de canal" "channelPicker": "Selector de canal",
"autoRoles": "Roles automaticos"
} }
}, },
"status": { "status": {
@@ -217,12 +218,18 @@
"disabled": "🔴 Desactivado" "disabled": "🔴 Desactivado"
}, },
"channelNotConfigured": "#sin-configurar", "channelNotConfigured": "#sin-configurar",
"autoRolesNotConfigured": "Ningun rol automatico configurado",
"channelPickerPlaceholder": "Selecciona un canal de bienvenida", "channelPickerPlaceholder": "Selecciona un canal de bienvenida",
"channelPickerHint": "Usa el menu de seleccion de abajo para elegir el canal destino.", "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": { "buttons": {
"toggle": "Alternar estado", "toggle": "Alternar estado",
"channel": "Configurar canal", "channel": "Configurar canal",
"channelCancel": "Cerrar selector", "channelCancel": "Cerrar selector",
"roles": "Agregar roles auto",
"rolesCancel": "Cerrar selector de roles",
"rolesClear": "Limpiar roles auto",
"test": "Probar" "test": "Probar"
}, },
"type": { "type": {
+8 -1
View File
@@ -209,7 +209,8 @@
"status": "Statut", "status": "Statut",
"channel": "Salon", "channel": "Salon",
"type": "Type", "type": "Type",
"channelPicker": "Selection du salon" "channelPicker": "Selection du salon",
"autoRoles": "Roles auto"
} }
}, },
"status": { "status": {
@@ -217,12 +218,18 @@
"disabled": "🔴 Desactive" "disabled": "🔴 Desactive"
}, },
"channelNotConfigured": "#non-configure", "channelNotConfigured": "#non-configure",
"autoRolesNotConfigured": "Aucun role auto configure",
"channelPickerPlaceholder": "Selectionne un salon de bienvenue", "channelPickerPlaceholder": "Selectionne un salon de bienvenue",
"channelPickerHint": "Choisis le salon dans le select menu ci-dessous.", "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": { "buttons": {
"toggle": "Toggle statut", "toggle": "Toggle statut",
"channel": "Configurer salon", "channel": "Configurer salon",
"channelCancel": "Fermer select salon", "channelCancel": "Fermer select salon",
"roles": "Ajouter roles auto",
"rolesCancel": "Fermer select roles",
"rolesClear": "Vider roles auto",
"test": "Tester" "test": "Tester"
}, },
"type": { "type": {
+29
View File
@@ -1,6 +1,7 @@
import type { import type {
Client, Client,
Guild, Guild,
GuildMember,
Message, Message,
MessageCreateOptions, MessageCreateOptions,
User, User,
@@ -15,6 +16,7 @@ export interface MemberMessageConfig {
enabled: boolean; enabled: boolean;
channelId: string | null; channelId: string | null;
messageType: MemberMessageRenderType; messageType: MemberMessageRenderType;
autoRoleIds: string[];
} }
export type DispatchMemberMessageFailureReason = export type DispatchMemberMessageFailureReason =
@@ -41,6 +43,27 @@ export interface DispatchMemberMessageInput {
ignoreEnabled?: boolean; 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 { export interface SendableChannel {
send: (payload: string | MessageCreateOptions) => Promise<unknown>; send: (payload: string | MessageCreateOptions) => Promise<unknown>;
} }
@@ -66,19 +89,25 @@ export interface MemberMessageRow {
enabled: boolean; enabled: boolean;
channel_id: string | null; channel_id: string | null;
message_type: string; message_type: string;
auto_role_ids: string | null;
} }
export interface MemberMessageCustomIds { export interface MemberMessageCustomIds {
toggleButton: string; toggleButton: string;
channelButton: string; channelButton: string;
channelCancelButton: string; channelCancelButton: string;
roleButton: string;
roleCancelButton: string;
roleClearButton: string;
typeSelect: string; typeSelect: string;
channelSelect: string; channelSelect: string;
roleSelect: string;
testButton: string; testButton: string;
} }
export interface MemberMessagePanelUiState { export interface MemberMessagePanelUiState {
channelPickerOpen: boolean; channelPickerOpen: boolean;
rolePickerOpen: boolean;
} }
export interface MemberMessagePanelSession { export interface MemberMessagePanelSession {
+18
View File
@@ -4,16 +4,34 @@ import type {
MemberMessageRenderType, MemberMessageRenderType,
} from "../types/memberMessages.js"; } from "../types/memberMessages.js";
const DISCORD_SNOWFLAKE_REGEX = /^\d{17,20}$/;
export const MEMBER_MESSAGE_KINDS: readonly MemberMessageKind[] = ["welcome", "goodbye"]; export const MEMBER_MESSAGE_KINDS: readonly MemberMessageKind[] = ["welcome", "goodbye"];
export const MEMBER_MESSAGE_RENDER_TYPES: readonly MemberMessageRenderType[] = ["simple", "embed", "container", "image"]; export const MEMBER_MESSAGE_RENDER_TYPES: readonly MemberMessageRenderType[] = ["simple", "embed", "container", "image"];
export const DEFAULT_MEMBER_MESSAGE_RENDER_TYPE: MemberMessageRenderType = "simple"; export const DEFAULT_MEMBER_MESSAGE_RENDER_TYPE: MemberMessageRenderType = "simple";
export const sanitizeMemberMessageRoleIds = (roleIds: readonly string[]): string[] => {
const uniqueRoleIds = new Set<string>();
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 => ({ export const createDefaultMemberMessageConfig = (): MemberMessageConfig => ({
enabled: false, enabled: false,
channelId: null, channelId: null,
messageType: DEFAULT_MEMBER_MESSAGE_RENDER_TYPE, messageType: DEFAULT_MEMBER_MESSAGE_RENDER_TYPE,
autoRoleIds: [],
}); });
export const isMemberMessageKindValue = (value: string): value is MemberMessageKind => { export const isMemberMessageKindValue = (value: string): value is MemberMessageKind => {
+28
View File
@@ -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"]);
});