System podań w discord.js v14: kod albo bez kodu

Zespół Toscio · Zaktualizowano 22 października 2026 · 5 min czytania

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.add dla 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.

Twoje następne podanie jest dwie minuty stąd.

Dodaj Toscio, wpisz /toscio setup, wybierz szablon. I tyle.