how run a loop without stopping the rest of the script in discord.py

Viewed 229

im trying to make a discord bot that acts like a minecraft server console, however, and i feel like this is a very basic problem I want the bot to keep sending the output of a Minecraft server and also write inputs via messages in a specific channel, but of course, if you have a loop running the rest of the program doesn't run, I've tried multiprocess and multithreading but it didn't work because of the asynchronous functions. this is me code:

minecraft_dir = r"C:\Users\Pablo\Desktop\1.18 server - Copy"
executable = r'java -Xms4G -Xmx4G -jar "C:\Users\Pablo\Desktop\1.18 server - Copy\server.jar" java'
process = None

async def start_serv(msg):
  os.chdir(minecraft_dir)
  process = subprocess.Popen(executable,stdin=PIPE,stdout=PIPE, text=True)
  for line in process.stdout:
    await msg.channel.send(line)

def serv_cmd(cmd):
  if cmd == "stop":
    process = None
  cmd = cmd + "\n"
  cmd = cmd.encode()
  process.stdin.write(cmd)
  process.stding.flush()

@client.event
async def on_message(message):
  global process
  if message.author.id != client.user.id and message.channel.name == "mc-server-console":
    command = message.content
    command=command.lower()
    if command == "start":
      if process == None:
        await message.channel.send("yessir")
    if process != None:
      serv_cmd(command)

here is my multithreading attempt:

async def start_serv():
  os.chdir(minecraft_dir)
  process = subprocess.Popen(executable,stdin=PIPE,stdout=PIPE, text=True)
  for line in process.stdout:
    await channel.send(line)
    time.sleep(0.1)

def serv_cmd(cmd):
  if cmd == "stop":
    process = None
  cmd = cmd + "\n"
  cmd = cmd.encode()
  process.stdin.write(cmd)
  process.stding.flush()

def main():
  @client.event
  async def on_message(message):
    global process
    if message.author.id != client.user.id and message.channel.name == "mc-server-console":
      command = message.content
      command=command.lower()
      if command == "start":
        if process == None:
          await message.channel.send("yessir")
          thread2.start()
      if process != None:
        serv_cmd(command)

thread1 = threading.Thread(target=main)
thread1.start()
thread2 = threading.Thread(target=start_serv)

if anyone knows a way to only send messages when the output of the server updates, I think that would work too.

1 Answers

Threading actually isn't a good move here. Most of the time Threads with discord.py aren't going to be "thread safe." You could mess around with Lock's but it's easier to avoid them as much as possible. Please note this is a prototype as I have not tested any of this (I don't have MC). I would take it as a concept and use it to guide you

Let's walk through a way to do what you want while not blocking the event loop.

import os
import subprocess
import aiohttp
import discord
from discord.ext import commands
from typing import TYPE_CHECKING, Generic, TypeVar, Any

if TYPE_CHECKING:
    from asyncio import AbstractEventLoop
    
B = TypeVar('B', bound='commands.Bot')

# We'll make a class caleld MinecraftRunner
# to hold all minecraft based operations
    
class MinecraftRunner(Generic[B]):
    def __init__(self, bot: B) -> None:
        self.bot: commands.Bot = bot
        self.loop: AbstractEventLoop = bot.loop
        self.session: aiohttp.ClientSession = getattr(bot, 'session', aiohttp.ClientSession())
        
        self.minecraft_dir: str = r"C:\Users\Pablo\Desktop\1.18 server - Copy"
        self.executable: str = r'java -Xms4G -Xmx4G -jar "C:\Users\Pablo\Desktop\1.18 server - Copy\server.jar" java'
        
        # We'll use webhooks to send to the channel instead of `channel.send`. 
        # This will not rate-limit our bot.
        self.webhook_url: str = '' 
        
        # Using loop.create_task will allow us to not block
        # the main event loop.
        self.runner_task = self.loop.create_task(self.main_loop())
        
    async def send_to_webhook(self, *args, **kwargs) -> discord.WebhookMessage:
        if not (webhook := getattr(self, 'webhook', None)):
            self.webhook = webhook = await (discord.Webhook.from_url(self.webhook_url, session=self.session, bot_token=self.bot.http.token)).fetch()

        return await webhook.send(*args, **kwargs)
    
    async def main_loop(self) -> None:
        await self.bot.wait_until_ready() # Don't do anything until our bot is ready to do work.
        
        os.chdir(self.minecraft_dir)
        self.process = process = subprocess.Popen(self.executable,stdin=subprocess.PIPE,stdout=subprocess.PIPE, text=True)
        
        for line in process.stdout:
            await self.send_to_webhook(line)
    
# Let's put our on_message into a simple cog as well
class MinecraftListener(commands.Cog):
    def __init__(self, bot: commands.Bot) -> None:
        self.bot: commands.Bot = bot
        self.minecraft_runner = MinecraftRunner(bot)
        
    def cog_unload(self) -> None:
        self.minecraft_runner.runner_task.cancel()
        
        try:
            delattr(self.minecraft_runner, 'runner_task')
        except AttributeError:
            pass
        
    async def send_command(self, command: str) -> None:
        command += '\n'
        command.encode()
        self.minecraft_runner.process.stdin.write(command)
        self.minecraft_runner.process.stdin.flush()
    
    @commands.Cog.listener()
    async def on_message(self, message: discord.Message) -> Any:
        if message.author.bot: # This message was sent from a bot.
            return
        if message.webhook_id: # This message is a webhook message.
            return
        
        command = message.content.lower()
        channel = message.channel
        
        if command == 'start':
            if not hasattr(self.minecraft_runner, 'runner_task'):
                self.minecraft_runner.runner_task = self.minecraft_runner.loop.create_task(self.minecraft_runner.main_loop())
                return await channel.send('The process has been started.')
            
            return await channel.send('The process is already started.')
                
        elif command == 'stop': # Stop the process
            if not hasattr(self.minecraft_runner, 'runner_task'):
                return await channel.send('The process is not running!')
            
            return self.cog_unload()
        
        # This isn't a custom command, send it to the minecraft process
        await self.send_command(command)
Related