From 4254ed97831d08bac64a027097c4e3af83928afe Mon Sep 17 00:00:00 2001 From: Lucas Santana Date: Sat, 4 Jul 2026 10:18:42 -0300 Subject: [PATCH 1/2] fix(bot): collect /vaga descricao via modal, not a single-line option MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Discord's plain string options can't carry real newlines — pasting a bulleted requirements list into one collapses it onto a single line (browsers flatten
  • items into spaces on copy), producing garbled output like "- Req A. - Req B. - Req C." instead of a real bullet list. descricao is now collected via a modal with a paragraph-style text input, which does support multi-line text, so whoever runs /vaga can paste or type requirements with real line breaks preserved end to end. --- .../functions/general/commands/vaga.spec.ts | 68 ++++++++++++++++++- .../src/functions/general/commands/vaga.ts | 54 ++++++++++++--- 2 files changed, 109 insertions(+), 13 deletions(-) diff --git a/packages/bot/src/functions/general/commands/vaga.spec.ts b/packages/bot/src/functions/general/commands/vaga.spec.ts index cc5261018..8aefef6d2 100644 --- a/packages/bot/src/functions/general/commands/vaga.spec.ts +++ b/packages/bot/src/functions/general/commands/vaga.spec.ts @@ -78,7 +78,18 @@ function makeInteraction( : Promise.reject(new Error('timeout')), ), } - const reply = jest.fn(() => Promise.resolve(message)) + // descricao is now collected via a modal (paragraph text input), not a + // plain string option — the modal submission is its own interaction + // that owns the reply/editReply for the preview + button flow. + const modalReply = jest.fn(() => Promise.resolve(message)) + const modalSubmit = { + user: { id: 'u1' }, + fields: { + getTextInputValue: (name: string) => opts[name] ?? '', + }, + reply: modalReply, + editReply: jest.fn(), + } const interaction = { user: { id: 'u1', tag: 'mod#1' }, guild: { @@ -98,10 +109,19 @@ function makeInteraction( options: { getString: (name: string) => opts[name] ?? null, }, - reply, + showModal: jest.fn(), + awaitModalSubmit: jest.fn(() => Promise.resolve(modalSubmit)), + reply: jest.fn(), editReply: jest.fn(), } - return { interaction, reply, send, update, message } + return { + interaction, + reply: modalReply, + send, + update, + message, + modalSubmit, + } } describe('/vaga command', () => { @@ -185,4 +205,46 @@ describe('/vaga command', () => { }) expect(send).not.toHaveBeenCalled() }) + + it('shows a modal for descricao before doing any lookups', async () => { + const { interaction } = makeInteraction(base, { + customId: 'vaga_cancel', + }) + await vaga.execute({ + interaction: interaction as never, + client: {} as never, + }) + expect(interaction.showModal).toHaveBeenCalledTimes(1) + expect(interaction.awaitModalSubmit).toHaveBeenCalledTimes(1) + }) + + it('preserves real line breaks from the modal in the published message', async () => { + const multiline = { + ...base, + descricao: '- Requisito um\n- Requisito dois\n- Requisito três', + } + const { interaction, send } = makeInteraction(multiline, { + customId: 'vaga_publish', + }) + await vaga.execute({ + interaction: interaction as never, + client: {} as never, + }) + const sent = send.mock.calls[0][0] as { content: string } + expect(sent.content).toContain( + '- Requisito um\n- Requisito dois\n- Requisito três', + ) + }) + + it('does nothing further when the modal submission times out', async () => { + const { interaction, send } = makeInteraction(base) + interaction.awaitModalSubmit = jest.fn(() => + Promise.reject(new Error('time')), + ) + await vaga.execute({ + interaction: interaction as never, + client: {} as never, + }) + expect(send).not.toHaveBeenCalled() + }) }) diff --git a/packages/bot/src/functions/general/commands/vaga.ts b/packages/bot/src/functions/general/commands/vaga.ts index 701f04f62..f200fc53d 100644 --- a/packages/bot/src/functions/general/commands/vaga.ts +++ b/packages/bot/src/functions/general/commands/vaga.ts @@ -6,6 +6,9 @@ import { ButtonStyle, ComponentType, ChannelType, + ModalBuilder, + TextInputBuilder, + TextInputStyle, type ChatInputCommandInteraction, type GuildBasedChannel, } from 'discord.js' @@ -46,6 +49,40 @@ function buildVagaMessage( return parts.join('\n\n') } +// Discord's plain string options are single-line inputs — pasting a bulleted +// requirements list into one collapses it onto one line (browsers flatten +//
  • newlines into spaces on copy). A modal's paragraph text input is the +// only way to actually accept real line breaks here. +function buildDescricaoModal(): ModalBuilder { + return new ModalBuilder() + .setCustomId('vaga_descricao') + .setTitle('Descrição da vaga') + .addComponents( + new ActionRowBuilder().addComponents( + new TextInputBuilder() + .setCustomId('descricao') + .setLabel('Descrição / requisitos') + .setStyle(TextInputStyle.Paragraph) + .setRequired(true) + .setMaxLength(1800), + ), + ) +} + +async function collectDescricao(chat: ChatInputCommandInteraction) { + await chat.showModal(buildDescricaoModal()) + try { + return await chat.awaitModalSubmit({ + filter: (i) => i.user.id === chat.user.id, + time: 5 * 60 * 1000, + }) + } catch { + // No follow-up possible — the original interaction was only + // acknowledged by showModal(), which leaves nothing to edit. + return null + } +} + export default new Command({ data: new SlashCommandBuilder() .setName('vaga') @@ -59,12 +96,6 @@ export default new Command({ .setDescription('Título da vaga') .setRequired(true), ) - .addStringOption((o) => - o - .setName('descricao') - .setDescription('Descrição / requisitos da vaga') - .setRequired(true), - ) .addStringOption((o) => o .setName('url') @@ -104,7 +135,6 @@ export default new Command({ } const titulo = chat.options.getString('titulo', true) - const descricao = chat.options.getString('descricao', true) const url = chat.options.getString('url', true) const modalidadeVal = chat.options.getString('modalidade') const senioridadeVal = chat.options.getString('senioridade') @@ -113,6 +143,10 @@ export default new Command({ ? MODALIDADE[modalidadeVal as keyof typeof MODALIDADE] : null + const modalSubmit = await collectDescricao(chat) + if (!modalSubmit) return + const descricao = modalSubmit.fields.getTextInputValue('descricao') + const prisma = getPrismaClient() const messages = await prisma.reactionRoleMessage.findMany({ where: { guildId: chat.guild.id }, @@ -152,7 +186,7 @@ export default new Command({ // Discord rejects message content over 2000 chars — fail early with a // clear message instead of a runtime API error at publish time. if (preview.length > 2000) { - await chat.reply({ + await modalSubmit.reply({ content: `⚠️ A vaga ficou muito longa (${preview.length}/2000 caracteres). Encurte a descrição.`, ephemeral: true, }) @@ -171,7 +205,7 @@ export default new Command({ ) const tagList = tags.map((t) => t.label).join(', ') || '(nenhum)' - const message = await chat.reply({ + const message = await modalSubmit.reply({ content: `**Prévia da vaga** (cargos marcados: ${tagList})\n\n${preview}`, components: [row], ephemeral: true, @@ -186,7 +220,7 @@ export default new Command({ time: 5 * 60 * 1000, }) } catch { - await chat.editReply({ + await modalSubmit.editReply({ content: '⏱️ Tempo esgotado — vaga não publicada.', components: [], }) From a5dbe9bb30775b3776f322b04c9203b6bca1696f Mon Sep 17 00:00:00 2001 From: Lucas Santana Date: Sat, 4 Jul 2026 10:30:51 -0300 Subject: [PATCH 2/2] fix(bot): scope the vaga descricao modal to its own invocation MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit CodeRabbit and cubic independently caught this: awaitModalSubmit only filtered on user.id, so a moderator opening a second /vaga before submitting the first one's modal could have the first invocation resolve with the second's descrição — mixing mismatched título/url/ modalidade with the wrong requirements text. customId now embeds chat.id (unique per interaction) and the filter checks it alongside user.id. Added a test that exercises the actual filter function (the mock doesn't call it, so the previous tests never verified this). --- .../functions/general/commands/vaga.spec.ts | 37 +++++++++++++++++++ .../src/functions/general/commands/vaga.ts | 14 +++++-- 2 files changed, 47 insertions(+), 4 deletions(-) diff --git a/packages/bot/src/functions/general/commands/vaga.spec.ts b/packages/bot/src/functions/general/commands/vaga.spec.ts index 8aefef6d2..c6dcfa86a 100644 --- a/packages/bot/src/functions/general/commands/vaga.spec.ts +++ b/packages/bot/src/functions/general/commands/vaga.spec.ts @@ -91,6 +91,7 @@ function makeInteraction( editReply: jest.fn(), } const interaction = { + id: 'interaction-1', user: { id: 'u1', tag: 'mod#1' }, guild: { id: 'g1', @@ -247,4 +248,40 @@ describe('/vaga command', () => { }) expect(send).not.toHaveBeenCalled() }) + + it('scopes the modal filter to this invocation, rejecting a stale/concurrent submission (regression for cross-invocation mixing)', async () => { + const { interaction } = makeInteraction(base, { + customId: 'vaga_cancel', + }) + await vaga.execute({ + interaction: interaction as never, + client: {} as never, + }) + const [{ filter }] = interaction.awaitModalSubmit.mock.calls[0] as [ + { + filter: (i: { + user: { id: string } + customId: string + }) => boolean + }, + ] + const thisCustomId = `vaga_descricao_${interaction.id}` + + // Same user, this invocation's modal — accepted. + expect(filter({ user: { id: 'u1' }, customId: thisCustomId })).toBe( + true, + ) + // Same user, a DIFFERENT (e.g. earlier, still-open) /vaga + // invocation's modal — must be rejected, not mixed in. + expect( + filter({ + user: { id: 'u1' }, + customId: 'vaga_descricao_some-other-interaction', + }), + ).toBe(false) + // Different user entirely — rejected. + expect(filter({ user: { id: 'u2' }, customId: thisCustomId })).toBe( + false, + ) + }) }) diff --git a/packages/bot/src/functions/general/commands/vaga.ts b/packages/bot/src/functions/general/commands/vaga.ts index f200fc53d..37d57cda3 100644 --- a/packages/bot/src/functions/general/commands/vaga.ts +++ b/packages/bot/src/functions/general/commands/vaga.ts @@ -53,9 +53,9 @@ function buildVagaMessage( // requirements list into one collapses it onto one line (browsers flatten //
  • newlines into spaces on copy). A modal's paragraph text input is the // only way to actually accept real line breaks here. -function buildDescricaoModal(): ModalBuilder { +function buildDescricaoModal(customId: string): ModalBuilder { return new ModalBuilder() - .setCustomId('vaga_descricao') + .setCustomId(customId) .setTitle('Descrição da vaga') .addComponents( new ActionRowBuilder().addComponents( @@ -70,10 +70,16 @@ function buildDescricaoModal(): ModalBuilder { } async function collectDescricao(chat: ChatInputCommandInteraction) { - await chat.showModal(buildDescricaoModal()) + // Scoped to this invocation's own interaction id — without it, a second + // /vaga run by the same user before the first one's modal is submitted + // could resolve THIS awaitModalSubmit with the OTHER invocation's + // descrição, mixing it with this call's título/url/modalidade. + const customId = `vaga_descricao_${chat.id}` + await chat.showModal(buildDescricaoModal(customId)) try { return await chat.awaitModalSubmit({ - filter: (i) => i.user.id === chat.user.id, + filter: (i) => + i.user.id === chat.user.id && i.customId === customId, time: 5 * 60 * 1000, }) } catch {