From 9bf136abf513e796f30095062675b8d8e7b6b7ed Mon Sep 17 00:00:00 2001 From: Will Date: Wed, 10 Apr 2024 15:57:07 -0700 Subject: [PATCH] Turn SparkBot into MineBot --- .gitignore | 5 +- Deprecated_Example_DB.db | Bin 8192 -> 0 bytes README.md | 27 ++- env.dist | 3 - main.py | 475 +++++++++++++++++++++------------------ 5 files changed, 274 insertions(+), 236 deletions(-) delete mode 100644 Deprecated_Example_DB.db diff --git a/.gitignore b/.gitignore index a7affa5..512c7a6 100644 --- a/.gitignore +++ b/.gitignore @@ -1,5 +1,4 @@ *.env /.venv -member_data.db -discord.log -SparkBot.log \ No newline at end of file +bot_data.db +bot.log \ No newline at end of file diff --git a/Deprecated_Example_DB.db b/Deprecated_Example_DB.db deleted file mode 100644 index e5af1a45b74f782d828265c9407b8ddfcf796960..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 8192 zcmWFz^vNtqRY=P(%1ta$FlG>7U}R))P*7lCU|*KW2n zaDnFJL4d#M-~kxdxMO4Iu}^;*764@p<&Cp}o&RFse11u4aY?+nnW3RU2#}=^3LH-` XGB7gHH89pSGF31%urjo;GBf}H?M+eq diff --git a/README.md b/README.md index 59e0f29..f424c35 100644 --- a/README.md +++ b/README.md @@ -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`. \ No newline at end of file +creates the SQLITE3 file `bot_data.db`. \ No newline at end of file diff --git a/env.dist b/env.dist index 59e1411..51c58eb 100644 --- a/env.dist +++ b/env.dist @@ -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 diff --git a/main.py b/main.py index 8af0460..1fbef29 100644 --- a/main.py +++ b/main.py @@ -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)