NapNodes

← Discord bot guides

Discord bots · updated 3 November 2026

Discord suggestions bot: /suggest, button voting and staff answers (Python and JavaScript)

“What if we did an event on Fridays?” gets lost in chat within ten minutes. With a suggestions channel, every idea gets its own card, the community votes with two buttons and the staff accepts it, rejects it or puts it under review with a reason everyone can see. This bot does it with three commands, in discord.py or discord.js, with no database and no privileged intents.

What the bot does

Anyone types /suggest with their idea and the bot posts it in the suggestions channel as a card with a number, author, votes and status. Below it, 👍 and 👎: one vote per person, they can change their mind, and pressing the same button again removes the vote. The card updates instantly.

Whoever manages the server picks the channel with /suggestion-channel and answers with /respond: “Accepted”, “Rejected” or “Under review”, with an optional reason shown on the card. Accepting or rejecting turns the buttons off and changes the card's colour; “Under review” lets voting go on.

Everything goes in suggestions.json, next to the bot: a restart loses neither the suggestions nor the votes, and the buttons on old cards keep working. So nobody floods the channel, each person can send one every 2 minutes, and a suggestion never pings anyone even if it contains an @.

Rather not set it up yourself? Discord bot hosting from €1.50 a month, no contract and a full refund within 72 hours. Or create a free Discord bot, no card needed.

Before the bot: what Discord already has

A forum channel works for suggestions without a bot: each idea is a post with its own conversation, and with tags (“Accepted”, “Rejected”) the staff keeps them sorted. What it lacks is a clear vote count and an official answer you can see at a glance.

Discord polls are for choosing between options you already have, not for collecting ideas. If you want votes per idea, numbered, with the staff's decision, that's what this bot does.

1. Create the bot and invite it

  1. Create the application and get the token

    At discord.com/developers/applications, “New Application”; in the “Bot” tab, “Reset Token” and copy the token. It's the bot's password: don't share it or push it to GitHub. If it's your first bot, the full walkthrough is in your first bot in Python or in JavaScript. The “Privileged Gateway Intents” stay off.

  2. Invite it with three permissions

    In “OAuth2” → “URL Generator”, tick “bot” and “applications.commands”, and in the permissions “View Channels”, “Send Messages” and “Embed Links” (19456 in total). The bot permissions calculator shows them and gives you the link.

  3. Set up the channel

    A text channel, for example #suggestions, where the bot can write. If you want suggestions to come only through the command, remove “Send Messages” from @everyone in that channel and give it to the bot's role: slash commands still work, because “Use Application Commands” allows them.

2. The bot in Python (discord.py)

Three files in one folder. The token goes in .env, without quotes. You need Python 3.10 or newer.

requirements.txt
discord.py
python-dotenv
.env
DISCORD_TOKEN=paste-your-token-here
app.py
import json
import os
import time
from pathlib import Path
from typing import Optional

import discord
from discord import app_commands
from dotenv import load_dotenv

load_dotenv()  # reads the token from the .env file

# Everything is saved next to the bot: a restart loses neither the suggestions nor the votes.
DATA = Path("suggestions.json")
COOLDOWN = 120  # seconds between two suggestions from the same person
STATUSES = {
    "accepted": ("Accepted", "✅ Accepted", discord.Color.green()),
    "rejected": ("Rejected", "❌ Rejected", discord.Color.red()),
    "review": ("Under review", "🔎 Under review", discord.Color.gold()),
}
OPEN = ("Open", "💡 Open for votes", discord.Color.blurple())
CLOSED = ("accepted", "rejected")  # no more voting on these
NOBODY = discord.AllowedMentions.none()  # a suggestion never pings anyone
last: dict[int, float] = {}  # when each person last suggested something


def load() -> dict:
    return json.loads(DATA.read_text("utf-8")) if DATA.exists() else {}


def save(data: dict) -> None:
    DATA.write_text(json.dumps(data, indent=2, ensure_ascii=False), "utf-8")


def card(number: str, s: dict) -> discord.Embed:
    _, title, color = STATUSES.get(s["status"], OPEN)
    e = discord.Embed(title=f"Suggestion #{number}", description=s["text"], color=color)
    e.add_field(name="From", value=f"<@{s['author']}>")
    e.add_field(name="Votes", value=f"👍 {len(s['yes'])} · 👎 {len(s['no'])}")
    e.add_field(name="Status", value=title)
    if s["reason"]:
        e.add_field(name="Staff answer", value=s["reason"], inline=False)
    return e


class Votes(discord.ui.View):
    """The two buttons. Fixed custom_id and timeout=None: they keep working after a restart."""

    def __init__(self, closed: bool = False):
        super().__init__(timeout=None)
        for button in self.children:
            button.disabled = closed

    async def vote(self, interaction: discord.Interaction, side: str):
        data = load()
        suggestions = data.get(str(interaction.guild_id), {}).get("suggestions", {})
        number, s = next(((n, s) for n, s in suggestions.items() if s["message"] == interaction.message.id), (None, None))
        if s is None or s["status"] in CLOSED:
            await interaction.response.send_message("This suggestion is no longer open for votes.", ephemeral=True)
            return
        who = interaction.user.id
        other = "no" if side == "yes" else "yes"
        if who in s[other]:
            s[other].remove(who)  # changed their mind: one vote per person
        if who in s[side]:
            s[side].remove(who)  # pressing the same button again removes the vote
        else:
            s[side].append(who)
        save(data)
        await interaction.response.edit_message(embed=card(number, s))

    @discord.ui.button(emoji="👍", style=discord.ButtonStyle.success, custom_id="suggestion:yes")
    async def yes(self, interaction: discord.Interaction, button: discord.ui.Button):
        await self.vote(interaction, "yes")

    @discord.ui.button(emoji="👎", style=discord.ButtonStyle.danger, custom_id="suggestion:no")
    async def no(self, interaction: discord.Interaction, button: discord.ui.Button):
        await self.vote(interaction, "no")


class Bot(discord.Client):
    def __init__(self):
        super().__init__(intents=discord.Intents.default())  # no privileged intents
        self.tree = app_commands.CommandTree(self)

    async def setup_hook(self):
        self.add_view(Votes())  # listens again to the buttons of suggestions already posted
        await self.tree.sync()  # publishes the commands


bot = Bot()


@bot.tree.command(name="suggestion-channel", description="Choose the channel where suggestions are posted")
@app_commands.guild_only()
@app_commands.default_permissions(manage_guild=True)  # by default, only whoever manages the server
@app_commands.describe(channel="The suggestions channel")
async def suggestion_channel(interaction: discord.Interaction, channel: discord.TextChannel):
    mine = channel.permissions_for(interaction.guild.me)
    if not (mine.view_channel and mine.send_messages and mine.embed_links):
        await interaction.response.send_message(
            f"In {channel.mention} I am missing “View Channel”, “Send Messages” or “Embed Links”.", ephemeral=True)
        return
    data = load()
    data.setdefault(str(interaction.guild_id), {"next": 1, "suggestions": {}})["channel"] = channel.id
    save(data)
    await interaction.response.send_message(f"Suggestions will go to {channel.mention}.", ephemeral=True)


@bot.tree.command(name="suggest", description="Send a suggestion for the server")
@app_commands.guild_only()
@app_commands.describe(idea="Your suggestion, in one message")
async def suggest(interaction: discord.Interaction, idea: app_commands.Range[str, 10, 1000]):
    before = last.get(interaction.user.id)
    if before is not None and time.monotonic() - before < COOLDOWN:
        left = int(COOLDOWN - (time.monotonic() - before)) + 1
        await interaction.response.send_message(f"Wait {left} s before sending another one.", ephemeral=True)
        return
    data = load()
    server = data.get(str(interaction.guild_id))
    channel = bot.get_channel(server["channel"]) if server and "channel" in server else None
    if channel is None:
        await interaction.response.send_message(
            "There is no suggestions channel yet: someone who manages the server has to use /suggestion-channel.", ephemeral=True)
        return
    # The number is reserved before posting: two suggestions at once never share it.
    number = str(server["next"])
    server["next"] += 1
    save(data)
    s = {"author": interaction.user.id, "text": idea, "yes": [], "no": [], "status": "open", "reason": None,
         "channel": channel.id}
    try:
        message = await channel.send(embed=card(number, s), view=Votes(), allowed_mentions=NOBODY)
    except discord.HTTPException as error:
        print(f"Could not post suggestion {number}: {error!r}")
        await interaction.response.send_message(
            "I could not post it: check my permissions in the suggestions channel.", ephemeral=True)
        return
    s["message"] = message.id
    fresh = load()  # read again: someone may have voted on another one while this was being posted
    fresh[str(interaction.guild_id)]["suggestions"][number] = s
    save(fresh)
    last[interaction.user.id] = time.monotonic()
    await interaction.response.send_message(f"Thanks! Your suggestion #{number} is in {channel.mention}.", ephemeral=True)


@bot.tree.command(name="respond", description="Accept, reject or put a suggestion under review")
@app_commands.guild_only()
@app_commands.default_permissions(manage_guild=True)
@app_commands.describe(number="The suggestion number", status="Your decision", reason="Why (shown on the card)")
@app_commands.choices(status=[app_commands.Choice(name=name, value=key) for key, (name, _, _) in STATUSES.items()])
async def respond(interaction: discord.Interaction, number: app_commands.Range[int, 1], status: app_commands.Choice[str],
                  reason: Optional[app_commands.Range[str, 1, 1000]] = None):
    data = load()
    s = data.get(str(interaction.guild_id), {}).get("suggestions", {}).get(str(number))
    if s is None:
        await interaction.response.send_message(f"There is no suggestion #{number}.", ephemeral=True)
        return
    s["status"] = status.value
    s["reason"] = reason
    save(data)
    try:
        channel = bot.get_channel(s["channel"])
        await channel.get_partial_message(s["message"]).edit(embed=card(str(number), s),
                                                            view=Votes(closed=status.value in CLOSED))
    except (AttributeError, discord.HTTPException) as error:
        print(f"Could not update card {number}: {error!r}")
        await interaction.response.send_message("Saved, but I could not update the card: was it deleted?", ephemeral=True)
        return
    await interaction.response.send_message(f"Suggestion #{number}: {status.name.lower()}.", ephemeral=True)


@bot.event
async def on_ready():
    print(f"Logged in as {bot.user}")


bot.run(os.environ["DISCORD_TOKEN"])

The same bot in JavaScript (discord.js)

package.json, .env and index.js. You need Node.js 20 or newer.

package.json
{
  "name": "suggestions-bot",
  "version": "1.0.0",
  "main": "index.js",
  "dependencies": {
    "discord.js": "^14.16.0",
    "dotenv": "^16.4.0"
  }
}
.env
DISCORD_TOKEN=paste-your-token-here
index.js
require("dotenv").config(); // reads the token from the .env file
const fs = require("node:fs");
const path = require("node:path");
const { ActionRowBuilder, ButtonBuilder, ButtonStyle, ChannelType, Client, EmbedBuilder, Events, GatewayIntentBits, InteractionContextType, MessageFlags, PermissionFlagsBits, SlashCommandBuilder } = require("discord.js");

// Everything is saved next to the bot: a restart loses neither the suggestions nor the votes.
const DATA = path.join(__dirname, "suggestions.json");
const COOLDOWN = 120; // seconds between two suggestions from the same person
const STATUSES = {
  accepted: { name: "Accepted", title: "✅ Accepted", color: 0x57f287 },
  rejected: { name: "Rejected", title: "❌ Rejected", color: 0xed4245 },
  review: { name: "Under review", title: "🔎 Under review", color: 0xfee75c },
};
const OPEN = { name: "Open", title: "💡 Open for votes", color: 0x5865f2 };
const CLOSED = ["accepted", "rejected"]; // no more voting on these
const NOBODY = { parse: [] }; // a suggestion never pings anyone
const last = new Map(); // when each person last suggested something
const privately = { flags: MessageFlags.Ephemeral };

const load = () => (fs.existsSync(DATA) ? JSON.parse(fs.readFileSync(DATA, "utf8")) : {});
const save = (data) => fs.writeFileSync(DATA, JSON.stringify(data, null, 2));

function card(number, s) {
  const { title, color } = STATUSES[s.status] ?? OPEN;
  const e = new EmbedBuilder().setTitle(`Suggestion #${number}`).setDescription(s.text).setColor(color).addFields(
    { name: "From", value: `<@${s.author}>`, inline: true },
    { name: "Votes", value: `👍 ${s.yes.length} · 👎 ${s.no.length}`, inline: true },
    { name: "Status", value: title, inline: true },
  );
  if (s.reason) e.addFields({ name: "Staff answer", value: s.reason });
  return e;
}

// The two buttons. The customId is fixed: they keep working after the bot restarts.
const votes = (closed = false) => new ActionRowBuilder().addComponents(
  new ButtonBuilder().setCustomId("suggestion:yes").setEmoji("👍").setStyle(ButtonStyle.Success).setDisabled(closed),
  new ButtonBuilder().setCustomId("suggestion:no").setEmoji("👎").setStyle(ButtonStyle.Danger).setDisabled(closed),
);

const commands = [
  new SlashCommandBuilder().setName("suggestion-channel").setDescription("Choose the channel where suggestions are posted")
    .setContexts(InteractionContextType.Guild)
    .setDefaultMemberPermissions(PermissionFlagsBits.ManageGuild) // by default, only whoever manages the server
    .addChannelOption((o) => o.setName("channel").setDescription("The suggestions channel").setRequired(true).addChannelTypes(ChannelType.GuildText)),
  new SlashCommandBuilder().setName("suggest").setDescription("Send a suggestion for the server")
    .setContexts(InteractionContextType.Guild)
    .addStringOption((o) => o.setName("idea").setDescription("Your suggestion, in one message").setRequired(true).setMinLength(10).setMaxLength(1000)),
  new SlashCommandBuilder().setName("respond").setDescription("Accept, reject or put a suggestion under review")
    .setContexts(InteractionContextType.Guild)
    .setDefaultMemberPermissions(PermissionFlagsBits.ManageGuild)
    .addIntegerOption((o) => o.setName("number").setDescription("The suggestion number").setRequired(true).setMinValue(1))
    .addStringOption((o) => o.setName("status").setDescription("Your decision").setRequired(true)
      .addChoices(...Object.entries(STATUSES).map(([value, e]) => ({ name: e.name, value }))))
    .addStringOption((o) => o.setName("reason").setDescription("Why (shown on the card)").setMaxLength(1000)),
];

async function chooseChannel(i) {
  const channel = i.options.getChannel("channel");
  const mine = channel.permissionsFor(i.guild.members.me);
  if (!mine.has([PermissionFlagsBits.ViewChannel, PermissionFlagsBits.SendMessages, PermissionFlagsBits.EmbedLinks])) {
    await i.reply({ content: `In ${channel} I am missing “View Channel”, “Send Messages” or “Embed Links”.`, ...privately });
    return;
  }
  const data = load();
  (data[i.guildId] ??= { next: 1, suggestions: {} }).channel = channel.id;
  save(data);
  await i.reply({ content: `Suggestions will go to ${channel}.`, ...privately });
}

async function suggest(i) {
  const before = last.get(i.user.id);
  if (before !== undefined && Date.now() - before < COOLDOWN * 1000) {
    const left = Math.ceil((COOLDOWN * 1000 - (Date.now() - before)) / 1000);
    await i.reply({ content: `Wait ${left} s before sending another one.`, ...privately });
    return;
  }
  const data = load();
  const server = data[i.guildId];
  const channel = server?.channel ? client.channels.cache.get(server.channel) : undefined;
  if (!channel) {
    await i.reply({ content: "There is no suggestions channel yet: someone who manages the server has to use /suggestion-channel.", ...privately });
    return;
  }
  // The number is reserved before posting: two suggestions at once never share it.
  const number = String(server.next);
  server.next += 1;
  save(data);
  const s = { author: i.user.id, text: i.options.getString("idea"), yes: [], no: [], status: "open", reason: null, channel: channel.id };
  let message;
  try {
    message = await channel.send({ embeds: [card(number, s)], components: [votes()], allowedMentions: NOBODY });
  } catch (error) {
    console.error(`Could not post suggestion ${number}:`, error);
    await i.reply({ content: "I could not post it: check my permissions in the suggestions channel.", ...privately });
    return;
  }
  s.message = message.id;
  const fresh = load(); // read again: someone may have voted on another one while this was being posted
  fresh[i.guildId].suggestions[number] = s;
  save(fresh);
  last.set(i.user.id, Date.now());
  await i.reply({ content: `Thanks! Your suggestion #${number} is in ${channel}.`, ...privately });
}

async function vote(i) {
  const side = i.customId === "suggestion:yes" ? "yes" : "no";
  const other = side === "yes" ? "no" : "yes";
  const data = load();
  const entry = Object.entries(data[i.guildId]?.suggestions ?? {}).find(([, s]) => s.message === i.message.id);
  if (!entry || CLOSED.includes(entry[1].status)) {
    await i.reply({ content: "This suggestion is no longer open for votes.", ...privately });
    return;
  }
  const [number, s] = entry;
  s[other] = s[other].filter((id) => id !== i.user.id); // changed their mind: one vote per person
  if (s[side].includes(i.user.id)) s[side] = s[side].filter((id) => id !== i.user.id); // pressing the same button again removes the vote
  else s[side].push(i.user.id);
  save(data);
  await i.update({ embeds: [card(number, s)] });
}

async function respond(i) {
  const number = String(i.options.getInteger("number"));
  const status = i.options.getString("status");
  const data = load();
  const s = data[i.guildId]?.suggestions?.[number];
  if (!s) {
    await i.reply({ content: `There is no suggestion #${number}.`, ...privately });
    return;
  }
  s.status = status;
  s.reason = i.options.getString("reason");
  save(data);
  try {
    const channel = client.channels.cache.get(s.channel);
    await channel.messages.edit(s.message, { embeds: [card(number, s)], components: [votes(CLOSED.includes(status))] });
  } catch (error) {
    console.error(`Could not update card ${number}:`, error);
    await i.reply({ content: "Saved, but I could not update the card: was it deleted?", ...privately });
    return;
  }
  await i.reply({ content: `Suggestion #${number}: ${STATUSES[status].name.toLowerCase()}.`, ...privately });
}

const client = new Client({ intents: [GatewayIntentBits.Guilds] }); // no privileged intents

client.on(Events.InteractionCreate, async (i) => {
  try {
    if (i.isButton() && i.customId.startsWith("suggestion:")) await vote(i);
    else if (i.isChatInputCommand() && i.commandName === "suggest") await suggest(i);
    else if (i.isChatInputCommand() && i.commandName === "suggestion-channel") await chooseChannel(i);
    else if (i.isChatInputCommand() && i.commandName === "respond") await respond(i);
  } catch (error) {
    console.error(error);
  }
});

client.once(Events.ClientReady, async (c) => {
  await c.application.commands.set(commands.map((x) => x.toJSON())); // publishes the commands
  console.log(`Logged in as ${c.user.tag}`);
});

client.login(process.env.DISCORD_TOKEN);

3. Try it

Who sees /suggestion-channel and /respond can be changed without touching the code in “Server Settings” → “Integrations” → your bot: for example, so moderators can answer too.

  1. Start the bot

    In a terminal, in the folder: pip install -r requirements.txt and python app.py (or npm install and node index.js). When it says “Logged in as…”, you're set. The commands can take a few minutes to show up the first time.

  2. Pick the channel

    Type /suggestion-channel and choose #suggestions. If it's missing a permission there, the bot tells you which one.

  3. Send one and vote

    With /suggest write an idea of at least 10 characters. Card #1 appears with the two buttons: press 👍 and then 👎, and watch your vote move. Ask another account to vote too.

  4. Answer as the staff

    /respond with number 1, the status “Accepted” and a reason like “Coming next month”. The card turns green, shows the answer and the buttons switch off.

Why votes aren't lost on restart

Discord buttons only carry an identifier: the bot has to recognise it when someone presses, also on cards from weeks ago. That's why both buttons have a fixed custom_id (suggestion:yes and suggestion:no) and the card is found by the message that was pressed.

In discord.py, the view is created with timeout=None and registered again on startup with self.add_view(Votes()): without that line, after a restart the old buttons answer “This interaction failed”. In discord.js there's nothing to register: every press reaches InteractionCreate and the bot checks the customId.

Votes are stored as lists of who voted, not as a number: that way nobody votes twice, and changing your mind moves the vote instead of adding another one.

If something doesn't work

Pressing a button says “This interaction failed”: the bot is off, or in Python self.add_view(Votes()) is missing from setup_hook. Check the console.

/suggest answers that there's no channel: someone with “Manage Server” has to use /suggestion-channel first. If it says it couldn't post it, the bot has lost “View Channel”, “Send Messages” or “Embed Links” in that channel; the console shows the error (50013 is “Missing Permissions”).

/respond says it couldn't update the card: it was deleted, or the bot can no longer see that channel. The decision is saved anyway.

The commands don't show up: what to check when slash commands don't appear. If the bot is online and doesn't answer, my bot isn't responding.

Ideas to extend it

A thread per suggestion to discuss it without flooding the channel: after posting the card, message.create_thread(...) in discord.py or message.startThread(...) in discord.js (the bot also needs “Create Public Threads”).

Let the author know the decision: when answering, send them a direct message with the status and the reason (they may have DMs closed: catch the error).

More buttons, like “See my suggestions”, or a menu to pick the category: buttons and menus in a Discord bot. If the JSON gets too small for you, move it to SQLite with a database for your bot.

4. Keep it online all the time

If the bot is off, /suggest doesn't answer and the buttons fail: it has to be on all the time, not just when your computer is. Free and paid options, compared, in where to host your Discord bot.

NapNodes has a free 256 MB bot that doesn't turn off when idle; you renew it every 7 days with “Start” (its catches, in this guide). Create it, pick Node.js or Python in “Settings” → “Language”, upload your files in “Files” and click “Start”: the libraries in requirements.txt or package.json install themselves on startup, and suggestions.json stays in the bot's folder between restarts. So you don't have to renew it, the Mini plan (512 MB) stays online all the time (bot prices).

Frequently asked questions

How do I make a suggestions channel with voting on Discord?

With a bot that posts each suggestion as a card with two voting buttons and stores who voted. The full code in Python and JavaScript, with commands to suggest and to answer, is in this guide.

Why do my bot's buttons stop working after a restart?

Because the bot no longer recognises those buttons. Each button needs a fixed identifier and, in discord.py, a view with no timeout that is registered again on startup, as the bot in this guide does.

What permissions does a suggestions bot need?

View channels, send messages and embed links (the permissions number is 19456). The buttons don't need any extra permission.

Can someone vote twice?

No: the bot stores who voted for what. Pressing the other button changes the vote and pressing the same one again removes it.

Where do I keep the suggestions bot online all the time?

On a server. NapNodes has a free 256 MB bot that you renew with one click every week, or the Mini plan, always online.

Shall we set it up for you?

Discord bot hosting from €1.50 a month, never oversold: your RAM is yours alone. And if you'd rather start without paying, there's a free Discord bot, no card needed.

Free tool: Discord bot permissions calculator. Tick your bot's permissions and get the number and the invite link; or paste a number and see which permissions it grants.

Keep reading

All Discord bot guides, sorted →