[aru_53]最近我在运营一个 Discord 社区,需要实现一个功能:当用户通过特定邀请链接加入服务器时,自动引导他们填写一份简单的问卷,并根据他们使用的设备(iOS/Android)自动发放对应的内测优惠码。
为了追求极致的用户体验,我放弃了传统的“私聊(DM)”机器人,而是使用了 Discord 较新的 Modal(模态框) 和 Button(按钮) 功能,实现了完全在频道内完成的“无感交互”。
这篇文章将记录整个开发过程和完整代码,教你如何用 Python (discord.py) 实现这套系统。
功能明细
-
自动身份组:用户通过特定链接进群,自动获得
🎟️ Access Pass身份。 -
引导领码:机器人在频道 @用户,引导点击“领取”按钮。
-
填写问卷:用户点击按钮,屏幕中央弹出表单(Modal),无需切换页面。
-
设备分流:提交问卷后,弹出设备选择按钮(iOS / Android)。
-
自动发码:根据选择,从后台库存发放对应优惠码,并防止重复领取。
-
数据导出:管理员可以通过指令导出 CSV 表格。
准备工作
1. 环境要求
-
Python 3.8+
-
库:
discord.py
pip install discord.py
2. Discord 开发者后台设置
-
前往 Discord Developer Portal 创建应用。
-
Bot 页面:
-
开启 Privileged Gateway Intents 下的三个开关:
Presence Intent,Server Members Intent,Message Content Intent(这步很重要!)。 -
复制 Token。
-
-
OAuth2 页面:
-
选择
botscope。 -
权限勾选
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. 部署领码面板
-
运行机器人:
python bot.py -
进入 Discord 的领码频道。
-
输入指令:
!setup_claim -
机器人会发送带有按钮的卡片,这个卡片是永久有效的,即使机器人重启按钮也能用(因为我们用了
timeout=None和add_view)。
3. 数据管理
如果想查看发了多少码、用户选了什么,输入 !export,机器人会发给你一份 CSV 表格。
开发心得
-
模态框的限制:Discord 的 Modal 只能放文本输入框,不能放下拉菜单。为了解决“选择 iOS/Android”的问题,我采用了 Modal + View 的组合拳:先填表单,提交后再弹出一个只有两个按钮的消息让用户选设备。
-
数据持久化:使用 SQLite 替代 JSON 存用户数据,在并发量大时更稳定,也不容易丢数据。
-
无感交互:所有的反馈消息都设置了
ephemeral=True,这意味着这些消息只有点击按钮的用户自己能看到,不会刷屏,保护了用户隐私,也保持了频道的整洁。
🎨 原创不易,支持请点赞、转载请注明本文作者为除夕
