Turn SparkBot into MineBot

This commit is contained in:
Will Bradley 2024-04-10 15:57:07 -07:00
parent f44468e4b6
commit 9bf136abf5
Signed by: will
GPG Key ID: 1159B930701263F3
5 changed files with 274 additions and 236 deletions

5
.gitignore vendored
View File

@ -1,5 +1,4 @@
*.env
/.venv
member_data.db
discord.log
SparkBot.log
bot_data.db
bot.log

Binary file not shown.

View File

@ -1,6 +1,8 @@
# SparkBot
# MineBot
A Discord bot for Spark Studio Salem
A Discord bot for managing your Minecraft server via [Minecraft Minder](https://git.zyphon.com/will/minecraft-minder)
Forked from [SparkBot](https://github.com/jkkicks/SparkBot)
## Prerequisites
@ -21,16 +23,25 @@ Run `python3 main.py`
## Usage
### Available Commands
### Command Tree
```
/help : to view all commands
/nick : to view current nickname
/setnick : to change nickname
/reinit : to re-initialize a user in the database (i.e. if they joined when the bot wasn't listening)
/addserver - Add a Minecraft server to the bot (global)
/adduser - Add a user to a Minecraft server (global)
/removeuser - Remove a user from a Minecraft server
/listservers - List the Minecraft servers you have access to
/status - Get the status of the first Minecraft server you have acces to
/start - Start the first Minecraft server you have acces to
```
#### Hidden commands
```
/99 - Brooklyn 99 Quotes
/shutdown - Kill the bot (global)
/sync - Sync the slash-command tree with Discord
```
## Database
Initialization of the structure is automatically handled inside `main.py` and
creates the SQLITE3 file `member_data.db`.
creates the SQLITE3 file `bot_data.db`.

View File

@ -1,6 +1,3 @@
##EXAMPLE .ENV FILE
BOT_TOKEN = ""INPUT DISCORD API KEY HERE"
BOT_CHANNEL_ID = INPUT CHANNEL ID FOR COMMANDS
WELCOME_CHANNEL_ID = INPUT WELCOME CHANNEL ID HERE
GUILD_ID = INPUT GUILD ID HERE

475
main.py
View File

@ -9,197 +9,250 @@ from datetime import datetime, timezone
from discord import ui, Interaction
import random
import sys
import requests
import time
async def welcome_message():
# Find the #welcome channel
welcome_channel_id = int(os.getenv('WELCOME_CHANNEL_ID'))
welcome_channel = discord.utils.get(client.get_all_channels(), id=welcome_channel_id)
## Defines ##
# Check if welcome message has already been sent in the channel
async for message in welcome_channel.history(limit=100):
if message.author == client.user and message.embeds:
print(f'Welcome message found. Message ID: {message.id}')
return # If the welcome message is found, exit the function
# Send welcome message in the #welcome channel
print(f'No welcome message found, creating one now')
view = discord.ui.View()
button = discord.ui.Button(label="Complete Onboarding")
view.add_item(button)
embed = discord.Embed(title="Welcome to the Server!", description="Here's how to get started:")
# embed.set_thumbnail(url=ctx.guild.icon)
embed.add_field(name="Step 1:",
value="Read the server rules in [#rules](https://discord.com/channels/1207801896656568480/1207802982574596137) channel.")
embed.add_field(name="Step 2:",
value="Check out some cool posts over in [#projects](https://discord.com/channels/1207801896656568480/1207807674075320390).")
embed.add_field(name="Step 3:", value="Complete Onboarding procedure to unlock the rest of the server.")
embed.set_footer(text="Enjoy your stay!")
await welcome_channel.send(embed=embed, view=OnboardButtons())
async def update_nickname(member, firstname, lastname): #update server nickname from DB
nickname = f"{firstname} {lastname}"
with sqlite3.connect('member_data.db') as conn:
c = conn.cursor()
c.execute("UPDATE members SET nickname = ?, firstname = ?, lastname = ? WHERE user_id = ?", (nickname, firstname, lastname, member.id))
conn.commit()
print(f'Updated DB nickname for: {member}, {firstname}, {lastname}, {nickname}')
await member.edit(nick=nickname)
async def remove_user(user): #Remove user from DB and delete server nickname
with sqlite3.connect('member_data.db') as conn:
c = conn.cursor()
c.execute("DELETE FROM members WHERE user_id = ?", (user.id,))
conn.commit()
if user:
await user.edit(nick=None)
#TODO demote user role
async def update_onboard(member): #increase onboarding status by 1
logging.info(f'Updating onboarding for: {member.display_name} {member.id}')
# Connect to the database using a context manager
with sqlite3.connect('member_data.db') as conn:
c = conn.cursor()
c.execute("SELECT onboarding_status FROM members WHERE user_id = ?", (member.id,))
row = c.fetchone()
if row is None:
logging.error("No matching user found in the database.")
return
status = row[0]
status += 1
c.execute("UPDATE members SET onboarding_status = ? WHERE user_id = ?",
(status, member.id))
conn.commit()
logging.warning(f'Updated onboarding for {member.display_name}: {status}')
async def add_member_to_role(member, role_name):
print(f"Adding {role_name} role to {member.display_name}")
role = discord.utils.get(member.guild.roles, name=role_name) # Get role from guild
if role:
await member.add_roles(role)
print(f"Added role '{role_name}' to member '{member.display_name}'")
else:
print(f"Role '{role_name}' not found in server '{member.guild.name}'")
# Load environment variables
# Load environment variables from .env file
load_dotenv()
# Load bot token and welcome channel id from environment variables
TOKEN = os.getenv('BOT_TOKEN')
# Setup logging
logging.basicConfig(filename='SparkBot.log', level=logging.INFO)
logging.basicConfig(filename='bot.log', level=logging.INFO)
# Define intents
intents = discord.Intents.all()
# Connect to SQLite database
with sqlite3.connect('member_data.db') as conn:
c = conn.cursor()
# Create table with fields if it doesn't exist already
c.execute('''CREATE TABLE IF NOT EXISTS members (
user_id INTEGER PRIMARY KEY,
username TEXT, nickname TEXT,
firstname TEXT, lastname TEXT,
join_datetime TEXT,
onboarding_status INTEGER,
last_change_datetime TEXT
)''')
conn.commit()
## Function and Class Definitions ##
class PersistentViewBot(commands.Bot):
def __init__(self):
intents = discord.Intents().all()
# Define intents
intents = discord.Intents.none()
# intents.auto_moderation = True
# intents.auto_moderation_configuration = True
# intents.auto_moderation_execution = True
# intents.bans = True
# intents.dm_messages = True
# intents.dm_reactions = True
# intents.dm_typing = True
# intents.emojis = True
# intents.emojis_and_stickers = True
# intents.guild_messages = True
# intents.guild_reactions = True
# intents.guild_scheduled_events = True
# intents.guild_typing = True
intents.guilds = True
# intents.integrations = True
# intents.invites = True
# intents.members = True
intents.message_content = True
intents.messages = True
# intents.moderation = True
# intents.presences = True
# intents.reactions = True
# intents.typing = True
# intents.value = True
# intents.voice_states = True
# intents.webhooks = True
super().__init__(command_prefix=commands.when_mentioned_or("/"), intents=intents)
async def setup_hook(self) -> None:
self.add_view(OnboardButtons())
# async def setup_hook(self) -> None:
# self.add_view(OnboardButtons())
#self.add_view(OnboardButtons()) #Add More views with more add_view commands.
client = PersistentViewBot()
class OnboardModal(discord.ui.Modal, title="Onboarding: "):
first_name = discord.ui.TextInput(
style=discord.TextStyle.short,
label="First name",
required=True,
placeholder="John "
)
last_name = discord.ui.TextInput(
style=discord.TextStyle.short,
label="Last Name",
required=True,
placeholder="Doe"
)
async def on_submit(self, interaction: discord.InteractionResponse):
await interaction.response.defer()
await update_nickname(member=self.user, firstname=self.first_name.value, lastname=self.last_name.value)
await update_onboard(member=self.user)
role_to_add = "Maker"
await add_member_to_role(member=self.user, role_name=role_to_add)
# print(f'First name: {self.first_name.value}')
# print(f'Last Name: {self.last_name.value}')
# print(f'User: {self.user.id}')
#channel = interaction.guild.get_channel(WELCOME_CHANNEL_ID)
#embed = discord.Embed(title="New Onboarding data", description=self.message.value)
#embed.set_author(name=self.user.nick)
#await channel.send(embed=embed)
await interaction.send_message("Thanks for completing onboarding!", ephemeral=True)
async def on_error(self, interaction: discord.Interaction, error):
...
## Instantiate bot ##
bot = PersistentViewBot()
class OnboardButtons(discord.ui.View):
def __init__(self):
super().__init__(timeout=None)
@discord.ui.button(label="Complete Onboarding", style=discord.ButtonStyle.green, custom_id="1")
async def onboard(self, interaction: discord.InteractionResponse, button: discord.ui.Button):
onboard_modal = OnboardModal()
onboard_modal.user = interaction.user
await interaction.response.send_modal(onboard_modal)
@client.event
@bot.event
async def on_ready():
print(f'Logged in as {client.user}')
await welcome_message()
try: #sync slash commands
synced = await client.tree.sync()
print(f'Slash Commands Synced. {str(len(synced))} Total Commands {synced}')
except Exception as e:
print(e)
print(f'Logged in as {bot.user}')
# await welcome_message()
print("Members in the DB:")
with sqlite3.connect('member_data.db') as conn:
with sqlite3.connect('bot_data.db') as conn:
c = conn.cursor()
c.execute("SELECT user_id, username, nickname FROM members")
print("Users in the DB:")
c.execute("SELECT id, username FROM users")
for row in c.fetchall():
print(f' ID: {row[0]} User: {row[1]} Nick: {row[2]}')
print(f' ID: {row[0]} User: {row[1]}')
print("Servers in the DB:")
c.execute("SELECT id, join_url FROM mc_servers")
for row in c.fetchall():
print(f' ID: {row[0]} URL: {row[1]}')
print("Permissions in the DB:")
c.execute("SELECT user_id, mc_server_id, permission_level FROM mc_server_users")
for row in c.fetchall():
print(f' U: {row[0]} S: {row[1]} Perm: {row[2]}')
print("Ready.")
@client.command(name='reinit')
async def cmd_reinit(ctx):
"""Re-initialize a user in the database (i.e. if the bot wasn't listening when they joined)"""
await on_member_join(ctx.author)
await ctx.send("Reinitialized!")
@bot.tree.command(name='addserver', description="Add a Minecraft server to the bot")
@app_commands.describe(
status_url="The Minecraft Minder root URL to query/change server status from, like `http://example.com/minecraft.php?password=1234`",
join_url="The Minecraft server hostname and port for people to join, like `minecraft.example.com:25565`"
)
async def addserver(interaction: discord.Interaction, status_url: str, join_url: str):
logging.info(f'Addserver: {interaction.user.name} {interaction.user.id} added {status_url} {join_url}')
@client.command(name='nick')
async def cmd_nick(ctx):
"""View current nickname"""
await ctx.send(f'You are {ctx.author.nick}')
# Connect to the database using a context manager
with sqlite3.connect('bot_data.db') as conn:
c = conn.cursor()
@client.command(name='setnick')
async def cmd_setnick(ctx, arg1, arg2):
"""Change nickname (use two words separated by a space)"""
await update_nickname(ctx.author, arg1, arg2)
await ctx.send(f'You are now {ctx.author.nick}')
c.execute("INSERT OR REPLACE INTO users (id, username) VALUES (?,?)", (interaction.user.id,interaction.user.name))
c.execute("INSERT INTO mc_servers (status_url, join_url) VALUES (?,?)", (status_url,join_url))
server_id = c.lastrowid
c.execute("INSERT OR REPLACE INTO mc_server_users (user_id, mc_server_id, permission_level) VALUES (?,?,?)",
(interaction.user.id, server_id, 100))
su_id = c.lastrowid
@client.command(name='99')
conn.commit()
logging.warning(f'Addserver: st: {status_url} jo: {join_url} u: {interaction.user.id} s: {server_id} su: {su_id}')
await interaction.response.send_message(content="Added!", ephemeral=True)
@bot.tree.command(name='adduser', description="Give a user privileges to control your Minecraft server")
@app_commands.describe(member="The member you want to add", server_id="The internal server ID to add them to")
async def adduser(interaction: discord.Interaction, member: discord.Member, server_id: int):
logging.info(f'Adduser: {member.name} {member.id} {server_id}')
with sqlite3.connect('bot_data.db') as conn:
c = conn.cursor()
c.execute("SELECT s.id, su.permission_level FROM mc_servers s JOIN mc_server_users su ON su.user_id = ? AND su.mc_server_id = ? AND su.mc_server_id = s.id", (interaction.user.id,server_id))
row = c.fetchone()
if row:
if row[1] >= 100:
c.execute("INSERT OR REPLACE INTO users (id, username) VALUES (?,?)", (member.id,member.name))
c.execute("SELECT user_id, mc_server_id FROM mc_server_users WHERE user_id = ? AND mc_server_id = ?", (member.id, server_id))
if c.fetchone():
logging.warning(f'Adduser: u: {interaction.user.id} m: {member.name} s: {row[0]} has perm')
await interaction.response.send_message(content="{} is already added to the server!".format(member.mention), ephemeral=True)
return
else:
c.execute("INSERT OR REPLACE INTO mc_server_users (user_id, mc_server_id, permission_level) VALUES (?,?,?)",
(member.id, row[0], 1))
conn.commit()
logging.warning(f'Adduser: u: {interaction.user.id} m: {member.name} s: {row[0]}')
await interaction.response.send_message(content="Added {} to the server!".format(member.mention))
return
else:
logging.error(f'Adduser: u: {interaction.user.id} m: {member.name} s: {server_id} privfail')
await interaction.response.send_message(content="You don't have enough privileges to add users to server #{}! ({}<100)".format(server_id, row[1]), ephemeral=True)
return
else:
logging.error(f'Adduser: u: {interaction.user.id} m: {member.name} s: {server_id} permfail')
await interaction.response.send_message(content="You don't have permission to access server #{}!".format(server_id), ephemeral=True)
return
@bot.tree.command(name='removeuser', description="Remove a user from the ability to control your Minecraft server")
@app_commands.describe(member="The member you want to remove", server_id="The internal server ID to remove them from")
async def removeuser(interaction: discord.Interaction, member: discord.Member, server_id: int):
logging.info(f'Removeuser: {member.name} {member.id} {server_id}')
with sqlite3.connect('bot_data.db') as conn:
c = conn.cursor()
c.execute("SELECT s.id, su.permission_level FROM mc_servers s JOIN mc_server_users su ON su.user_id = ? AND su.mc_server_id = ? AND su.mc_server_id = s.id", (interaction.user.id,server_id))
row = c.fetchone()
if row:
if row[1] >= 100:
c.execute("SELECT user_id, mc_server_id FROM mc_server_users WHERE user_id = ? AND mc_server_id = ?", (member.id, server_id))
if c.fetchone() == None:
logging.warning(f'Removeuser: u: {interaction.user.id} m: {member.name} s: {row[0]} doesn\'t have perm')
await interaction.response.send_message(content="{} is already removed from the server!".format(member.mention), ephemeral=True)
return
else:
c.execute("DELETE FROM mc_server_users WHERE user_id=? AND mc_server_id=?", (member.id, row[0]))
conn.commit()
logging.warning(f'Removeuser: u: {interaction.user.id} m: {member.name} s: {row[0]}')
await interaction.response.send_message(content="Removed {} from the server!".format(member.mention), ephemeral=True)
return
else:
logging.error(f'Removeuser: u: {interaction.user.id} m: {member.name} s: {server_id} privfail')
await interaction.response.send_message(content="You don't have enough privileges to remove users from server #{}! ({}<100)".format(server_id, row[1]), ephemeral=True)
return
else:
logging.error(f'Removeuser: u: {interaction.user.id} m: {member.name} s: {server_id} permfail')
await interaction.response.send_message(content="You don't have permission to access server #{}!".format(server_id), ephemeral=True)
return
@bot.tree.command(name='listservers', description="Show the IDs of the MC servers you have access to")
async def listservers(interaction: discord.Interaction):
logging.info(f'Listservers: {interaction.user.name}')
# Connect to the database using a context manager
with sqlite3.connect('bot_data.db') as conn:
c = conn.cursor()
c.execute("SELECT id, status_url, join_url FROM mc_servers s JOIN mc_server_users su ON su.user_id = ?", (interaction.user.id,))
o = ""
for row in c.fetchall():
o += '- **ID**: {}\n - **Status URL**: {}\n - **Join URL**: {}\n'.format(row[0], row[1], row[2])
if o == "":
o = "No servers."
await interaction.response.send_message(content=o, ephemeral=True)
@bot.tree.command(name='status', description="Minecraft Status")
async def cmd_status(interaction: discord.Interaction):
logging.warning(f'Member {interaction.user} queried Minecraft status.')
with sqlite3.connect('bot_data.db') as conn:
c = conn.cursor()
# Todo: select "this guild's/channel's" server not just the first match
c.execute("SELECT s.id, su.permission_level, s.status_url, s.join_url FROM mc_servers s JOIN mc_server_users su ON su.user_id = ? AND su.mc_server_id = s.id", (interaction.user.id,))
row = c.fetchone()
if row:
if row[1] >= 1:
logging.warning(f'Member {interaction.user} started Minecraft.')
msg = await interaction.response.defer(thinking=True)
res = requests.post(row[2], data={"action": "status"})
await interaction.followup.send(content=f'{res.text.capitalize()}\nJoin at `{row[3]}`')
@bot.tree.command(name='start', description="Start Minecraft")
async def cmd_start(interaction: discord.Interaction):
logging.info(f'Start: {interaction.user.name} {interaction.user.id}')
with sqlite3.connect('bot_data.db') as conn:
c = conn.cursor()
# Todo: select "this guild's/channel's" server not just the first match
c.execute("SELECT s.id, su.permission_level, s.status_url, s.join_url FROM mc_servers s JOIN mc_server_users su ON su.user_id = ? AND su.mc_server_id = s.id", (interaction.user.id,))
row = c.fetchone()
if row:
if row[1] >= 1:
logging.warning(f'Member {interaction.user} started Minecraft.')
msg = await interaction.response.defer(thinking=True)
res = requests.post(row[2], data={"action": "on"})
cont=True
count=0
while cont:
count += 1
time.sleep(5)
res = requests.post(row[2], data={"action": "status"})
if "running" in res.text:
await interaction.followup.send(content=f'Started: {res.text.capitalize()}\nJoin at `{row[3]}`')
cont=False
elif count > 10:
await interaction.followup.send(content=f'Starting timed out: {res.text.capitalize()}')
cont=False
return
else:
logging.error(f'Start: u: {interaction.user.id} m: {interaction.user.name} s: {row[0]} permfail')
await interaction.response.send_message(content="You don't have permission to access server #{}!".format(row[0]), ephemeral=True)
return
else:
logging.error(f'Start: u: {interaction.user.id} m: {interaction.user.name} permfail')
await interaction.response.send_message(content="You don't have permission to access any servers!", ephemeral=True)
return
@bot.command(name='99')
async def cmd_nine_nine(ctx):
brooklyn_99_quotes = [
'I\'m the human form of the 💯 emoji.',
@ -213,73 +266,51 @@ async def cmd_nine_nine(ctx):
response = random.choice(brooklyn_99_quotes)
await ctx.send(response)
@client.command(name='shutdown')
@bot.command(name='shutdown')
async def cmd_shutdown(ctx):
"""Shutdown the bot"""
await ctx.send("Shutting down!")
sys.exit()
# @client.event
# async def on_message(message):
# if message.author == client.user:
# return
@bot.command(name='sync')
async def cmd_sync(ctx):
synced = await bot.tree.sync()
o = f'{len(synced)}/{len(bot.tree.get_commands())} Commands Synced: ({", ".join([str(o.name) for o in synced])})'
await ctx.send(o, ephemeral=True)
logging.warning(o)
# brooklyn_99_quotes = [
# 'I\'m the human form of the 💯 emoji.',
# 'Bingpot!',
# (
# 'Cool. Cool cool cool cool cool cool cool, '
# 'no doubt no doubt no doubt no doubt.'
# ),
# ]
# if message.content == '/99':
# response = random.choice(brooklyn_99_quotes)
# await message.channel.send(response)
@client.event
async def on_member_join(member):
await welcome_message()
with sqlite3.connect('member_data.db') as conn:
c = conn.cursor()
# Check if member already exists in the database
c.execute("SELECT * FROM members WHERE user_id = ?", (member.id,))
existing_member = c.fetchone()
if not existing_member: # If it's the member's first time joining
# Add new member to the database
c.execute(
"INSERT OR REPLACE INTO members (user_id, username, join_datetime, onboarding_status, last_change_datetime) VALUES (?, ?, ?, ?, ?)",
(member.id, member.name, member.joined_at.isoformat(), 0, datetime.now(timezone.utc).isoformat()))
conn.commit()
# Update member nickname
await member.edit(nick=c.execute("SELECT nickname FROM members WHERE user_id = ?", (member.id,)).fetchone()[0])
# Log member join
logging.warning(f'Member {member.name} joined the server.')
else:
logging.info(f'Member {member.name} rejoined the server.')
@client.tree.command(name="remove", description="Remove user from database, and remove user's nickname")
@app_commands.describe(member="The member you want to remove")
async def remove(interaction: discord.Integration, member: discord.Member):
await remove_user(member)
await interaction.response.send_message(f"User {member.display_name} Removed", ephemeral=True)
@client.event
@bot.event
async def on_member_remove(member):
# Log member leave
logging.warning(f'Member {member.name} left the server.')
# Load bot token and welcome channel id from .env file
TOKEN = os.getenv('BOT_TOKEN')
WELCOME_CHANNEL_ID = os.getenv('WELCOME_CHANNEL_ID')
GUILD_ID = str(os.getenv('GUILD_ID'))
## Runtime ##
# Connect to SQLite database
with sqlite3.connect('bot_data.db') as conn:
c = conn.cursor()
# Create table with fields if it doesn't exist already
c.execute('''CREATE TABLE IF NOT EXISTS users (
id TEXT PRIMARY KEY,
username TEXT
)''')
c.execute('''CREATE TABLE IF NOT EXISTS mc_servers (
id INTEGER PRIMARY KEY AUTOINCREMENT,
status_url TEXT,
join_url TEXT
)''')
c.execute('''CREATE TABLE IF NOT EXISTS mc_server_users (
user_id TEXT,
mc_server_id TEXT,
permission_level INTEGER,
FOREIGN KEY (user_id) REFERENCES users (id),
FOREIGN KEY (mc_server_id) REFERENCES mc_servers (id),
PRIMARY KEY (user_id, mc_server_id)
)''')
conn.commit()
# Start bot
client.run(TOKEN)
bot.run(TOKEN)