Files
flint/apps/bot/src/legacy/features/memberMessages/service.ts
T
2026-04-23 23:39:18 +02:00

440 lines
10 KiB
TypeScript

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<string, string> => ({
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, string>,
): 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, string>,
): 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<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 (
i18n: I18nService,
lang: SupportedLang,
kind: MemberMessageKind,
renderType: MemberMessageRenderType,
guild: Guild,
user: User,
): Promise<MessageCreateOptions> => {
const vars = messageTemplateVars(guild, user);
const allowedMentions: NonNullable<MessageCreateOptions["allowedMentions"]> =
{
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<void> {
await this.repository.upsertByBotGuildKind(botId, guildId, kind, config);
}
public async cleanupGuild(botId: string, guildId: string): Promise<void> {
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> {
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,
};
}
}
}