import { AttachmentBuilder, ContainerBuilder, EmbedBuilder, MessageFlags, PermissionFlagsBits, TextDisplayBuilder, type Guild, type GuildMember, type MessageCreateOptions, type User, } from "discord.js"; import type { I18nService } from "../../i18n/index.js"; import type { SupportedLang } from "../../types/command.js"; import type { AssignWelcomeAutoRolesInput, AssignWelcomeAutoRolesResult, DispatchMemberMessageInput, DispatchMemberMessageResult, MemberMessageConfig, MemberMessageKind, MemberMessageRenderType, SendableChannel, TemplateSuffix, } from "../../types/memberMessages.js"; import { sanitizeMemberMessageRoleIds } from "../../validators/memberMessages.js"; import { renderMemberMessageImage } from "./imageRenderer.js"; import type { MemberMessageRepository } from "./repository.js"; export type { DispatchMemberMessageFailureReason, DispatchMemberMessageResult, } from "../../types/memberMessages.js"; const hasSendMethod = (value: unknown): value is SendableChannel => { if (!value || typeof value !== "object") { return false; } return ( "send" in value && typeof (value as { send?: unknown }).send === "function" ); }; const hasCode = (error: unknown): error is { code: number } => { if (!error || typeof error !== "object") { return false; } return ( "code" in error && typeof (error as { code?: unknown }).code === "number" ); }; const messageTemplateVars = ( guild: Guild, user: User, ): Record => ({ user: `<@${user.id}>`, username: user.username, guild: guild.name, }); const messageColor = (kind: MemberMessageKind): number => { return kind === "welcome" ? 0x57f287 : 0xed4245; }; const resolveTemplate = ( i18n: I18nService, lang: SupportedLang, kind: MemberMessageKind, suffix: TemplateSuffix, vars: Record, ): string => { const key = `commands.${kind}.templates.${suffix}`; return i18n.t(lang, key, vars); }; const resolveNonEmptyTemplate = ( i18n: I18nService, 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 key = `commands.${kind}.templates.${suffix}`; const fallback = i18n.t("en", key, vars).trim(); return fallback.length > 0 ? fallback : key; }; 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, lang: SupportedLang, kind: MemberMessageKind, renderType: MemberMessageRenderType, guild: Guild, user: User, ): Promise => { const vars = messageTemplateVars(guild, user); const allowedMentions: NonNullable = { parse: [], users: [user.id], }; if (renderType === "simple") { return { content: resolveTemplate(i18n, lang, kind, "simple", vars), allowedMentions, }; } if (renderType === "embed") { return { allowedMentions, embeds: [ new EmbedBuilder() .setColor(messageColor(kind)) .setTitle(resolveTemplate(i18n, lang, kind, "embedTitle", vars)) .setDescription( resolveTemplate(i18n, lang, kind, "embedDescription", vars), ), ], }; } if (renderType === "container") { const container = new ContainerBuilder(); container.addTextDisplayComponents( new TextDisplayBuilder().setContent( `## ${resolveTemplate(i18n, lang, kind, "containerTitle", vars)}\n${resolveTemplate(i18n, lang, kind, "containerDescription", vars)}`, ), ); return { flags: MessageFlags.IsComponentsV2, components: [container], allowedMentions, }; } const imageTitle = resolveNonEmptyTemplate( i18n, lang, kind, "imageTitle", vars, ); const imageSubtitle = resolveNonEmptyTemplate( i18n, lang, kind, "imageDescription", vars, ); const imageBuffer = await renderMemberMessageImage({ kind, title: imageTitle, subtitle: imageSubtitle, username: user.globalName ?? user.username, avatarUrl: user.displayAvatarURL({ extension: "png", size: 512 }), }); const fileName = `${kind}-${guild.id}-${user.id}.png`; return { allowedMentions, content: resolveTemplate(i18n, lang, kind, "simple", vars), files: [new AttachmentBuilder(imageBuffer, { name: fileName })], }; }; export class MemberMessageService { public constructor(private readonly repository: MemberMessageRepository) {} public resolveBotId( client: DispatchMemberMessageInput["client"], ): string | null { return client.user?.id ?? null; } public async getConfig( botId: string, guildId: string, kind: MemberMessageKind, ) { return this.repository.getByBotGuildKind(botId, guildId, kind); } public async saveConfig( botId: string, guildId: string, kind: MemberMessageKind, config: MemberMessageConfig, ): Promise { await this.repository.upsertByBotGuildKind(botId, guildId, kind, config); } public async cleanupGuild(botId: string, guildId: string): Promise { 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) { return { sent: false, reason: "bot_not_ready", channelId: null, }; } const config = await this.repository.getByBotGuildKind( botId, input.guild.id, input.kind, ); if (!input.ignoreEnabled && !config.enabled) { return { sent: false, reason: "disabled", channelId: config.channelId, }; } if (!config.channelId) { return { sent: false, reason: "missing_channel", channelId: null, }; } const channel = await input.guild.channels .fetch(config.channelId) .catch(() => null); if (!channel) { return { sent: false, reason: "channel_not_found", channelId: config.channelId, }; } if (!hasSendMethod(channel)) { return { sent: false, reason: "channel_not_sendable", channelId: config.channelId, }; } const me = input.guild.members.me; if ( me && "permissionsFor" in channel && typeof channel.permissionsFor === "function" ) { const permissions = channel.permissionsFor(me); if ( !permissions || !permissions.has(PermissionFlagsBits.ViewChannel) || !permissions.has(PermissionFlagsBits.SendMessages) ) { return { sent: false, reason: "missing_permissions", channelId: config.channelId, }; } } const lang = input.i18n.resolveLang(input.guild.preferredLocale ?? null); const payload = await buildMemberMessagePayload( input.i18n, lang, input.kind, config.messageType, input.guild, input.user, ); try { await channel.send(payload); return { sent: true, reason: "sent", channelId: config.channelId, }; } catch (error) { if (hasCode(error) && error.code === 50013) { return { sent: false, reason: "missing_permissions", channelId: config.channelId, }; } return { sent: false, reason: "send_failed", channelId: config.channelId, }; } } }