分类: Discord 机器人开发

Discord 机器人开发#30打造投票·问卷(Poll)机器人!原生投票 + 按钮实时统计(Python 示例)🗳️

本文译自韩语原文。

目录10 项

大家好!我是 Mojangcam 😎

上一篇我们做了货币·商店(Economy)系统,完成了一个能赚金币、买道具的机器人。服务器里算是多了一样"好玩的东西"!

这一篇我们来做服务器运营中真正常用的功能——投票·问卷(Poll)机器人。两种方式都会讲到。

  1. 原生投票 — 由机器人弹出 Discord 自带的投票 UI(最简单!)
  2. 按钮自定义投票 — 用按钮自己做,实时统计·防止重复投票·截止投票全都随心控制

定服务器活动日期、决定下一期做什么内容、就规则修改征求赞成与反对……如果你一直在给消息加表情再手动数票,今天这篇就能帮你彻底解决!🙌

![](./img-01.png)

📌 准备工作

  • Python 3.8+(建议 3.10 及以上)
  • discord.py 2.x — 原生 Poll 需要 2.5 及以上
  • python-dotenv — 为了不把机器人令牌直接写进代码,这个是必需的!
终端
pip install -U "discord.py>=2.5" python-dotenv

可以这样确认已安装的版本。

终端
python -c "import discord; print(discord.__version__)"

另外,机器人令牌千万不要硬编码在代码里。请在项目文件夹里创建一个 .env 文件。

文本
token=여기에_봇_토큰_붙여넣기

💡 提示:一定要在 .gitignore 里加上一行 .env!令牌一旦被传到 GitHub,Discord 会自动让这个令牌失效。(这倒也算不幸中的万幸 😅)

🧩 设置 Intents(意图)

要接收原生投票的投票事件,需要 polls intent。如果只用按钮方式,不开也没关系。

Python
intents = discord.Intents.default()
intents.message_content = True
intents.polls = True  # 要接收投票事件(on_poll_vote_add 等)时需要

📌 第 1 步 - 弹出原生投票(最简单的路)

从 discord.py 2.5 开始新增了 discord.Poll 类,机器人可以直接弹出 Discord 应用自带的投票 UI。

Python
@bot.tree.command(name="투표", description="弹出 Discord 自带的投票")
async def native_poll(interaction: discord.Interaction, 질문: str, 항목: str):
    # 选项用逗号分隔接收:"炸鸡,披萨,猪蹄"
    choices = [c.strip() for c in 항목.split(",") if c.strip()][:10]

    if len(choices) < 2:
        await interaction.response.send_message("请至少输入 2 个选项!", ephemeral=True)
        return

    poll = discord.Poll(question=질문, duration=datetime.timedelta(hours=24))
    for c in choices:
        poll.add_answer(text=c)

    await interaction.response.send_message(poll=poll)

下面逐一拆解 💡

  • discord.Poll(question, duration):创建投票对象。question 最多 300 个字符,duration 用 datetime.timedelta 指定,最长可以到 32 天(768 小时)。
  • poll.add_answer(text=..., emoji=...):添加选项。最多只能 10 个。还可以用 emoji 参数加上表情。
  • send_message(poll=poll):把它用 poll= 传给消息就完事了!统计、截止、防重复全由 Discord 自动处理。
  • 另外,机器人需要有创建投票(Send Polls)权限。

如果想在有人投票或撤回投票的那一刻做点什么,用事件就行。

Python
@bot.event
async def on_poll_vote_add(user, answer):
    print(f"{user} 投给了 '{answer.text}'")

💬 这个事件只有在该消息还在机器人缓存里时才会触发。想连旧消息也捕获的话,请用 on_raw_poll_vote_add。

原生投票确实很方便,但也有局限:最多 10 个选项、不能按身份组限制、没法把结果漂亮地画进自己的嵌入消息、投票进行中无法加工统计数据。所以我们进入第 2 步!


📌 第 2 步 - 按钮自定义投票(实时统计)

结构是一个按钮对应一个选项,再把谁按了什么存进字典里。

Python
class PollView(discord.ui.View):
    def __init__(self, question: str, choices: list[str], author_id: int, timeout: float = 3600):
        super().__init__(timeout=timeout)
        self.question = question
        self.choices = choices
        self.author_id = author_id
        self.votes: dict[int, int] = {}  # {用户ID: 选项 index}

        for idx, label in enumerate(choices):
            self.add_item(VoteButton(label=label, index=idx))

下面逐一拆解 💡

  • self.votes:这是核心。键是用户 ID,所以同一个人再按一次时,值会被覆盖。也就是说,从结构上就杜绝了重复投票。改主意了?按另一个按钮就行!
  • timeout:过了这个时间(秒),按钮会自动停止工作。3600 = 1 小时。
  • author_id:保存下来,是为了只让发起投票的人能按"提前截止"。

按钮本体长这样。

Python
class VoteButton(discord.ui.Button):
    def __init__(self, label: str, index: int):
        super().__init__(label=label, style=discord.ButtonStyle.secondary)
        self.index = index

    async def callback(self, interaction: discord.Interaction):
        view: PollView = self.view
        view.votes[interaction.user.id] = self.index
        await interaction.response.edit_message(embed=view.build_embed())
  • callback:按下按钮的瞬间执行。记录选票后马上重绘消息。这就是"实时统计"的真面目!
  • edit_message:不发新消息,而是直接替换原来的消息。聊天频道不会被刷屏 👍

📌 第 3 步 - 把结果画成柱状图

只显示数字太单调了,我们用方块字符来画个柱状图。

Python
def build_embed(self) -> discord.Embed:
    counts = [0] * len(self.choices)
    for choice_idx in self.votes.values():
        counts[choice_idx] += 1
    total = sum(counts)

    lines = []
    for label, cnt in zip(self.choices, counts):
        ratio = (cnt / total) if total else 0
        bar = "█" * round(ratio * 12) or "░"
        lines.append(f"`{bar:<12}` **{cnt}票** ({ratio*100:.0f}%) · {label}")

    embed = discord.Embed(title=f"🗳️ {self.question}", description="\n".join(lines))
    embed.set_footer(text=f"共 {total} 票 · 点击按钮投票(再点一次可以更改)")
    return embed
  • bar:<12:用反引号包起来并把宽度固定为 12 格,这样柱子的长度就不会参差不齐。
  • or "░":0 票时为了不变成空行,放一个空白格字符进去。
  • 总票数和 len(self.votes) 相同。毕竟一人一票嘛!

![](./img-02.png)


📌 完整代码(直接复制 OK)

Python
# discord.py 2.5+ / Python 3.10+
# pip install -U "discord.py>=2.5" python-dotenv
import os
import datetime

import discord
from discord.ext import commands
from dotenv import load_dotenv

load_dotenv()
token = os.getenv("token")

intents = discord.Intents.default()
intents.message_content = True
intents.polls = True

bot = commands.Bot(command_prefix="!", intents=intents)

class VoteButton(discord.ui.Button):
    def __init__(self, label: str, index: int):
        super().__init__(label=label, style=discord.ButtonStyle.secondary)
        self.index = index

    async def callback(self, interaction: discord.Interaction):
        view: "PollView" = self.view
        if view.closed:
            await interaction.response.send_message("这个投票已经截止了!", ephemeral=True)
            return
        view.votes[interaction.user.id] = self.index
        await interaction.response.edit_message(embed=view.build_embed(), view=view)

class CloseButton(discord.ui.Button):
    def __init__(self):
        super().__init__(label="截止投票", style=discord.ButtonStyle.danger, row=4)

    async def callback(self, interaction: discord.Interaction):
        view: "PollView" = self.view
        if interaction.user.id != view.author_id:
            await interaction.response.send_message("只有发起投票的人才能截止!", ephemeral=True)
            return
        view.closed = True
        for item in view.children:
            item.disabled = True
        await interaction.response.edit_message(embed=view.build_embed(), view=view)
        view.stop()

class PollView(discord.ui.View):
    def __init__(self, question: str, choices: list[str], author_id: int, timeout: float = 3600):
        super().__init__(timeout=timeout)
        self.question = question
        self.choices = choices
        self.author_id = author_id
        self.votes: dict[int, int] = {}
        self.closed = False

        for idx, label in enumerate(choices):
            self.add_item(VoteButton(label=label, index=idx))
        self.add_item(CloseButton())

    def build_embed(self) -> discord.Embed:
        counts = [0] * len(self.choices)
        for choice_idx in self.votes.values():
            counts[choice_idx] += 1
        total = sum(counts)

        lines = []
        for label, cnt in zip(self.choices, counts):
            ratio = (cnt / total) if total else 0
            bar = "█" * round(ratio * 12) or "░"
            lines.append(f"`{bar:<12}` **{cnt}票** ({ratio * 100:.0f}%) · {label}")

        title = ("🔒 [마감] " if self.closed else "🗳️ ") + self.question
        embed = discord.Embed(title=title, description="\n".join(lines), color=0x5865F2)
        footer = f"共 {total} 票"
        footer += " · 这是最终结果" if self.closed else " · 点击按钮投票(再点一次可以更改)"
        embed.set_footer(text=footer)
        return embed

    async def on_timeout(self):
        self.closed = True
        for item in self.children:
            item.disabled = True

@bot.event
async def on_ready():
    await bot.tree.sync()
    print(f"{bot.user} 已就绪!")

@bot.tree.command(name="투표", description="开始按钮投票(选项用逗号分隔)")
async def poll_command(interaction: discord.Interaction, 질문: str, 항목: str, 시간: int = 1):
    choices = [c.strip() for c in 항목.split(",") if c.strip()][:5]
    if len(choices) < 2:
        await interaction.response.send_message("请至少输入 2 个选项!例:炸鸡,披萨,猪蹄", ephemeral=True)
        return

    view = PollView(질문, choices, interaction.user.id, timeout=시간 * 3600)
    await interaction.response.send_message(embed=view.build_embed(), view=view)

@bot.tree.command(name="기본투표", description="弹出 Discord 原生投票")
async def native_poll(interaction: discord.Interaction, 질문: str, 항목: str, 시간: int = 24):
    choices = [c.strip() for c in 항목.split(",") if c.strip()][:10]
    if len(choices) < 2:
        await interaction.response.send_message("请至少输入 2 个选项!", ephemeral=True)
        return

    poll = discord.Poll(question=질문, duration=datetime.timedelta(hours=시간))
    for c in choices:
        poll.add_answer(text=c)

    await interaction.response.send_message(poll=poll)

@bot.event
async def on_poll_vote_add(user, answer):
    print(f"[投票] {user} → {answer.text}")

bot.run(token)

📌 运行结果

启动机器人,在服务器里这样试试。

文本
/투표 질문:오늘 저녁 뭐 먹을까요? 항목:치킨,피자,족발 시간:2

会弹出一个带有 3 个选项按钮和"截止投票"按钮的嵌入消息。只要有人按下按钮,柱状图就会立即更新。同一个人按另一个按钮时,选票会转移过去;再按同一个按钮,票数也不会增加。

发起投票的人按下"截止投票"后,所有按钮都会被禁用,标题也会变成 🔒 [마감](已截止)。就算没人按,过了 시간(投票时长,默认 1 小时)后,on_timeout 也会自动把它锁上。

原生投票可以用 /기본투표 来试试。Discord 应用里熟悉的那个投票 UI 会原样弹出!

![](./img-03.png)


📌 常见错误

1. AttributeError: module 'discord' has no attribute 'Poll'

  • 原因:discord.py 版本低于 2.5。Poll 类是在 2.5 中新增的。
  • 解决:执行 pip install -U "discord.py>=2.5" 后,用 python -c "import discord; print(discord.__version__)" 确认一下。

2. AttributeError: 'Intents' object has no attribute 'polls'

  • 原因:和上面一样是版本问题。polls intent 也是 2.5 才加入的。
  • 解决:升级一下;如果只用按钮方式,把 intents.polls = True 这一行删掉也可以。

3. 按了投票按钮却出现"此交互失败(상호작용이 실패했습니다)"

  • 原因:因为机器人重启过了。View 对象只在内存中存活期间才有效。
  • 解决:短时间的投票重新发起就行;如果想要机器人关掉也能保留的投票,就要指定 timeout=None + custom_id,做成持久化 View(persistent view),并把选票存进数据库(SQLite)。如果选票只放在内存字典里,一重启就全没了!

4. Missing Permissions — 原生投票发不出去

  • 原因:机器人的身份组没有创建投票(Send Polls)权限。
  • 解决:在服务器设置(서버 설정) → 身份组(역할) → 机器人的身份组里打开这个权限。顺便也检查一下频道级别的权限覆盖。

5. 斜杠命令在服务器里看不到

  • 原因:全局同步(bot.tree.sync())最多可能要 1 小时才生效。
  • 解决:测试时用 await bot.tree.sync(guild=discord.Object(id=서버ID)) 只同步到指定服务器,就会立即生效。

📌 总结

今天我们用两种方式做了投票·问卷机器人 🙌

  • 原生投票 — 5 行代码搞定。统计和截止都由 Discord 负责,最省事
  • 按钮自定义投票 — 实时柱状图、防重复投票、提前截止,全都能随心控制

"想快速问一下大家" → 原生投票;"想把结果展示得好看、还要加条件" → 按钮。这样记就行了!

下一篇我们会讲怎么把投票结果存进数据库,让机器人重启后数据也不会丢。算是上面第 3 个错误的根本解决篇。敬请期待!😎

📚 Discord 机器人开发系列

连载 · 共 32 篇中的第 30 篇

Discord 机器人开发

  1. #1Discord 机器人开发 - Slash Command 完全攻略!
  2. #2为 Slash Command 添加选项(Arguments)!
  3. #3在 Slash Command 中添加子命令(Subcommand)!
  4. #4用 Slash Command 发送 Embed 嵌入消息!
  5. #5实现按钮(Button)交互!
  6. #6实现下拉菜单(Select Menu)!
  7. #7用模态框(Modal)接收用户输入(Python 示例)
  8. #8用嵌入消息(Embed)的图片和字段美化消息(Python 示例)
  9. #9接入 AI(Claude·ChatGPT API)!(Python 示例)
  10. #10支持多语言(i18n)!(Python 示例)
  11. #11用 Flask 制作网页仪表盘!(Python 示例)
  12. #12管理命令冷却时间(Cooldown)与权限(Permission)!(Python 示例)
  13. #13用身份组·频道自动化管理服务器!(Python 示例)
  14. #14保存数据!JSON vs SQLite(Python 示例)
  15. #15슬래시 명령어 그룹(Group)으로 명령어 정리하기! (파이썬 예제) (韩语)
  16. #16리마인더·알림 기능 만들기! 봇이 먼저 말 걸게 하기 (파이썬 예제) (韩语)
  17. #17RAG로 문서 기반 답변봇 만들기! 우리 서버 전용 FAQ 봇 (파이썬 예제) (韩语)
  18. #18투표(Poll) 기능 만들기! 다수결로 정하는 서버 규칙 (파이썬 예제) (韩语)
  19. #19Cog로 코드 쪼개기! 1000줄 봇도 안 더러워지는 구조 (파이썬 예제) (韩语)
  20. #20자동 환영 메시지·온보딩 만들기! 규칙 동의 버튼으로 자동 역할 부여 (파이썬 예제) (韩语)
  21. #21키워드 자동응답·금지어 필터 만들기! 경고 누적으로 자동 제재까지 (파이썬 예제) (韩语)
  22. #22슬래시 명령어 자동완성(Autocomplete) 만들기! 금지어를 골라서 추가·삭제 (파이썬 예제) (韩语)
  23. #23aiosqlite로 비동기 DB 제대로 설계하기! JSON 탈출 (파이썬 예제) (韩语)
  24. #24채널 난독화(Channel Obfuscation) 대응하기! 11/16부터 안 보이는 채널이 생겨요 (파이썬 예제) (韩语)
  25. #25봇이 죽지 않게! 에러 핸들링·로깅 체계 잡기 (파이썬 예제) (韩语)
  26. #26티켓(Ticket) 문의 시스템 만들기! 버튼 하나로 1:1 상담 채널 자동 생성 (파이썬 예제) (韩语)
  27. #27티켓 담당자 배정·우선순위 만들기! 스태프가 여러 명이어도 안 꼬이게 (파이썬 예제) (韩语)
  28. #28레벨·경험치(XP) 시스템 만들기! 채팅할수록 레벨업, 랭킹판까지 (파이썬 예제) (韩语)
  29. #29화폐·상점(Economy) 시스템 만들기! 코인 벌어서 아이템 사기 (파이썬 예제) (韩语)
  30. #30打造投票·问卷(Poll)机器人!原生投票 + 按钮实时统计(Python 示例)🗳️当前文章
  31. #32打造服务器日志·审核日志(Audit Log)频道!自动记录消息删除、编辑、成员进出和踢出(Python 示例)🕵️
  32. #33임시 음성방 확장, 파티방 만들기! 방장 위임·강퇴·초대 전용(비공개) 방 + 전용 채팅방 자동 생성 (파이썬 예제) 🎉 (韩语)
显示全部 32 篇收起

作者

Mojangcam

亲手写代码、跑服务器、解决卡住的报错,把一路学到的东西记录下来的开发与 IT 实战博客