System podań w discord.js v14: kod albo bez kodu
Jak zrobić system podań w discord.js v14? Potrzebujesz czterech elementów: panelu z przyciskiem Apply, modala (wyskakującego formularza Discorda, maksymalnie 5 pól na okno, więc dłuższe formularze muszą łączyć kilka okien), karty wysyłanej na kanał administracji z przyciskami Accept i Decline, a po przyjęciu member.roles.add i DM-a do kandydata. Ten poradnik daje ci krótki, działający szkielet, listę tego, co i tak musisz wokół niego zbudować, i drogę bez kodu, jeśli nie chcesz utrzymywać bota.
Co musisz napisać
| Element | API discord.js v14 |
|---|---|
| Panel z przyciskiem Apply | ButtonBuilder, ActionRowBuilder, channel.send |
| Formularz | ModalBuilder, TextInputBuilder, interaction.showModal |
| Odczyt odpowiedzi | interaction.isModalSubmit(), interaction.fields.getTextInputValue |
| Karta dla administracji z przyciskami | EmbedBuilder, ButtonBuilder z ID kandydata w customId |
| Decyzja | member.roles.add, member.send, interaction.update |
Minimalny szkielet w discord.js v14
Jeden plik, npm install discord.js, Node 18 lub nowszy. Uzupełnij trzy ID i ustaw DISCORD_TOKEN. Bot potrzebuje tylko intentu Guilds. Teksty w kodzie zostawiliśmy po angielsku: zamień je na polskie u siebie.
// discord.js v14: npm install discord.js
const {
Client, GatewayIntentBits, Events, ActionRowBuilder, ButtonBuilder, ButtonStyle,
ModalBuilder, TextInputBuilder, TextInputStyle, EmbedBuilder, MessageFlags, PermissionFlagsBits,
} = require('discord.js');
const APPLY_CHANNEL_ID = '000000000000000000'; // kanał z przyciskiem Apply
const STAFF_CHANNEL_ID = '000000000000000000'; // prywatny kanał administracji
const ROLE_ID = '000000000000000000'; // rola nadawana po przyjęciu
const client = new Client({ intents: [GatewayIntentBits.Guilds] });
// Publikuje panel przy starcie: uruchom raz, potem usuń
client.once(Events.ClientReady, async (c) => {
const channel = await c.channels.fetch(APPLY_CHANNEL_ID);
const button = new ButtonBuilder().setCustomId('apply').setLabel('Apply').setStyle(ButtonStyle.Primary);
await channel.send({ content: 'Want to join the staff? Click below.', components: [new ActionRowBuilder().addComponents(button)] });
});
client.on(Events.InteractionCreate, async (interaction) => {
// 1. Przycisk Apply otwiera formularz (maks. 5 pól na modal)
if (interaction.isButton() && interaction.customId === 'apply') {
const age = new TextInputBuilder().setCustomId('age').setLabel('How old are you?')
.setStyle(TextInputStyle.Short).setMaxLength(3).setRequired(true);
const why = new TextInputBuilder().setCustomId('why').setLabel('Why do you want to join the staff?')
.setStyle(TextInputStyle.Paragraph).setMaxLength(1000).setRequired(true);
const modal = new ModalBuilder().setCustomId('apply-form').setTitle('Staff application')
.addComponents(new ActionRowBuilder().addComponents(age), new ActionRowBuilder().addComponents(why));
await interaction.showModal(modal);
return;
}
// 2. Formularz wysłany: publikujemy kartę na kanale administracji
if (interaction.isModalSubmit() && interaction.customId === 'apply-form') {
const embed = new EmbedBuilder().setTitle(`Application from ${interaction.user.username}`).addFields(
{ name: 'Age', value: interaction.fields.getTextInputValue('age') },
{ name: 'Why', value: interaction.fields.getTextInputValue('why') },
);
const buttons = new ActionRowBuilder().addComponents(
new ButtonBuilder().setCustomId(`accept:${interaction.user.id}`).setLabel('Accept').setStyle(ButtonStyle.Success),
new ButtonBuilder().setCustomId(`decline:${interaction.user.id}`).setLabel('Decline').setStyle(ButtonStyle.Danger),
);
const staff = await interaction.client.channels.fetch(STAFF_CHANNEL_ID);
await staff.send({ embeds: [embed], components: [buttons] });
await interaction.reply({ content: 'Application sent!', flags: MessageFlags.Ephemeral });
return;
}
// 3. Accept albo Decline na karcie dla administracji
if (interaction.isButton() && /^(accept|decline):\d+$/.test(interaction.customId)) {
if (!interaction.memberPermissions?.has(PermissionFlagsBits.ManageGuild)) {
await interaction.reply({ content: 'Staff only.', flags: MessageFlags.Ephemeral });
return;
}
const [action, userId] = interaction.customId.split(':');
const member = await interaction.guild.members.fetch(userId).catch(() => null);
if (!member) {
await interaction.reply({ content: 'This member left the server.', flags: MessageFlags.Ephemeral });
return;
}
if (action === 'accept') {
try {
await member.roles.add(ROLE_ID); // rola bota musi być nad ROLE_ID
} catch {
await interaction.reply({ content: 'Could not give the role: check the role order.', flags: MessageFlags.Ephemeral });
return;
}
}
// Kandydat może mieć zamknięte DM: ignorujemy błąd
await member.send(action === 'accept' ? 'Your application was accepted!' : 'Your application was declined.').catch(() => {});
await interaction.update({ content: `${action === 'accept' ? '✅ Accepted' : '❌ Declined'} by ${interaction.user}`, components: [] });
}
});
client.login(process.env.DISCORD_TOKEN);
ID kandydata jest zapisane w customId przycisku, więc decyzja działa nawet po restarcie. Cała reszta żyje tylko w wiadomości dla administracji.
Co musisz zbudować sam
Szkielet działa, ale prawdziwy system podań potrzebuje więcej:
- Przechowywanie. Trzymaj podania w bazie danych, a nie tylko w wiadomościach na Discordzie, żeby je przeszukiwać, eksportować i zachować po usunięciu wiadomości.
- Jedno oczekujące podanie na członka, żeby nikt nie wysłał tego samego formularza dziesięć razy.
- Czas oczekiwania po odrzuceniu, zapisywany na członka i na formularz.
- Więcej niż 5 pytań. Wysłanie modala nie może otworzyć kolejnego modala: odpowiedz przyciskiem Dalej, który otwiera następne okno, i zapisuj odpowiedzi między krokami.
- Uprawnienia administracji. Tutaj decydować może tylko „Zarządzanie serwerem”; będziesz chciał ról administracji na każdy formularz.
- Kolejność ról. Discord odrzuca
roles.adddla roli wyższej niż rola bota: wykryj to i powiedz administracji, co poprawić. - Zamknięte DM. Daj znać administracji, gdy DM nie dotarł, żeby uprzedziła kandydata inaczej.
- Hosting. Proces działający 24/7, który wstaje po awarii i jest aktualizowany, gdy zmienia się discord.js.
Co Toscio robi bez kodu
Jeśli nie chcesz tego wszystkiego utrzymywać, nasz poradnik bez kodu pokazuje ten sam proces w Toscio: /toscio setup, szablon i panel działa. Wszystko z listy wyżej jest już załatwione: natywne formularze Discorda łączone po pięć pytań, jedno oczekujące podanie na członka, czas oczekiwania, role administracji, sprawdzanie kolejności ról i zamknięte DM oznaczane na karcie.
Do tego dochodzą notatki administracji i historia kandydata na każdej karcie. Darmowy plan obejmuje quiz z oceną, etap przed przyjęciem (rozmowa, okres próbny) i dostęp administracji do dashboardu, z 2 formularzami po 10 pytań.
Zostaw swój skrypt, podepnij webhooka (Premium)
Masz już skrypt, arkusz albo CAD/MDT, który potrzebuje podań? Zostaw go i pozwól Toscio wysyłać mu zdarzenia. W edytorze formularza otwórz „Webhook (integracje)” w ustawieniach zaawansowanych i wpisz adres https. Toscio wysyła:
| Zdarzenie | Kiedy |
|---|---|
application.submitted |
podanie zostało otrzymane |
application.staged |
przechodzi do etapu przed przyjęciem |
application.accepted |
zostało przyjęte |
application.refused |
zostało odrzucone |
ping |
klikasz „Wyślij test” (application to null) |
Treść to JSON:
{
"event": "application.accepted",
"sentAt": "2026-10-22T18:04:11.000Z",
"guildId": "123456789012345678",
"form": { "id": 42, "name": "Staff recruitment" },
"application": {
"id": 1337,
"status": "accepted",
"userId": "234567890123456789",
"username": "alex",
"submittedAt": "2026-10-21T09:12:00.000Z",
"decidedAt": "2026-10-22T18:04:10.000Z",
"reviewerId": "345678901234567890",
"reason": null,
"stage": null,
"quiz": { "score": 3, "total": 4, "passed": true, "passPercent": 75 },
"web": false,
"answers": [{ "question": "How old are you?", "answer": "19" }]
}
}
Każde żądanie ma dwa nagłówki: X-Toscio-Timestamp (czas Unix, w sekundach) i X-Toscio-Signature, czyli sha256= i szesnastkowy HMAC-SHA256 z ${timestamp}.${surowa treść}, liczony „Sekretem podpisu” formularza. Sprawdź go, zanim zaufasz treści:
// npm install express
const express = require('express');
const crypto = require('node:crypto');
const SECRET = process.env.TOSCIO_SIGNING_SECRET; // „Sekret podpisu” z edytora formularza
const app = express();
// Surowa treść: podpis jest liczony na dokładnie odebranych bajtach
app.post('/toscio-webhook', express.raw({ type: 'application/json' }), (req, res) => {
const timestamp = req.get('X-Toscio-Timestamp') ?? '';
const signature = Buffer.from(req.get('X-Toscio-Signature') ?? '');
const expected = Buffer.from('sha256=' + crypto.createHmac('sha256', SECRET).update(`${timestamp}.`).update(req.body).digest('hex'));
const valid = signature.length === expected.length && crypto.timingSafeEqual(signature, expected);
const recent = Math.abs(Date.now() / 1000 - Number(timestamp)) < 300; // odrzuca żądania starsze niż 5 minut
if (!valid || !recent) return res.sendStatus(401);
const payload = JSON.parse(req.body.toString('utf8'));
if (payload.event === 'application.accepted') {
console.log(`${payload.application.username} accepted on ${payload.form.name}`);
}
res.sendStatus(204);
});
app.listen(3000); // za reverse proxy, które serwuje https na porcie 443
Limity, które warto znać:
- Tylko publiczne adresy https: nazwa domeny na porcie 443, bez adresu IP i bez nazwy lokalnej.
- Przekierowania nie są wykonywane, a Toscio czeka na odpowiedź maksymalnie 5 sekund.
- Bez ponawiania: nieudane żądanie nie jest wysyłane ponownie.
- Sekret jest ukryty przed osobami z administracji bez „Zarządzania serwerem”.
Webhooki są częścią Premium (4,99 €/mies. albo 49,90 €/rok za serwer), tak jak głosowanie administracji, formularz na stronie i odwołania od bana.
Pytania
Jak zrobić system podań w discord.js v14?
Wyślij wiadomość z przyciskiem, po kliknięciu otwórz modal (ModalBuilder z polami TextInputBuilder), odczytaj odpowiedzi po wysłaniu modala, wyślij je na kanał administracji z przyciskami Accept i Decline, a potem nadaj rolę przez member.roles.add i napisz DM do kandydata. Skrypt na tej stronie robi dokładnie to w około 70 linijkach.
Czy modal na Discordzie może mieć więcej niż 5 pytań?
Nie. Modal mieści maksymalnie 5 pól. Przy dłuższych formularzach łączysz kilka modali, a ponieważ wysłanie modala nie może od razu otworzyć kolejnego, odpowiadasz przyciskiem Dalej, który otwiera następny, i zapisujesz odpowiedzi pomiędzy krokami.
Jak sprawdzić podpis webhooka Toscio?
Policz HMAC-SHA256 z nagłówka X-Toscio-Timestamp, kropki i surowej treści żądania, z sekretem podpisu twojego formularza, w zapisie szesnastkowym. Dodaj na początku sha256= i porównaj z X-Toscio-Signature porównaniem w stałym czasie. Odrzucaj też żądania ze zbyt starym znacznikiem czasu.
Czy Toscio ponawia nieudane webhooki?
Nie. Każde zdarzenie jest wysyłane raz, z limitem 5 sekund. Jeśli twój adres nie działa albo odpowiada za wolno, to zdarzenie przepada; samo podanie jest przetwarzane normalnie. Żeby nadrobić, użyj dashboardu albo eksportu CSV.
Czy jest bot do podań na Discordzie open source?
Tak, na GitHubie znajdziesz kilka, które sam hostujesz i utrzymujesz. Toscio jest za to botem hostowanym: nic nie wdrażasz, a jeśli i tak chcesz mieć w procesie własny kod, jego webhooki wysyłają każde zdarzenie podania do twojego skryptu.


