手把手教你做一个 Discord 自动发码机器人 (Modal + 按钮交互)

除夕 24 0

手把手教你做一个 Discord 自动发码机器人 (Modal + 按钮交互)

[aru_53]最近我在运营一个 Discord 社区,需要实现一个功能:当用户通过特定邀请链接加入服务器时,自动引导他们填写一份简单的问卷,并根据他们使用的设备(iOS/Android)自动发放对应的内测优惠码。

为了追求极致的用户体验,我放弃了传统的“私聊(DM)”机器人,而是使用了 Discord 较新的 Modal(模态框)Button(按钮) 功能,实现了完全在频道内完成的“无感交互”。

这篇文章将记录整个开发过程和完整代码,教你如何用 Python (discord.py) 实现这套系统。

功能明细

  1. 自动身份组:用户通过特定链接进群,自动获得 🎟️ Access Pass 身份。

  2. 引导领码:机器人在频道 @用户,引导点击“领取”按钮。

  3. 填写问卷:用户点击按钮,屏幕中央弹出表单(Modal),无需切换页面。

  4. 设备分流:提交问卷后,弹出设备选择按钮(iOS / Android)。

  5. 自动发码:根据选择,从后台库存发放对应优惠码,并防止重复领取。

  6. 数据导出:管理员可以通过指令导出 CSV 表格。

准备工作

1. 环境要求

  • Python 3.8+

  • 库:discord.py

pip install discord.py

2. Discord 开发者后台设置

  1. 前往 Discord Developer Portal 创建应用。

  2. Bot 页面:

    • 开启 Privileged Gateway Intents 下的三个开关:Presence Intent, Server Members Intent, Message Content Intent(这步很重要!)。

    • 复制 Token

  3. OAuth2 页面:

    • 选择 bot scope。

    • 权限勾选 Administrator (方便测试) 或 Manage Roles, Manage Channels, Send Messages 等。

    • 生成邀请链接让机器人进群。


代码实现

这个机器人的核心逻辑分为三部分:数据存储 (SQLite)交互界面 (UI)事件监听 (Events)

1. 项目结构

你的文件夹里应该有以下文件:

  • bot.py: 主程序代码。

  • promo_codes.json: 存放优惠码库存。

  • bot_data.db: (自动生成) 存放用户数据。

2. 完整代码 (bot.py)

import discord
from discord.ext import commands
from discord import ui
import asyncio
import json
import os
import sqlite3
import csv
from datetime import datetime

# --- ⚙️ 配置区 (记得修改这里) ---
TOKEN = '你的_ROBOT_TOKEN'
PROXY_URL = "http://127.0.0.1:7890"  # 国内用户可能需要代理,不需要设为 None

TARGET_INVITE_CODE = "QyxY2EjTuV"      # 你的推广邀请码
ROLE_NAME = "🎟️ Access Pass"           # 进群自动给的身份组
CLAIM_CHANNEL_NAME = "🎁-claim-code"   # 领码频道名称

# 文件路径
DB_FILE = "bot_data.db"
PROMO_FILE = "promo_codes.json"

# --- 初始化 ---
intents = discord.Intents.default()
intents.members = True
intents.message_content = True
intents.invites = True

bot = commands.Bot(command_prefix='!', intents=intents, proxy=PROXY_URL)
invites_cache = {}

# --- 🛠️ 数据库与文件处理 ---

def init_db():
    """初始化 SQLite 数据库"""
    conn = sqlite3.connect(DB_FILE)
    c = conn.cursor()
    c.execute('''CREATE TABLE IF NOT EXISTS users (
                    user_id INTEGER PRIMARY KEY,
                    username TEXT,
                    answers TEXT,
                    promo_code TEXT,
                    os_type TEXT,
                    timestamp TEXT
                )''')
    conn.commit()
    conn.close()
    
    # 初始化优惠码文件 (如果不存在)
    if not os.path.exists(PROMO_FILE):
        default_data = {
            "ios": ["IOS-CODE-1", "IOS-CODE-2"],
            "android": ["AND-CODE-1", "AND-CODE-2"]
        }
        with open(PROMO_FILE, "w", encoding='utf-8') as f:
            json.dump(default_data, f, indent=4)
        print(f"📄 已建立 {PROMO_FILE},请手动填入真实优惠码。")

def check_existing_code(user_id):
    """检查用户是否已领过码"""
    conn = sqlite3.connect(DB_FILE)
    c = conn.cursor()
    c.execute("SELECT promo_code FROM users WHERE user_id = ?", (user_id,))
    result = c.fetchone()
    conn.close()
    if result and result[0]:
        return result[0]
    return None

def save_user_data(user_id, username, answers, code, os_type):
    """保存用户数据"""
    conn = sqlite3.connect(DB_FILE)
    c = conn.cursor()
    answers_str = json.dumps(answers, ensure_ascii=False)
    timestamp = datetime.now().strftime("%Y-%m-%d %H:%M:%S")

    try:
        c.execute('''INSERT OR REPLACE INTO users 
                     (user_id, username, answers, promo_code, os_type, timestamp) 
                     VALUES (?, ?, ?, ?, ?, ?)''', 
                     (user_id, username, answers_str, code, os_type, timestamp))
        conn.commit()
    except Exception as e:
        print(f"❌ 数据库错误: {e}")
    finally:
        conn.close()

def get_new_promo_code(os_type):
    """从 JSON 库存中取码"""
    with open(PROMO_FILE, "r", encoding='utf-8') as f:
        data = json.load(f)

    key = os_type.lower()
    if key not in data or not data[key]:
        return None

    code = data[key].pop(0)

    with open(PROMO_FILE, "w", encoding='utf-8') as f:
        json.dump(data, f, indent=4)
        
    return code

# --- 🖥️ UI 交互逻辑 (核心部分) ---

# 3. 设备选择视图 (用户填完表单后看到这个)
class DeviceSelectView(ui.View):
    def __init__(self, answer_q1):
        self.answer_q1 = answer_q1 # 接收上一步的问卷答案
        super().__init__(timeout=180)

    async def _handle_click(self, interaction: discord.Interaction, os_type: str):
        # 防重复检查
        existing_code = check_existing_code(interaction.user.id)
        if existing_code:
            await interaction.response.edit_message(content=f"⚠️ 你已经领过码了: **`{existing_code}`**", view=None)
            return

        # 发放逻辑
        code = get_new_promo_code(os_type)
        answers = [self.answer_q1]
        save_user_data(interaction.user.id, interaction.user.name, answers, code, os_type)

        if code:
            msg = f"🎉 **成功!** 這是你的 **{os_type}** 專屬優惠碼:\n# **`{code}`**\n(請盡快複製保存)"
        else:
            msg = f"✅ 设置完成!可惜 **{os_type}** 的优惠码已被领完。"

        await interaction.response.edit_message(content=msg, view=None)

    @ui.button(label="iOS (iPhone)", emoji="🍎", style=discord.ButtonStyle.gray)
    async def ios_button(self, interaction: discord.Interaction, button: ui.Button):
        await self._handle_click(interaction, "iOS")

    @ui.button(label="Android", emoji="🤖", style=discord.ButtonStyle.gray)
    async def android_button(self, interaction: discord.Interaction, button: ui.Button):
        await self._handle_click(interaction, "Android")

# 2. 模态框 (Modal) - 填写问卷
class PetQuestionModal(ui.Modal, title='🎁 申请内测资格'):
    q1 = ui.TextInput(label='1. 你家里养猫还是养狗?', placeholder='猫 / 狗 / 都有', max_length=50)

    async def on_submit(self, interaction: discord.Interaction):
        # 提交问卷后,不直接发码,而是弹出设备选择按钮
        view = DeviceSelectView(answer_q1=self.q1.value)
        await interaction.response.send_message(
            content="**最后一步:** 请选择你使用的手机系统:",
            view=view,
            ephemeral=True # 仅对自己可见
        )

# 1. 初始领码按钮 (永久存在于频道)
class ClaimView(ui.View):
    def __init__(self):
        super().__init__(timeout=None) # 设置为 None 确保重启后按钮依然有效

    @ui.button(label="🎁 点击领取内测码", style=discord.ButtonStyle.blurple, custom_id="persistent_claim_btn")
    async def claim_button(self, interaction: discord.Interaction, button: ui.Button):
        existing_code = check_existing_code(interaction.user.id)
        if existing_code:
            await interaction.response.send_message(f"✋ 你已经领过了!码是: **`{existing_code}`**", ephemeral=True)
        else:
            await interaction.response.send_modal(PetQuestionModal())

# --- 📡 事件监听 ---

@bot.event
async def on_ready():
    print(f'✅ {bot.user} 已上线!')
    init_db()
    bot.add_view(ClaimView()) # 注册持久化视图
    
    # 缓存邀请链接
    for guild in bot.guilds:
        try:
            current_invites = await guild.invites()
            invites_cache[guild.id] = {invite.code: invite.uses for invite in current_invites}
        except: pass

@bot.command()
async def setup_claim(ctx):
    """管理员指令:发送领码面板"""
    if not ctx.author.guild_permissions.administrator: return
    await ctx.message.delete()
    
    embed = discord.Embed(title="🎁 欢迎加入内测!", description="点击下方按钮回答简单问题,即刻获取优惠码。", color=discord.Color.blue())
    # 这里可以换成你的 Banner 图片
    embed.set_image(url="https://你的图片链接.jpg")
    
    await ctx.send(embed=embed, view=ClaimView())

@bot.command()
async def export(ctx):
    """管理员指令:导出 CSV"""
    if not ctx.author.guild_permissions.administrator: return
    
    conn = sqlite3.connect(DB_FILE)
    c = conn.cursor()
    c.execute("SELECT * FROM users")
    rows = c.fetchall()
    conn.close()
    
    if not rows:
        await ctx.send("📂 暂无数据。")
        return

    with open("users.csv", "w", newline='', encoding='utf-8-sig') as f:
        writer = csv.writer(f)
        writer.writerow(["User ID", "Username", "Answers", "Promo Code", "OS Type", "Timestamp"])
        writer.writerows(rows)
    
    await ctx.send(file=discord.File("users.csv"))

@bot.event
async def on_member_join(member):
    """新成员加入检测"""
    guild = member.guild
    try: new_invites = await guild.invites()
    except: return

    old_invites = invites_cache.get(guild.id, {})
    used_invite_code = None
    
    # 比对邀请码使用次数
    for invite in new_invites:
        old_uses = old_invites.get(invite.code, 0)
        if invite.uses > old_uses:
            used_invite_code = invite.code
            invites_cache[guild.id][invite.code] = invite.uses
            break
    
    if not used_invite_code:
        invites_cache[guild.id] = {inv.code: inv.uses for inv in new_invites}

    # 如果匹配到目标邀请码
    if used_invite_code == TARGET_INVITE_CODE:
        # 给身份
        role = discord.utils.get(guild.roles, name=ROLE_NAME)
        if role: await member.add_roles(role)
        
        # 频道引导
        ch = discord.utils.get(guild.channels, name=CLAIM_CHANNEL_NAME)
        if ch:
            await ch.send(f"👋 欢迎 {member.mention}! 请点击上方的 **'点击领取'** 按钮开始!", delete_after=60)

bot.run(TOKEN)

使用指南

1. 配置优惠码

首次运行后,会自动生成 promo_codes.json。打开它,按照分类填入你的真实优惠码:

{
    "ios": ["IOS-001", "IOS-002", "IOS-003"],
    "android": ["AND-001", "AND-002", "AND-003"]
}

2. 部署领码面板

  1. 运行机器人:python bot.py

  2. 进入 Discord 的领码频道。

  3. 输入指令:!setup_claim

  4. 机器人会发送带有按钮的卡片,这个卡片是永久有效的,即使机器人重启按钮也能用(因为我们用了 timeout=Noneadd_view)。

3. 数据管理

如果想查看发了多少码、用户选了什么,输入 !export,机器人会发给你一份 CSV 表格。

开发心得

  1. 模态框的限制:Discord 的 Modal 只能放文本输入框,不能放下拉菜单。为了解决“选择 iOS/Android”的问题,我采用了 Modal + View 的组合拳:先填表单,提交后再弹出一个只有两个按钮的消息让用户选设备。

  2. 数据持久化:使用 SQLite 替代 JSON 存用户数据,在并发量大时更稳定,也不容易丢数据。

  3. 无感交互:所有的反馈消息都设置了 ephemeral=True,这意味着这些消息只有点击按钮的用户自己能看到,不会刷屏,保护了用户隐私,也保持了频道的整洁。

发表评论 取消回复
OwO 图片 链接 代码

分享