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
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.
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.
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.
discord.py
python-dotenvDISCORD_TOKEN=paste-your-token-hereimport 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.
{
"name": "suggestions-bot",
"version": "1.0.0",
"main": "index.js",
"dependencies": {
"discord.js": "^14.16.0",
"dotenv": "^16.4.0"
}
}DISCORD_TOKEN=paste-your-token-hererequire("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.
Start the bot
In a terminal, in the folder:
pip install -r requirements.txtandpython app.py(ornpm installandnode index.js). When it says “Logged in as…”, you're set. The commands can take a few minutes to show up the first time.Pick the channel
Type
/suggestion-channeland choose #suggestions. If it's missing a permission there, the bot tells you which one.Send one and vote
With
/suggestwrite 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.Answer as the staff
/respondwith 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
- Discord Role Bot: Buttons So Everyone Picks Their Own Roles (Python and JavaScript)
Make your own Discord self-role bot in Python or JavaScript: a message with buttons to add and remove roles, no database and no reaction roles needed.
- Discord Verification Bot in Python: a Button and a Question Before Joining
Build your own Discord verification bot with discord.py: a “Verify me” button, an anti-bot question and a role that unlocks the server. Full code, free.
- How to Make a Discord Bot in Python (discord.py), Step by Step
Make your first Discord bot in Python with discord.py: create the application, get the token, add slash commands (/hello) and keep it online for free.