From b437496dd0c01fd54ca22e3db39ee73d78afb1e5 Mon Sep 17 00:00:00 2001 From: hugopocas Date: Tue, 25 Feb 2025 16:34:37 +0000 Subject: [PATCH 1/2] =?UTF-8?q?feat:=20adicionar=20perguntas=20an=C3=B3nim?= =?UTF-8?q?as?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../command/anonymousQuestionCommand.ts | 69 ++++++++++++ .../approveAnonymousQuestionUseCase.ts | 106 ++++++++++++++++++ .../rejectAnonymousQuestionUseCase.ts | 80 +++++++++++++ .../sendAnonymousQuestionUseCase.ts | 94 ++++++++++++++++ domain/service/channelResolver.ts | 2 + domain/service/chatService.ts | 18 ++- domain/service/commandUseCaseResolver.ts | 7 +- domain/service/interactionResolver.ts | 89 +++++++++++++++ domain/service/questionTrackingService.ts | 19 ++++ index.ts | 35 +++++- infrastructure/service/discordChatService.ts | 45 +++++++- types.ts | 5 + vitest/service/commandUseCaseResolver.spec.ts | 6 +- 13 files changed, 559 insertions(+), 16 deletions(-) create mode 100644 application/command/anonymousQuestionCommand.ts create mode 100644 application/usecases/approveAnonymousQuestionUseCase.ts create mode 100644 application/usecases/rejectAnonymousQuestionUseCase.ts create mode 100644 application/usecases/sendAnonymousQuestion/sendAnonymousQuestionUseCase.ts create mode 100644 domain/service/interactionResolver.ts create mode 100644 domain/service/questionTrackingService.ts diff --git a/application/command/anonymousQuestionCommand.ts b/application/command/anonymousQuestionCommand.ts new file mode 100644 index 0000000..9cce5a6 --- /dev/null +++ b/application/command/anonymousQuestionCommand.ts @@ -0,0 +1,69 @@ +import { Command, Context } from "../../types"; +import ChatService from "../../domain/service/chatService"; +import LoggerService from "../../domain/service/loggerService"; +import ChannelResolver from "../../domain/service/channelResolver"; +import SendAnonymousQuestionUseCase from "../usecases/sendAnonymousQuestion/sendAnonymousQuestionUseCase"; +import QuestionTrackingService from "../../domain/service/questionTrackingService"; + +export default class AnonymousQuestionCommand implements Command { + readonly name = "!pergunta"; + + private readonly chatService: ChatService; + + private readonly loggerService: LoggerService; + + private readonly channelResolver: ChannelResolver; + + private readonly questionTrackingService: QuestionTrackingService; + + constructor( + chatService: ChatService, + loggerService: LoggerService, + channelResolver: ChannelResolver, + questionTrackingService: QuestionTrackingService + ) { + this.chatService = chatService; + this.loggerService = loggerService; + this.channelResolver = channelResolver; + this.questionTrackingService = questionTrackingService; + } + + async execute(context: Context): Promise { + if (!context.message) { + return; + } + + const { message } = context; + + const isDM = message.channel.type === "DM"; + + if (!isDM) { + this.chatService.sendMessageToChannel("Este comando só pode ser usado em mensagens diretas.", context.channelId); + return; + } + + const questionContent = message.content.replace(/!pergunta\s+/i, "").trim(); + + if (!questionContent) { + this.chatService.sendMessageToChannel( + "Por favor, forneçe uma pergunta após o comando. Exemplo: `!pergunta Como faço para...`", + message.channel.id + ); + return; + } + + const sendAnonymousQuestionUseCase = new SendAnonymousQuestionUseCase({ + chatService: this.chatService, + loggerService: this.loggerService, + channelResolver: this.channelResolver, + questionTrackingService: this.questionTrackingService, + }); + + await sendAnonymousQuestionUseCase.execute({ + userId: message.author.id, + username: message.author.username, + questionContent, + dmChannelId: message.channel.id, + }); + } +} diff --git a/application/usecases/approveAnonymousQuestionUseCase.ts b/application/usecases/approveAnonymousQuestionUseCase.ts new file mode 100644 index 0000000..88b9c71 --- /dev/null +++ b/application/usecases/approveAnonymousQuestionUseCase.ts @@ -0,0 +1,106 @@ +import { ButtonInteraction, MessageEmbed } from "discord.js"; +import ChatService from "../../domain/service/chatService"; +import ChannelResolver from "../../domain/service/channelResolver"; +import LoggerService from "../../domain/service/loggerService"; +import { ChannelSlug } from "../../types"; +import QuestionTrackingService from "../../domain/service/questionTrackingService"; + +interface ApproveAnonymousQuestionUseCaseOptions { + chatService: ChatService; + loggerService: LoggerService; + channelResolver: ChannelResolver; + questionTrackingService: QuestionTrackingService; +} + +interface ApproveAnonymousQuestionParams { + questionId: string; + moderatorId: string; + interactionId: string; + questionContent: string; + interaction: ButtonInteraction; +} + +export default class ApproveAnonymousQuestionUseCase { + private chatService: ChatService; + + private loggerService: LoggerService; + + private channelResolver: ChannelResolver; + + private questionTrackingService: QuestionTrackingService; + + constructor({ + chatService, + loggerService, + channelResolver, + questionTrackingService, + }: ApproveAnonymousQuestionUseCaseOptions) { + this.chatService = chatService; + this.loggerService = loggerService; + this.channelResolver = channelResolver; + this.questionTrackingService = questionTrackingService; + } + + async execute({ + questionId, + moderatorId, + interactionId, + questionContent, + interaction, + }: ApproveAnonymousQuestionParams) { + this.loggerService.log(`A aprovar a pergunta ${questionId} pelo moderador ${moderatorId}`); + + const customIdParts = interactionId.split("_"); + const dmChannelId = customIdParts[2]; + const userId = customIdParts[3]; + + const publicChannelId = this.channelResolver.getBySlug(ChannelSlug.QUESTIONS); + await this.chatService.sendMessageToChannel(`**Pergunta anónima:**\n\n${questionContent}`, publicChannelId); + + const publicMessageLink = interaction.guildId + ? `https://discord.com/channels/${interaction.guildId}/${publicChannelId}/` + : ""; + + if (dmChannelId) { + await this.chatService.sendMessageToChannel( + `A tua pergunta anónima foi aprovada e publicada no canal <#${publicChannelId}>!${ + publicMessageLink ? `\n\nVê aqui: ${publicMessageLink}` : "" + }`, + dmChannelId + ); + } + + try { + const updatedEmbed = new MessageEmbed() + .setTitle("Pergunta Anónima Aprovada") + .setDescription(questionContent) + .setColor(0x00ff00) // Green + .setFields([ + { name: "ID", value: questionId, inline: true }, + { name: "Aprovado por", value: `<@${moderatorId}>`, inline: true }, + ]) + .setFooter({ text: "Esta pergunta foi aprovada e publicada" }); + + if (interaction.message && "edit" in interaction.message) { + await interaction.message.edit({ + embeds: [updatedEmbed], + components: [], + }); + } else { + await interaction.update({ + embeds: [updatedEmbed], + components: [], + }); + } + } catch (error) { + this.loggerService.log(`Erro ao atualizar mensagem de moderação: ${error}`); + } + + if (userId) { + this.questionTrackingService.removeQuestion(userId); + this.loggerService.log(`Pergunta ${questionId} removida do tracking para o user ${userId}`); + } + + return { success: true }; + } +} diff --git a/application/usecases/rejectAnonymousQuestionUseCase.ts b/application/usecases/rejectAnonymousQuestionUseCase.ts new file mode 100644 index 0000000..7c64853 --- /dev/null +++ b/application/usecases/rejectAnonymousQuestionUseCase.ts @@ -0,0 +1,80 @@ +import { ButtonInteraction, MessageEmbed } from "discord.js"; +import ChatService from "../../domain/service/chatService"; +import LoggerService from "../../domain/service/loggerService"; +import QuestionTrackingService from "../../domain/service/questionTrackingService"; + +interface RejectAnonymousQuestionUseCaseOptions { + chatService: ChatService; + loggerService: LoggerService; + questionTrackingService: QuestionTrackingService; +} + +interface RejectAnonymousQuestionParams { + questionId: string; + moderatorId: string; + interactionId: string; + questionContent: string; + interaction: ButtonInteraction; + reason?: string; +} + +export default class RejectAnonymousQuestionUseCase { + private chatService: ChatService; + + private loggerService: LoggerService; + + private questionTrackingService: QuestionTrackingService; + + constructor({ chatService, loggerService, questionTrackingService }: RejectAnonymousQuestionUseCaseOptions) { + this.chatService = chatService; + this.loggerService = loggerService; + this.questionTrackingService = questionTrackingService; + } + + async execute({ + questionId, + moderatorId, + interactionId, + questionContent, + interaction, + }: RejectAnonymousQuestionParams) { + this.loggerService.log(`Rejeitando a pergunta ${questionId} pelo moderador ${moderatorId}`); + + const customIdParts = interactionId.split("_"); + const dmChannelId = customIdParts[2]; + const userId = customIdParts[3]; + + if (dmChannelId) { + await this.chatService.sendMessageToChannel( + `A tua pergunta anónima foi rejeitada pelos moderadores.`, + dmChannelId + ); + } + + try { + const updatedEmbed = new MessageEmbed() + .setTitle("Pergunta Anónima Rejeitada") + .setDescription(questionContent) + .setColor(0xff0000) // Red + .addFields([ + { name: "ID", value: questionId || "N/A", inline: true }, + { name: "Rejeitado por", value: moderatorId ? `<@${moderatorId}>` : "Desconhecido", inline: true }, + ]) + .setFooter({ text: "Esta pergunta foi rejeitada" }); + + await interaction.update({ + embeds: [updatedEmbed], + components: [], + }); + } catch (error) { + this.loggerService.log(`Erro ao atualizar mensagem de moderação: ${error}`); + } + + if (userId) { + this.questionTrackingService.removeQuestion(userId); + this.loggerService.log(`Pergunta ${questionId} removida do tracking para o user ${userId}`); + } + + return { success: true }; + } +} diff --git a/application/usecases/sendAnonymousQuestion/sendAnonymousQuestionUseCase.ts b/application/usecases/sendAnonymousQuestion/sendAnonymousQuestionUseCase.ts new file mode 100644 index 0000000..ecad82d --- /dev/null +++ b/application/usecases/sendAnonymousQuestion/sendAnonymousQuestionUseCase.ts @@ -0,0 +1,94 @@ +import LoggerService from "../../../domain/service/loggerService"; +import ChatService from "../../../domain/service/chatService"; +import ChannelResolver from "../../../domain/service/channelResolver"; +import { ChannelSlug } from "../../../types"; +import QuestionTrackingService from "../../../domain/service/questionTrackingService"; + +export default class SendAnonymousQuestionUseCase { + private chatService: ChatService; + + private loggerService: LoggerService; + + private channelResolver: ChannelResolver; + + private questionTrackingService: QuestionTrackingService; + + constructor({ + chatService, + loggerService, + channelResolver, + questionTrackingService, + }: { + chatService: ChatService; + loggerService: LoggerService; + channelResolver: ChannelResolver; + questionTrackingService: QuestionTrackingService; + }) { + this.chatService = chatService; + this.loggerService = loggerService; + this.channelResolver = channelResolver; + this.questionTrackingService = questionTrackingService; + } + + async execute({ + userId, + username, + questionContent, + dmChannelId, + }: { + userId: string; + username: string; + questionContent: string; + dmChannelId: string; + }): Promise { + if (this.questionTrackingService.hasPendingQuestion(userId)) { + await this.chatService.sendMessageToChannel( + "Já tens uma pergunta pendente. Por favor, aguarda até que seja aprovada ou rejeitada antes de enviar outra.", + dmChannelId + ); + return; + } + + this.loggerService.log(`Pergunta anónima recebida de ${username}: ${questionContent}`); + + const questionId = Date.now().toString(); + + const moderationChannelId = this.channelResolver.getBySlug(ChannelSlug.MODERATION); + + const approveCustomId = `approve_${questionId}_${dmChannelId}_${userId}`; + const rejectCustomId = `reject_${questionId}_${dmChannelId}_${userId}`; + + await this.chatService.sendEmbedWithButtons( + moderationChannelId, + { + title: "Nova Pergunta Anónima", + description: questionContent, + color: 0x3498db, // Blue + fields: [ + { name: "ID", value: questionId, inline: true }, + { name: "Enviado por", value: username, inline: true }, + ], + footer: { text: "Pergunta anónima - Moderação necessária" }, + }, + [ + { + customId: approveCustomId, + label: "Aprovar", + style: "SUCCESS", + }, + { + customId: rejectCustomId, + label: "Rejeitar", + style: "DANGER", + }, + ] + ); + + this.questionTrackingService.trackQuestion(userId, questionId); + + await this.chatService.sendMessageToChannel( + "A tua pergunta anônima foi recebida e será analisada pelos moderadores.", + dmChannelId + ); + } +} diff --git a/domain/service/channelResolver.ts b/domain/service/channelResolver.ts index 2b001c0..1d921d9 100644 --- a/domain/service/channelResolver.ts +++ b/domain/service/channelResolver.ts @@ -3,6 +3,8 @@ import { ChannelSlug } from "../../types"; const fallbackChannelIds: Record = { [ChannelSlug.ENTRANCE]: "855861944930402344", [ChannelSlug.JOBS]: "876826576749215744", + [ChannelSlug.MODERATION]: "987719981443723266", + [ChannelSlug.QUESTIONS]: "1065751368809324634", }; export default class ChannelResolver { diff --git a/domain/service/chatService.ts b/domain/service/chatService.ts index 0133401..415a919 100644 --- a/domain/service/chatService.ts +++ b/domain/service/chatService.ts @@ -1,3 +1,19 @@ +export interface EmbedOptions { + title?: string; + description?: string; + color?: number; + fields?: Array<{ name: string; value: string; inline?: boolean }>; + footer?: { text: string; iconURL?: string }; +} + +export interface ButtonOptions { + customId: string; + label: string; + style: "PRIMARY" | "SECONDARY" | "SUCCESS" | "DANGER"; +} + export default interface ChatService { - sendMessageToChannel(message: string, channelId: string): void; + sendMessageToChannel(message: string, channelId: string): Promise; + + sendEmbedWithButtons(channelId: string, embedOptions: EmbedOptions, buttons: ButtonOptions[]): Promise; } diff --git a/domain/service/commandUseCaseResolver.ts b/domain/service/commandUseCaseResolver.ts index 403d454..d953d37 100644 --- a/domain/service/commandUseCaseResolver.ts +++ b/domain/service/commandUseCaseResolver.ts @@ -1,5 +1,4 @@ import { Command, Context } from "../../types"; -import UseCaseNotFound from "../exception/useCaseNotFound"; import LoggerService from "./loggerService"; export default class CommandUseCaseResolver { @@ -13,15 +12,17 @@ export default class CommandUseCaseResolver { this.commands = commands; } - async resolveByCommand(command: string, context: Context): Promise { + async resolveByCommand(command: string, context: Context): Promise { this.loggerService.log(`Command received: "${command}"`); const commandInstance = this.commands.find((cmd) => cmd.name === command); if (!commandInstance) { - throw new UseCaseNotFound().byCommand(command); + this.loggerService.log(`Command not found: "${command}"`); + return false; } await commandInstance.execute(context); + return true; } } diff --git a/domain/service/interactionResolver.ts b/domain/service/interactionResolver.ts new file mode 100644 index 0000000..e77add0 --- /dev/null +++ b/domain/service/interactionResolver.ts @@ -0,0 +1,89 @@ +import { ButtonInteraction } from "discord.js"; +import ChatService from "./chatService"; +import ChannelResolver from "./channelResolver"; +import LoggerService from "./loggerService"; +import ApproveAnonymousQuestionUseCase from "../../application/usecases/approveAnonymousQuestionUseCase"; +import RejectAnonymousQuestionUseCase from "../../application/usecases/rejectAnonymousQuestionUseCase"; +import QuestionTrackingService from "./questionTrackingService"; + +interface InteractionResolverOptions { + chatService: ChatService; + loggerService: LoggerService; + channelResolver: ChannelResolver; + questionTrackingService: QuestionTrackingService; +} + +export default class InteractionResolver { + private readonly chatService: ChatService; + + private readonly loggerService: LoggerService; + + private readonly channelResolver: ChannelResolver; + + private readonly questionTrackingService: QuestionTrackingService; + + constructor({ chatService, loggerService, channelResolver, questionTrackingService }: InteractionResolverOptions) { + this.chatService = chatService; + this.loggerService = loggerService; + this.channelResolver = channelResolver; + this.questionTrackingService = questionTrackingService; + } + + async resolveButtonInteraction(interaction: ButtonInteraction): Promise { + const { customId } = interaction; + const [action, questionId] = customId.split("_"); + + try { + const { message } = interaction; + let questionContent = "Pergunta anónima"; + + if (message.embeds && message.embeds.length > 0) { + questionContent = message.embeds[0].description || questionContent; + } + + if (action === "approve") { + const approveUseCase = new ApproveAnonymousQuestionUseCase({ + chatService: this.chatService, + loggerService: this.loggerService, + channelResolver: this.channelResolver, + questionTrackingService: this.questionTrackingService, + }); + + await approveUseCase.execute({ + questionId, + moderatorId: interaction.user.id, + interactionId: customId, + questionContent, + interaction, + }); + } else if (action === "reject") { + const rejectUseCase = new RejectAnonymousQuestionUseCase({ + chatService: this.chatService, + loggerService: this.loggerService, + questionTrackingService: this.questionTrackingService, + }); + + await rejectUseCase.execute({ + questionId, + moderatorId: interaction.user.id, + interactionId: customId, + questionContent, + interaction, + }); + } + } catch (error) { + this.loggerService.log(`Erro ao processar interação de botão: ${error}`); + + if (!interaction.replied && !interaction.deferred) { + try { + await interaction.reply({ + content: "Ocorreu um erro ao processar esta ação.", + ephemeral: true, + }); + } catch (replyError) { + this.loggerService.log(`Não foi possível responder à interação: ${replyError}`); + } + } + } + } +} diff --git a/domain/service/questionTrackingService.ts b/domain/service/questionTrackingService.ts new file mode 100644 index 0000000..3fbae51 --- /dev/null +++ b/domain/service/questionTrackingService.ts @@ -0,0 +1,19 @@ +export default class QuestionTrackingService { + private pendingQuestions: Map = new Map(); // userId -> questionId + + hasPendingQuestion(userId: string): boolean { + return this.pendingQuestions.has(userId); + } + + trackQuestion(userId: string, questionId: string): void { + this.pendingQuestions.set(userId, questionId); + } + + removeQuestion(userId: string): void { + this.pendingQuestions.delete(userId); + } + + getQuestionId(userId: string): string | undefined { + return this.pendingQuestions.get(userId); + } +} diff --git a/index.ts b/index.ts index 0c0f878..ce8f839 100644 --- a/index.ts +++ b/index.ts @@ -10,13 +10,16 @@ import MessageRepository from "./domain/repository/messageRepository"; import LoggerService from "./domain/service/loggerService"; import CommandUseCaseResolver from "./domain/service/commandUseCaseResolver"; import ChannelResolver from "./domain/service/channelResolver"; +import InteractionResolver from "./domain/service/interactionResolver"; import KataService from "./domain/service/kataService/kataService"; import CodewarsKataService from "./infrastructure/service/codewarsKataService"; import ContentAggregatorService from "./domain/service/contentAggregatorService/contentAggregatorService"; import LemmyContentAggregatorService from "./infrastructure/service/lemmyContentAggregatorService"; +import QuestionTrackingService from "./domain/service/questionTrackingService"; import CodewarsLeaderboardCommand from "./application/command/codewarsLeaderboardCommand"; import DontAskToAskCommand from "./application/command/dontAskToAskCommand"; import OnlyCodeQuestionsCommand from "./application/command/onlyCodeQuestionsCommand"; +import AnonymousQuestionCommand from "./application/command/anonymousQuestionCommand"; import { Command } from "./types"; dotenv.config(); @@ -24,25 +27,40 @@ dotenv.config(); const { DISCORD_TOKEN } = process.env; const client = new Client({ - intents: [Intents.FLAGS.GUILDS, Intents.FLAGS.GUILD_MEMBERS, Intents.FLAGS.GUILD_MESSAGES], + intents: [ + Intents.FLAGS.GUILDS, + Intents.FLAGS.GUILD_MEMBERS, + Intents.FLAGS.GUILD_MESSAGES, + Intents.FLAGS.DIRECT_MESSAGES, + ], + partials: ["CHANNEL"], }); const messageRepository: MessageRepository = new FileMessageRepository(); const chatService: ChatService = new DiscordChatService(client); const loggerService: LoggerService = new ConsoleLoggerService(); const channelResolver: ChannelResolver = new ChannelResolver(); +const questionTrackingService: QuestionTrackingService = new QuestionTrackingService(); const kataService: KataService = new CodewarsKataService(); const lemmyContentAggregatorService: ContentAggregatorService = new LemmyContentAggregatorService(); const commands: Command[] = [ new CodewarsLeaderboardCommand(chatService, kataService), new DontAskToAskCommand(chatService), new OnlyCodeQuestionsCommand(chatService), + new AnonymousQuestionCommand(chatService, loggerService, channelResolver, questionTrackingService), ]; const useCaseResolver = new CommandUseCaseResolver({ commands, loggerService, }); +const interactionResolver = new InteractionResolver({ + chatService, + loggerService, + channelResolver, + questionTrackingService, +}); + const checkForNewPosts = async () => { loggerService.log("Checking for new posts on content aggregator..."); @@ -105,18 +123,25 @@ client.on("guildMemberAdd", (member: GuildMember) => }).execute(member) ); -client.on("messageCreate", (messages: Message) => { +client.on("messageCreate", (message: Message) => { const COMMAND_PREFIX = "!"; - if (!messages.content.startsWith(COMMAND_PREFIX)) return; + if (!message.content.startsWith(COMMAND_PREFIX)) return; - const command = messages.content.split(" ")[0]; + const command = message.content.split(" ")[0]; try { useCaseResolver.resolveByCommand(command, { - channelId: messages.channel.id, + channelId: message.channel.id, + message, }); } catch (error: unknown) { loggerService.log(error); } }); + +client.on("interactionCreate", async (interaction) => { + if (interaction.isButton()) { + await interactionResolver.resolveButtonInteraction(interaction); + } +}); diff --git a/infrastructure/service/discordChatService.ts b/infrastructure/service/discordChatService.ts index 90228eb..9c6db06 100644 --- a/infrastructure/service/discordChatService.ts +++ b/infrastructure/service/discordChatService.ts @@ -1,8 +1,12 @@ -import { Client } from "discord.js"; -import ChatService from "../../domain/service/chatService"; +import { Client, MessageActionRow, MessageButton, MessageEmbed } from "discord.js"; +import ChatService, { ButtonOptions, EmbedOptions } from "../../domain/service/chatService"; export default class DiscordChatService implements ChatService { - constructor(private client: Client) {} + private client: Client; + + constructor(client: Client) { + this.client = client; + } async sendMessageToChannel(message: string, channelId: string): Promise { const channel = await this.client.channels.fetch(channelId); @@ -17,4 +21,39 @@ export default class DiscordChatService implements ChatService { channel.send(message); } + + async sendEmbedWithButtons(channelId: string, embedOptions: EmbedOptions, buttons: ButtonOptions[]): Promise { + const channel = await this.client.channels.fetch(channelId); + + if (channel === null) { + throw new Error(`Channel with id ${channelId} not found!`); + } + + if (!channel?.isText()) { + throw new Error(`Channel with id ${channelId} is not a text channel!`); + } + + const fields = embedOptions.fields || []; + + const embed = new MessageEmbed({ + title: embedOptions.title || "", + description: embedOptions.description || "", + color: embedOptions.color || 0x0099ff, + fields, + footer: embedOptions.footer + ? { + text: embedOptions.footer.text, + iconURL: embedOptions.footer.iconURL, + } + : undefined, + }); + + const row = new MessageActionRow(); + + buttons.forEach((button) => { + row.addComponents(new MessageButton().setCustomId(button.customId).setLabel(button.label).setStyle(button.style)); + }); + + await channel.send({ embeds: [embed], components: [row] }); + } } diff --git a/types.ts b/types.ts index 5e7f2bf..ed9fb51 100644 --- a/types.ts +++ b/types.ts @@ -1,14 +1,19 @@ +import { Message } from "discord.js"; + export interface ChatMember { id: string; } export interface Context { channelId: string; + message?: Message; } export enum ChannelSlug { ENTRANCE = "ENTRANCE", JOBS = "JOBS", + MODERATION = "MODERATION", + QUESTIONS = "QUESTIONS", } export type CommandMessages = { diff --git a/vitest/service/commandUseCaseResolver.spec.ts b/vitest/service/commandUseCaseResolver.spec.ts index c0197c1..0f530d1 100644 --- a/vitest/service/commandUseCaseResolver.spec.ts +++ b/vitest/service/commandUseCaseResolver.spec.ts @@ -48,10 +48,8 @@ describe("CommandUseCaseResolver", () => { expect(() => commandUseCaseResolver.resolveByCommand("!cwl", mockContext)).not.toThrow(); }); - it("should throw UseCaseNotFound error for unknown command", async () => { - await expect(commandUseCaseResolver.resolveByCommand("!unknown", mockContext)).rejects.toThrow( - 'Use case for command "!unknown" not found' - ); + it("should return false for unknown command", async () => { + await expect(commandUseCaseResolver.resolveByCommand("!unknown", mockContext)).resolves.toBe(false); }); afterEach(() => { From c3a94d608e50cb265e6c768bf8f9eba84c4bc1c1 Mon Sep 17 00:00:00 2001 From: hugopocas Date: Tue, 25 Feb 2025 16:34:37 +0000 Subject: [PATCH 2/2] =?UTF-8?q?feat:=20adicionar=20perguntas=20an=C3=B3nim?= =?UTF-8?q?as?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../command/anonymousQuestionCommand.ts | 69 ++++++++++++ .../approveAnonymousQuestionUseCase.ts | 106 ++++++++++++++++++ .../rejectAnonymousQuestionUseCase.ts | 80 +++++++++++++ .../sendAnonymousQuestionUseCase.ts | 94 ++++++++++++++++ domain/service/channelResolver.ts | 2 + domain/service/chatService.ts | 18 ++- domain/service/commandUseCaseResolver.ts | 7 +- domain/service/interactionResolver.ts | 89 +++++++++++++++ domain/service/questionTrackingService.ts | 19 ++++ index.ts | 35 +++++- infrastructure/service/discordChatService.ts | 45 +++++++- types.ts | 5 + vitest/service/commandUseCaseResolver.spec.ts | 6 +- 13 files changed, 559 insertions(+), 16 deletions(-) create mode 100644 application/command/anonymousQuestionCommand.ts create mode 100644 application/usecases/approveAnonymousQuestionUseCase.ts create mode 100644 application/usecases/rejectAnonymousQuestionUseCase.ts create mode 100644 application/usecases/sendAnonymousQuestion/sendAnonymousQuestionUseCase.ts create mode 100644 domain/service/interactionResolver.ts create mode 100644 domain/service/questionTrackingService.ts diff --git a/application/command/anonymousQuestionCommand.ts b/application/command/anonymousQuestionCommand.ts new file mode 100644 index 0000000..9cce5a6 --- /dev/null +++ b/application/command/anonymousQuestionCommand.ts @@ -0,0 +1,69 @@ +import { Command, Context } from "../../types"; +import ChatService from "../../domain/service/chatService"; +import LoggerService from "../../domain/service/loggerService"; +import ChannelResolver from "../../domain/service/channelResolver"; +import SendAnonymousQuestionUseCase from "../usecases/sendAnonymousQuestion/sendAnonymousQuestionUseCase"; +import QuestionTrackingService from "../../domain/service/questionTrackingService"; + +export default class AnonymousQuestionCommand implements Command { + readonly name = "!pergunta"; + + private readonly chatService: ChatService; + + private readonly loggerService: LoggerService; + + private readonly channelResolver: ChannelResolver; + + private readonly questionTrackingService: QuestionTrackingService; + + constructor( + chatService: ChatService, + loggerService: LoggerService, + channelResolver: ChannelResolver, + questionTrackingService: QuestionTrackingService + ) { + this.chatService = chatService; + this.loggerService = loggerService; + this.channelResolver = channelResolver; + this.questionTrackingService = questionTrackingService; + } + + async execute(context: Context): Promise { + if (!context.message) { + return; + } + + const { message } = context; + + const isDM = message.channel.type === "DM"; + + if (!isDM) { + this.chatService.sendMessageToChannel("Este comando só pode ser usado em mensagens diretas.", context.channelId); + return; + } + + const questionContent = message.content.replace(/!pergunta\s+/i, "").trim(); + + if (!questionContent) { + this.chatService.sendMessageToChannel( + "Por favor, forneçe uma pergunta após o comando. Exemplo: `!pergunta Como faço para...`", + message.channel.id + ); + return; + } + + const sendAnonymousQuestionUseCase = new SendAnonymousQuestionUseCase({ + chatService: this.chatService, + loggerService: this.loggerService, + channelResolver: this.channelResolver, + questionTrackingService: this.questionTrackingService, + }); + + await sendAnonymousQuestionUseCase.execute({ + userId: message.author.id, + username: message.author.username, + questionContent, + dmChannelId: message.channel.id, + }); + } +} diff --git a/application/usecases/approveAnonymousQuestionUseCase.ts b/application/usecases/approveAnonymousQuestionUseCase.ts new file mode 100644 index 0000000..88b9c71 --- /dev/null +++ b/application/usecases/approveAnonymousQuestionUseCase.ts @@ -0,0 +1,106 @@ +import { ButtonInteraction, MessageEmbed } from "discord.js"; +import ChatService from "../../domain/service/chatService"; +import ChannelResolver from "../../domain/service/channelResolver"; +import LoggerService from "../../domain/service/loggerService"; +import { ChannelSlug } from "../../types"; +import QuestionTrackingService from "../../domain/service/questionTrackingService"; + +interface ApproveAnonymousQuestionUseCaseOptions { + chatService: ChatService; + loggerService: LoggerService; + channelResolver: ChannelResolver; + questionTrackingService: QuestionTrackingService; +} + +interface ApproveAnonymousQuestionParams { + questionId: string; + moderatorId: string; + interactionId: string; + questionContent: string; + interaction: ButtonInteraction; +} + +export default class ApproveAnonymousQuestionUseCase { + private chatService: ChatService; + + private loggerService: LoggerService; + + private channelResolver: ChannelResolver; + + private questionTrackingService: QuestionTrackingService; + + constructor({ + chatService, + loggerService, + channelResolver, + questionTrackingService, + }: ApproveAnonymousQuestionUseCaseOptions) { + this.chatService = chatService; + this.loggerService = loggerService; + this.channelResolver = channelResolver; + this.questionTrackingService = questionTrackingService; + } + + async execute({ + questionId, + moderatorId, + interactionId, + questionContent, + interaction, + }: ApproveAnonymousQuestionParams) { + this.loggerService.log(`A aprovar a pergunta ${questionId} pelo moderador ${moderatorId}`); + + const customIdParts = interactionId.split("_"); + const dmChannelId = customIdParts[2]; + const userId = customIdParts[3]; + + const publicChannelId = this.channelResolver.getBySlug(ChannelSlug.QUESTIONS); + await this.chatService.sendMessageToChannel(`**Pergunta anónima:**\n\n${questionContent}`, publicChannelId); + + const publicMessageLink = interaction.guildId + ? `https://discord.com/channels/${interaction.guildId}/${publicChannelId}/` + : ""; + + if (dmChannelId) { + await this.chatService.sendMessageToChannel( + `A tua pergunta anónima foi aprovada e publicada no canal <#${publicChannelId}>!${ + publicMessageLink ? `\n\nVê aqui: ${publicMessageLink}` : "" + }`, + dmChannelId + ); + } + + try { + const updatedEmbed = new MessageEmbed() + .setTitle("Pergunta Anónima Aprovada") + .setDescription(questionContent) + .setColor(0x00ff00) // Green + .setFields([ + { name: "ID", value: questionId, inline: true }, + { name: "Aprovado por", value: `<@${moderatorId}>`, inline: true }, + ]) + .setFooter({ text: "Esta pergunta foi aprovada e publicada" }); + + if (interaction.message && "edit" in interaction.message) { + await interaction.message.edit({ + embeds: [updatedEmbed], + components: [], + }); + } else { + await interaction.update({ + embeds: [updatedEmbed], + components: [], + }); + } + } catch (error) { + this.loggerService.log(`Erro ao atualizar mensagem de moderação: ${error}`); + } + + if (userId) { + this.questionTrackingService.removeQuestion(userId); + this.loggerService.log(`Pergunta ${questionId} removida do tracking para o user ${userId}`); + } + + return { success: true }; + } +} diff --git a/application/usecases/rejectAnonymousQuestionUseCase.ts b/application/usecases/rejectAnonymousQuestionUseCase.ts new file mode 100644 index 0000000..7c64853 --- /dev/null +++ b/application/usecases/rejectAnonymousQuestionUseCase.ts @@ -0,0 +1,80 @@ +import { ButtonInteraction, MessageEmbed } from "discord.js"; +import ChatService from "../../domain/service/chatService"; +import LoggerService from "../../domain/service/loggerService"; +import QuestionTrackingService from "../../domain/service/questionTrackingService"; + +interface RejectAnonymousQuestionUseCaseOptions { + chatService: ChatService; + loggerService: LoggerService; + questionTrackingService: QuestionTrackingService; +} + +interface RejectAnonymousQuestionParams { + questionId: string; + moderatorId: string; + interactionId: string; + questionContent: string; + interaction: ButtonInteraction; + reason?: string; +} + +export default class RejectAnonymousQuestionUseCase { + private chatService: ChatService; + + private loggerService: LoggerService; + + private questionTrackingService: QuestionTrackingService; + + constructor({ chatService, loggerService, questionTrackingService }: RejectAnonymousQuestionUseCaseOptions) { + this.chatService = chatService; + this.loggerService = loggerService; + this.questionTrackingService = questionTrackingService; + } + + async execute({ + questionId, + moderatorId, + interactionId, + questionContent, + interaction, + }: RejectAnonymousQuestionParams) { + this.loggerService.log(`Rejeitando a pergunta ${questionId} pelo moderador ${moderatorId}`); + + const customIdParts = interactionId.split("_"); + const dmChannelId = customIdParts[2]; + const userId = customIdParts[3]; + + if (dmChannelId) { + await this.chatService.sendMessageToChannel( + `A tua pergunta anónima foi rejeitada pelos moderadores.`, + dmChannelId + ); + } + + try { + const updatedEmbed = new MessageEmbed() + .setTitle("Pergunta Anónima Rejeitada") + .setDescription(questionContent) + .setColor(0xff0000) // Red + .addFields([ + { name: "ID", value: questionId || "N/A", inline: true }, + { name: "Rejeitado por", value: moderatorId ? `<@${moderatorId}>` : "Desconhecido", inline: true }, + ]) + .setFooter({ text: "Esta pergunta foi rejeitada" }); + + await interaction.update({ + embeds: [updatedEmbed], + components: [], + }); + } catch (error) { + this.loggerService.log(`Erro ao atualizar mensagem de moderação: ${error}`); + } + + if (userId) { + this.questionTrackingService.removeQuestion(userId); + this.loggerService.log(`Pergunta ${questionId} removida do tracking para o user ${userId}`); + } + + return { success: true }; + } +} diff --git a/application/usecases/sendAnonymousQuestion/sendAnonymousQuestionUseCase.ts b/application/usecases/sendAnonymousQuestion/sendAnonymousQuestionUseCase.ts new file mode 100644 index 0000000..a9643b9 --- /dev/null +++ b/application/usecases/sendAnonymousQuestion/sendAnonymousQuestionUseCase.ts @@ -0,0 +1,94 @@ +import LoggerService from "../../../domain/service/loggerService"; +import ChatService from "../../../domain/service/chatService"; +import ChannelResolver from "../../../domain/service/channelResolver"; +import { ChannelSlug } from "../../../types"; +import QuestionTrackingService from "../../../domain/service/questionTrackingService"; + +export default class SendAnonymousQuestionUseCase { + private chatService: ChatService; + + private loggerService: LoggerService; + + private channelResolver: ChannelResolver; + + private questionTrackingService: QuestionTrackingService; + + constructor({ + chatService, + loggerService, + channelResolver, + questionTrackingService, + }: { + chatService: ChatService; + loggerService: LoggerService; + channelResolver: ChannelResolver; + questionTrackingService: QuestionTrackingService; + }) { + this.chatService = chatService; + this.loggerService = loggerService; + this.channelResolver = channelResolver; + this.questionTrackingService = questionTrackingService; + } + + async execute({ + userId, + username, + questionContent, + dmChannelId, + }: { + userId: string; + username: string; + questionContent: string; + dmChannelId: string; + }): Promise { + if (this.questionTrackingService.hasPendingQuestion(userId)) { + await this.chatService.sendMessageToChannel( + "Já tens uma pergunta pendente. Por favor, aguarda até que seja aprovada ou rejeitada antes de enviar outra.", + dmChannelId + ); + return; + } + + this.loggerService.log(`Pergunta anónima recebida de ${username}: ${questionContent}`); + + const questionId = Date.now().toString(); + + const moderationChannelId = this.channelResolver.getBySlug(ChannelSlug.MODERATION); + + const approveCustomId = `approve_${questionId}_${dmChannelId}_${userId}`; + const rejectCustomId = `reject_${questionId}_${dmChannelId}_${userId}`; + + await this.chatService.sendEmbedWithButtons( + moderationChannelId, + { + title: "Nova Pergunta Anónima", + description: questionContent, + color: 0x3498db, // Blue + fields: [ + { name: "ID", value: questionId, inline: true }, + { name: "Enviado por", value: username, inline: true }, + ], + footer: { text: "Pergunta anónima - Moderação necessária" }, + }, + [ + { + customId: approveCustomId, + label: "Aprovar", + style: "SUCCESS", + }, + { + customId: rejectCustomId, + label: "Rejeitar", + style: "DANGER", + }, + ] + ); + + this.questionTrackingService.trackQuestion(userId, questionId); + + await this.chatService.sendMessageToChannel( + "A tua pergunta anónima foi recebida e será analisada pelos moderadores.", + dmChannelId + ); + } +} diff --git a/domain/service/channelResolver.ts b/domain/service/channelResolver.ts index 2b001c0..1d921d9 100644 --- a/domain/service/channelResolver.ts +++ b/domain/service/channelResolver.ts @@ -3,6 +3,8 @@ import { ChannelSlug } from "../../types"; const fallbackChannelIds: Record = { [ChannelSlug.ENTRANCE]: "855861944930402344", [ChannelSlug.JOBS]: "876826576749215744", + [ChannelSlug.MODERATION]: "987719981443723266", + [ChannelSlug.QUESTIONS]: "1065751368809324634", }; export default class ChannelResolver { diff --git a/domain/service/chatService.ts b/domain/service/chatService.ts index 0133401..415a919 100644 --- a/domain/service/chatService.ts +++ b/domain/service/chatService.ts @@ -1,3 +1,19 @@ +export interface EmbedOptions { + title?: string; + description?: string; + color?: number; + fields?: Array<{ name: string; value: string; inline?: boolean }>; + footer?: { text: string; iconURL?: string }; +} + +export interface ButtonOptions { + customId: string; + label: string; + style: "PRIMARY" | "SECONDARY" | "SUCCESS" | "DANGER"; +} + export default interface ChatService { - sendMessageToChannel(message: string, channelId: string): void; + sendMessageToChannel(message: string, channelId: string): Promise; + + sendEmbedWithButtons(channelId: string, embedOptions: EmbedOptions, buttons: ButtonOptions[]): Promise; } diff --git a/domain/service/commandUseCaseResolver.ts b/domain/service/commandUseCaseResolver.ts index 403d454..d953d37 100644 --- a/domain/service/commandUseCaseResolver.ts +++ b/domain/service/commandUseCaseResolver.ts @@ -1,5 +1,4 @@ import { Command, Context } from "../../types"; -import UseCaseNotFound from "../exception/useCaseNotFound"; import LoggerService from "./loggerService"; export default class CommandUseCaseResolver { @@ -13,15 +12,17 @@ export default class CommandUseCaseResolver { this.commands = commands; } - async resolveByCommand(command: string, context: Context): Promise { + async resolveByCommand(command: string, context: Context): Promise { this.loggerService.log(`Command received: "${command}"`); const commandInstance = this.commands.find((cmd) => cmd.name === command); if (!commandInstance) { - throw new UseCaseNotFound().byCommand(command); + this.loggerService.log(`Command not found: "${command}"`); + return false; } await commandInstance.execute(context); + return true; } } diff --git a/domain/service/interactionResolver.ts b/domain/service/interactionResolver.ts new file mode 100644 index 0000000..e77add0 --- /dev/null +++ b/domain/service/interactionResolver.ts @@ -0,0 +1,89 @@ +import { ButtonInteraction } from "discord.js"; +import ChatService from "./chatService"; +import ChannelResolver from "./channelResolver"; +import LoggerService from "./loggerService"; +import ApproveAnonymousQuestionUseCase from "../../application/usecases/approveAnonymousQuestionUseCase"; +import RejectAnonymousQuestionUseCase from "../../application/usecases/rejectAnonymousQuestionUseCase"; +import QuestionTrackingService from "./questionTrackingService"; + +interface InteractionResolverOptions { + chatService: ChatService; + loggerService: LoggerService; + channelResolver: ChannelResolver; + questionTrackingService: QuestionTrackingService; +} + +export default class InteractionResolver { + private readonly chatService: ChatService; + + private readonly loggerService: LoggerService; + + private readonly channelResolver: ChannelResolver; + + private readonly questionTrackingService: QuestionTrackingService; + + constructor({ chatService, loggerService, channelResolver, questionTrackingService }: InteractionResolverOptions) { + this.chatService = chatService; + this.loggerService = loggerService; + this.channelResolver = channelResolver; + this.questionTrackingService = questionTrackingService; + } + + async resolveButtonInteraction(interaction: ButtonInteraction): Promise { + const { customId } = interaction; + const [action, questionId] = customId.split("_"); + + try { + const { message } = interaction; + let questionContent = "Pergunta anónima"; + + if (message.embeds && message.embeds.length > 0) { + questionContent = message.embeds[0].description || questionContent; + } + + if (action === "approve") { + const approveUseCase = new ApproveAnonymousQuestionUseCase({ + chatService: this.chatService, + loggerService: this.loggerService, + channelResolver: this.channelResolver, + questionTrackingService: this.questionTrackingService, + }); + + await approveUseCase.execute({ + questionId, + moderatorId: interaction.user.id, + interactionId: customId, + questionContent, + interaction, + }); + } else if (action === "reject") { + const rejectUseCase = new RejectAnonymousQuestionUseCase({ + chatService: this.chatService, + loggerService: this.loggerService, + questionTrackingService: this.questionTrackingService, + }); + + await rejectUseCase.execute({ + questionId, + moderatorId: interaction.user.id, + interactionId: customId, + questionContent, + interaction, + }); + } + } catch (error) { + this.loggerService.log(`Erro ao processar interação de botão: ${error}`); + + if (!interaction.replied && !interaction.deferred) { + try { + await interaction.reply({ + content: "Ocorreu um erro ao processar esta ação.", + ephemeral: true, + }); + } catch (replyError) { + this.loggerService.log(`Não foi possível responder à interação: ${replyError}`); + } + } + } + } +} diff --git a/domain/service/questionTrackingService.ts b/domain/service/questionTrackingService.ts new file mode 100644 index 0000000..3fbae51 --- /dev/null +++ b/domain/service/questionTrackingService.ts @@ -0,0 +1,19 @@ +export default class QuestionTrackingService { + private pendingQuestions: Map = new Map(); // userId -> questionId + + hasPendingQuestion(userId: string): boolean { + return this.pendingQuestions.has(userId); + } + + trackQuestion(userId: string, questionId: string): void { + this.pendingQuestions.set(userId, questionId); + } + + removeQuestion(userId: string): void { + this.pendingQuestions.delete(userId); + } + + getQuestionId(userId: string): string | undefined { + return this.pendingQuestions.get(userId); + } +} diff --git a/index.ts b/index.ts index 0c0f878..ce8f839 100644 --- a/index.ts +++ b/index.ts @@ -10,13 +10,16 @@ import MessageRepository from "./domain/repository/messageRepository"; import LoggerService from "./domain/service/loggerService"; import CommandUseCaseResolver from "./domain/service/commandUseCaseResolver"; import ChannelResolver from "./domain/service/channelResolver"; +import InteractionResolver from "./domain/service/interactionResolver"; import KataService from "./domain/service/kataService/kataService"; import CodewarsKataService from "./infrastructure/service/codewarsKataService"; import ContentAggregatorService from "./domain/service/contentAggregatorService/contentAggregatorService"; import LemmyContentAggregatorService from "./infrastructure/service/lemmyContentAggregatorService"; +import QuestionTrackingService from "./domain/service/questionTrackingService"; import CodewarsLeaderboardCommand from "./application/command/codewarsLeaderboardCommand"; import DontAskToAskCommand from "./application/command/dontAskToAskCommand"; import OnlyCodeQuestionsCommand from "./application/command/onlyCodeQuestionsCommand"; +import AnonymousQuestionCommand from "./application/command/anonymousQuestionCommand"; import { Command } from "./types"; dotenv.config(); @@ -24,25 +27,40 @@ dotenv.config(); const { DISCORD_TOKEN } = process.env; const client = new Client({ - intents: [Intents.FLAGS.GUILDS, Intents.FLAGS.GUILD_MEMBERS, Intents.FLAGS.GUILD_MESSAGES], + intents: [ + Intents.FLAGS.GUILDS, + Intents.FLAGS.GUILD_MEMBERS, + Intents.FLAGS.GUILD_MESSAGES, + Intents.FLAGS.DIRECT_MESSAGES, + ], + partials: ["CHANNEL"], }); const messageRepository: MessageRepository = new FileMessageRepository(); const chatService: ChatService = new DiscordChatService(client); const loggerService: LoggerService = new ConsoleLoggerService(); const channelResolver: ChannelResolver = new ChannelResolver(); +const questionTrackingService: QuestionTrackingService = new QuestionTrackingService(); const kataService: KataService = new CodewarsKataService(); const lemmyContentAggregatorService: ContentAggregatorService = new LemmyContentAggregatorService(); const commands: Command[] = [ new CodewarsLeaderboardCommand(chatService, kataService), new DontAskToAskCommand(chatService), new OnlyCodeQuestionsCommand(chatService), + new AnonymousQuestionCommand(chatService, loggerService, channelResolver, questionTrackingService), ]; const useCaseResolver = new CommandUseCaseResolver({ commands, loggerService, }); +const interactionResolver = new InteractionResolver({ + chatService, + loggerService, + channelResolver, + questionTrackingService, +}); + const checkForNewPosts = async () => { loggerService.log("Checking for new posts on content aggregator..."); @@ -105,18 +123,25 @@ client.on("guildMemberAdd", (member: GuildMember) => }).execute(member) ); -client.on("messageCreate", (messages: Message) => { +client.on("messageCreate", (message: Message) => { const COMMAND_PREFIX = "!"; - if (!messages.content.startsWith(COMMAND_PREFIX)) return; + if (!message.content.startsWith(COMMAND_PREFIX)) return; - const command = messages.content.split(" ")[0]; + const command = message.content.split(" ")[0]; try { useCaseResolver.resolveByCommand(command, { - channelId: messages.channel.id, + channelId: message.channel.id, + message, }); } catch (error: unknown) { loggerService.log(error); } }); + +client.on("interactionCreate", async (interaction) => { + if (interaction.isButton()) { + await interactionResolver.resolveButtonInteraction(interaction); + } +}); diff --git a/infrastructure/service/discordChatService.ts b/infrastructure/service/discordChatService.ts index 90228eb..9c6db06 100644 --- a/infrastructure/service/discordChatService.ts +++ b/infrastructure/service/discordChatService.ts @@ -1,8 +1,12 @@ -import { Client } from "discord.js"; -import ChatService from "../../domain/service/chatService"; +import { Client, MessageActionRow, MessageButton, MessageEmbed } from "discord.js"; +import ChatService, { ButtonOptions, EmbedOptions } from "../../domain/service/chatService"; export default class DiscordChatService implements ChatService { - constructor(private client: Client) {} + private client: Client; + + constructor(client: Client) { + this.client = client; + } async sendMessageToChannel(message: string, channelId: string): Promise { const channel = await this.client.channels.fetch(channelId); @@ -17,4 +21,39 @@ export default class DiscordChatService implements ChatService { channel.send(message); } + + async sendEmbedWithButtons(channelId: string, embedOptions: EmbedOptions, buttons: ButtonOptions[]): Promise { + const channel = await this.client.channels.fetch(channelId); + + if (channel === null) { + throw new Error(`Channel with id ${channelId} not found!`); + } + + if (!channel?.isText()) { + throw new Error(`Channel with id ${channelId} is not a text channel!`); + } + + const fields = embedOptions.fields || []; + + const embed = new MessageEmbed({ + title: embedOptions.title || "", + description: embedOptions.description || "", + color: embedOptions.color || 0x0099ff, + fields, + footer: embedOptions.footer + ? { + text: embedOptions.footer.text, + iconURL: embedOptions.footer.iconURL, + } + : undefined, + }); + + const row = new MessageActionRow(); + + buttons.forEach((button) => { + row.addComponents(new MessageButton().setCustomId(button.customId).setLabel(button.label).setStyle(button.style)); + }); + + await channel.send({ embeds: [embed], components: [row] }); + } } diff --git a/types.ts b/types.ts index 5e7f2bf..ed9fb51 100644 --- a/types.ts +++ b/types.ts @@ -1,14 +1,19 @@ +import { Message } from "discord.js"; + export interface ChatMember { id: string; } export interface Context { channelId: string; + message?: Message; } export enum ChannelSlug { ENTRANCE = "ENTRANCE", JOBS = "JOBS", + MODERATION = "MODERATION", + QUESTIONS = "QUESTIONS", } export type CommandMessages = { diff --git a/vitest/service/commandUseCaseResolver.spec.ts b/vitest/service/commandUseCaseResolver.spec.ts index c0197c1..0f530d1 100644 --- a/vitest/service/commandUseCaseResolver.spec.ts +++ b/vitest/service/commandUseCaseResolver.spec.ts @@ -48,10 +48,8 @@ describe("CommandUseCaseResolver", () => { expect(() => commandUseCaseResolver.resolveByCommand("!cwl", mockContext)).not.toThrow(); }); - it("should throw UseCaseNotFound error for unknown command", async () => { - await expect(commandUseCaseResolver.resolveByCommand("!unknown", mockContext)).rejects.toThrow( - 'Use case for command "!unknown" not found' - ); + it("should return false for unknown command", async () => { + await expect(commandUseCaseResolver.resolveByCommand("!unknown", mockContext)).resolves.toBe(false); }); afterEach(() => {