import { ActionRowBuilder, ButtonBuilder, ButtonStyle, EmbedBuilder, type TextChannel } from 'discord.js'; import type { PrismaClient, Suggestion } from '@prisma/client'; import { SuggestionStatusSchema, t, tf, type SuggestionStatus } from '@nexumi/shared'; import type { Locale } from '@nexumi/shared'; import { ensureGuild } from '../../guild.js'; import { logger } from '../../logger.js'; import { getGuildLocale } from '../../i18n.js'; import type { BotContext } from '../../types.js'; export const SUGGESTION_VOTE_PREFIX = 'sug:vote:'; export function upvoteButtonId(suggestionId: string): string { return `${SUGGESTION_VOTE_PREFIX}${suggestionId}:up`; } export function downvoteButtonId(suggestionId: string): string { return `${SUGGESTION_VOTE_PREFIX}${suggestionId}:down`; } export function isSuggestionVoteButton(customId: string): boolean { return customId.startsWith(SUGGESTION_VOTE_PREFIX); } export class SuggestionError extends Error { constructor(public readonly code: string) { super(code); } } const STATUS_COLORS: Record = { OPEN: 0x6366f1, APPROVED: 0x22c55e, DENIED: 0xef4444, CONSIDERED: 0xeab308, IMPLEMENTED: 0x3b82f6 }; export async function getSuggestionConfig(prisma: PrismaClient, guildId: string) { await ensureGuild(prisma, guildId); let config = await prisma.suggestionConfig.findUnique({ where: { guildId } }); if (!config) { config = await prisma.suggestionConfig.create({ data: { guildId } }); } return config; } export async function updateSuggestionConfig( prisma: PrismaClient, guildId: string, data: { enabled: boolean; openChannelId: string; approvedChannelId?: string | null; deniedChannelId?: string | null; createThread: boolean; } ) { await ensureGuild(prisma, guildId); return prisma.suggestionConfig.upsert({ where: { guildId }, update: data, create: { guildId, ...data } }); } function statusLabel(locale: Locale, status: SuggestionStatus): string { return t(locale, `suggestion.status.${status.toLowerCase()}`); } export function buildSuggestionEmbed( locale: Locale, suggestion: Suggestion, authorTag?: string ): EmbedBuilder { const status = SuggestionStatusSchema.parse(suggestion.status); const embed = new EmbedBuilder() .setColor(STATUS_COLORS[status]) .setTitle(tf(locale, 'suggestion.embed.title', { id: suggestion.id.slice(-8) })) .setDescription(suggestion.content.slice(0, 4096)) .addFields( { name: t(locale, 'suggestion.embed.status'), value: statusLabel(locale, status), inline: true }, { name: t(locale, 'suggestion.embed.votes'), value: `๐Ÿ‘ ${suggestion.upvotes} ยท ๐Ÿ‘Ž ${suggestion.downvotes}`, inline: true } ) .setFooter({ text: tf(locale, 'suggestion.embed.author', { author: authorTag ?? `<@${suggestion.authorId}>` }) }) .setTimestamp(suggestion.createdAt); if (suggestion.staffReason) { embed.addFields({ name: t(locale, 'suggestion.embed.staffReason'), value: suggestion.staffReason.slice(0, 1024), inline: false }); } return embed; } export function buildVoteButtons(suggestion: Suggestion, disabled = false): ActionRowBuilder { return new ActionRowBuilder().addComponents( new ButtonBuilder() .setCustomId(upvoteButtonId(suggestion.id)) .setLabel(`๐Ÿ‘ ${suggestion.upvotes}`) .setStyle(ButtonStyle.Success) .setDisabled(disabled), new ButtonBuilder() .setCustomId(downvoteButtonId(suggestion.id)) .setLabel(`๐Ÿ‘Ž ${suggestion.downvotes}`) .setStyle(ButtonStyle.Danger) .setDisabled(disabled) ); } export async function createSuggestion( context: BotContext, params: { guildId: string; authorId: string; content: string; }, locale: Locale ): Promise { const config = await getSuggestionConfig(context.prisma, params.guildId); if (!config.enabled || !config.openChannelId) { throw new SuggestionError('not_configured'); } const channel = (await context.client.channels.fetch(config.openChannelId)) as TextChannel; if (!channel?.isTextBased() || channel.isDMBased()) { throw new SuggestionError('invalid_channel'); } const suggestion = await context.prisma.suggestion.create({ data: { guildId: params.guildId, authorId: params.authorId, content: params.content, status: 'OPEN', channelId: config.openChannelId } }); const author = await context.client.users.fetch(params.authorId).catch(() => null); const embed = buildSuggestionEmbed(locale, suggestion, author?.tag); const components = [buildVoteButtons(suggestion)]; const message = await channel.send({ embeds: [embed], components }); let threadId: string | null = null; if (config.createThread) { const thread = await message .startThread({ name: tf(locale, 'suggestion.threadName', { id: suggestion.id.slice(-8) }), autoArchiveDuration: 1440 }) .catch((error) => { logger.warn({ error, suggestionId: suggestion.id }, 'Failed to create suggestion thread'); return null; }); threadId = thread?.id ?? null; } return context.prisma.suggestion.update({ where: { id: suggestion.id }, data: { messageId: message.id, threadId } }); } export async function applySuggestionVote( context: BotContext, suggestionId: string, userId: string, direction: 'up' | 'down', locale: Locale ): Promise { const suggestion = await context.prisma.suggestion.findUnique({ where: { id: suggestionId } }); if (!suggestion || suggestion.status !== 'OPEN') { throw new SuggestionError('not_open'); } if (!suggestion.messageId || !suggestion.channelId) { throw new SuggestionError('not_found'); } const upIds = suggestion.voterUpIds.filter((id) => id !== userId); const downIds = suggestion.voterDownIds.filter((id) => id !== userId); if (direction === 'up') { if (suggestion.voterUpIds.includes(userId)) { throw new SuggestionError('already_voted'); } upIds.push(userId); } else { if (suggestion.voterDownIds.includes(userId)) { throw new SuggestionError('already_voted'); } downIds.push(userId); } const upvotes = upIds.length; const downvotes = downIds.length; const updated = await context.prisma.suggestion.update({ where: { id: suggestionId }, data: { voterUpIds: upIds, voterDownIds: downIds, upvotes, downvotes } }); const channel = (await context.client.channels.fetch(suggestion.channelId)) as TextChannel; const message = await channel.messages.fetch(suggestion.messageId).catch(() => null); if (message) { const author = await context.client.users.fetch(updated.authorId).catch(() => null); await message.edit({ embeds: [buildSuggestionEmbed(locale, updated, author?.tag)], components: [buildVoteButtons(updated)] }); } return updated; } async function notifyAuthor( context: BotContext, suggestion: Suggestion, locale: Locale, status: SuggestionStatus, reason: string ): Promise { const user = await context.client.users.fetch(suggestion.authorId).catch(() => null); if (!user) { return; } await user .send({ content: tf(locale, 'suggestion.dm.statusUpdate', { status: statusLabel(locale, status), reason }) }) .catch(() => undefined); } export async function updateSuggestionStatus( context: BotContext, params: { suggestionId: string; guildId: string; status: SuggestionStatus; reason: string; }, locale: Locale ): Promise { const suggestion = await context.prisma.suggestion.findFirst({ where: { id: params.suggestionId, guildId: params.guildId } }); if (!suggestion) { throw new SuggestionError('not_found'); } const config = await getSuggestionConfig(context.prisma, params.guildId); const author = await context.client.users.fetch(suggestion.authorId).catch(() => null); const updatedData = { status: params.status, staffReason: params.reason }; let targetChannelId = suggestion.channelId; if (params.status === 'APPROVED' || params.status === 'IMPLEMENTED') { targetChannelId = config.approvedChannelId ?? suggestion.channelId; } else if (params.status === 'DENIED') { targetChannelId = config.deniedChannelId ?? suggestion.channelId; } const embed = buildSuggestionEmbed( locale, { ...suggestion, ...updatedData }, author?.tag ); const components = [buildVoteButtons({ ...suggestion, ...updatedData }, true)]; let messageId = suggestion.messageId; let channelId = suggestion.channelId; if (targetChannelId && targetChannelId !== suggestion.channelId) { const targetChannel = (await context.client.channels.fetch(targetChannelId)) as TextChannel; if (targetChannel?.isTextBased() && !targetChannel.isDMBased()) { const newMessage = await targetChannel.send({ embeds: [embed], components }); messageId = newMessage.id; channelId = targetChannelId; if (suggestion.messageId && suggestion.channelId) { const oldChannel = (await context.client.channels.fetch(suggestion.channelId)) as TextChannel; const oldMessage = await oldChannel?.messages.fetch(suggestion.messageId).catch(() => null); await oldMessage?.delete().catch(() => undefined); } } else if (suggestion.messageId && suggestion.channelId) { const channel = (await context.client.channels.fetch(suggestion.channelId)) as TextChannel; const message = await channel?.messages.fetch(suggestion.messageId).catch(() => null); await message?.edit({ embeds: [embed], components }); } } else if (suggestion.messageId && suggestion.channelId) { const channel = (await context.client.channels.fetch(suggestion.channelId)) as TextChannel; const message = await channel?.messages.fetch(suggestion.messageId).catch(() => null); await message?.edit({ embeds: [embed], components }); } const updated = await context.prisma.suggestion.update({ where: { id: suggestion.id }, data: { ...updatedData, messageId, channelId } }); await notifyAuthor(context, updated, locale, params.status, params.reason); return updated; } /** * Entry point for the `suggestionStatusUpdate` BullMQ job, enqueued by the * WebUI dashboard (which has no discord.js Client to move/edit the * suggestion embed or DM the author). Resolves the guild's locale and * delegates to {@link updateSuggestionStatus} so staff actions performed * from the dashboard behave identically to `/suggestion approve|deny|...`. */ export async function runSuggestionStatusUpdateJob( context: BotContext, params: { suggestionId: string; guildId: string; status: SuggestionStatus; reason: string; } ): Promise<{ id: string }> { const locale = await getGuildLocale(context.prisma, params.guildId); const updated = await updateSuggestionStatus(context, params, locale); return { id: updated.id }; }