Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
105 changes: 102 additions & 3 deletions packages/bot/src/functions/general/commands/vaga.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -78,8 +78,20 @@ 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 = {
id: 'interaction-1',
user: { id: 'u1', tag: 'mod#1' },
guild: {
id: 'g1',
Expand All @@ -98,10 +110,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', () => {
Expand Down Expand Up @@ -185,4 +206,82 @@ 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()
})

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,
)
})
})
60 changes: 50 additions & 10 deletions packages/bot/src/functions/general/commands/vaga.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,9 @@
ButtonStyle,
ComponentType,
ChannelType,
ModalBuilder,
TextInputBuilder,
TextInputStyle,
type ChatInputCommandInteraction,
type GuildBasedChannel,
} from 'discord.js'
Expand Down Expand Up @@ -46,6 +49,46 @@
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
// <li> newlines into spaces on copy). A modal's paragraph text input is the
// only way to actually accept real line breaks here.
function buildDescricaoModal(customId: string): ModalBuilder {
return new ModalBuilder()
.setCustomId(customId)
.setTitle('Descrição da vaga')
.addComponents(
new ActionRowBuilder<TextInputBuilder>().addComponents(
new TextInputBuilder()
.setCustomId('descricao')
.setLabel('Descrição / requisitos')
.setStyle(TextInputStyle.Paragraph)
.setRequired(true)
.setMaxLength(1800),
),
)
}

async function collectDescricao(chat: ChatInputCommandInteraction) {
// 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 && i.customId === customId,
time: 5 * 60 * 1000,
})
} catch {
// No follow-up possible — the original interaction was only
// acknowledged by showModal(), which leaves nothing to edit.
return null
}
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.

export default new Command({
data: new SlashCommandBuilder()
.setName('vaga')
Expand All @@ -59,12 +102,6 @@
.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')
Expand Down Expand Up @@ -93,7 +130,7 @@
),
),
category: 'general',
execute: async ({ interaction }) => {

Check warning on line 133 in packages/bot/src/functions/general/commands/vaga.ts

View workflow job for this annotation

GitHub Actions / Checks

Async method 'execute' has a complexity of 18. Maximum allowed is 15
const chat = interaction as ChatInputCommandInteraction
if (!chat.guild) {
await chat.reply({
Expand All @@ -104,7 +141,6 @@
}

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')
Expand All @@ -113,6 +149,10 @@
? 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 },
Expand Down Expand Up @@ -152,7 +192,7 @@
// 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,
})
Expand All @@ -171,7 +211,7 @@
)

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,
Expand All @@ -186,7 +226,7 @@
time: 5 * 60 * 1000,
})
} catch {
await chat.editReply({
await modalSubmit.editReply({
content: '⏱️ Tempo esgotado — vaga não publicada.',
components: [],
})
Expand Down
Loading