tl;dr How can my bot asynchronously wait for reactions on multiple messages?
I’m adding a rock-paper-scissors (rps) command to my Discord bot. Users can invoke the command can be invoked by entering .rps
along with an optional parameter, specifying a user to play with.
.rps @TrebledJ
When invoked, the bot will direct-message (DM) the user who invoked it and the target user (from the parameter). The two users then react to their DM with either ✊, 🖐, or ✌️.
Now I’m trying to get this working asynchronously. Specifically, the bot will send DMs to both users (asynchronously) and wait for their reactions (asynchronously). A step-by-step scenario:
Scenario (Asynchronous): 1. User A sends ".rps @User_B" 2. Bot DMs User A and B. 3. User A and B react to their DMs. 4. Bot processes reactions and outputs winner.
(See also: Note 1)
Since the goal is to listen to wait for reactions from multiple messages, I tried creating two separate threads/pools. Here were three attempts:
multiprocessing.pool.ThreadPool
multiprocessing.Pool
concurrent.futures.ProcessPoolExecutor
Unfortunately, all three didn’t work out. (Maybe I implemented something incorrectly?)
The following code shows the command function (rps
), a helper function (rps_dm_helper
), and the three (unsuccessful) attempts. The attempts all make use of different helper functions, but the underlying logic is the same. The first attempt has been uncommented for convenience.
import asyncio import discord from discord.ext import commands import random import os from multiprocessing.pool import ThreadPool # Attempt 1 # from multiprocessing import Pool # Attempt 2 # from concurrent.futures import ProcessPoolExecutor # Attempt 3 bot = commands.Bot(command_prefix='.') emojis = ['✊', '🖐', '✌'] # Attempt 1 & 2 async def rps_dm_helper(player: discord.User, opponent: discord.User): if player.bot: return random.choice(emojis) message = await player.send(f"Playing Rock-Paper-Scissors with {opponent}. React with your choice.") for e in emojis: await message.add_reaction(e) try: reaction, _ = await bot.wait_for('reaction_add', check=lambda r, u: r.emoji in emojis and r.message.id == message.id and u == player, timeout=60) except asyncio.TimeoutError: return None return reaction.emoji # # Attempt 3 # def rps_dm_helper(tpl: (discord.User, discord.User)): # player, opponent = tpl # # if player.bot: # return random.choice(emojis) # # async def rps_dm_helper_impl(): # message = await player.send(f"Playing Rock-Paper-Scissors with {opponent}. React with your choice.") # # for e in emojis: # await message.add_reaction(e) # # try: # reaction, _ = await bot.wait_for('reaction_add', # check=lambda r, u: r.emoji in emojis and r.message.id == message.id and u == player, # timeout=60) # except asyncio.TimeoutError: # return None # # return reaction.emoji # # return asyncio.run(rps_dm_helper_impl()) @bot.command() async def rps(ctx, opponent: discord.User = None): """ Play rock-paper-scissors! """ if opponent is None: opponent = bot.user # Attempt 1: multiprocessing.pool.ThreadPool pool = ThreadPool(processes=2) author_result = pool.apply_async(asyncio.run, args=(rps_dm_helper(ctx.author, opponent),)) opponent_result = pool.apply_async(asyncio.run, args=(rps_dm_helper(opponent, ctx.author),)) author_emoji = author_result.get() opponent_emoji = opponent_result.get() # # Attempt 2: multiprocessing.Pool # pool = Pool(processes=2) # author_result = pool.apply_async(rps_dm_helper, args=(ctx.author, opponent)) # opponent_result = pool.apply_async(rps_dm_helper, args=(opponent, ctx.author)) # author_emoji = author_result.get() # opponent_emoji = opponent_result.get() # # Attempt 3: concurrent.futures.ProcessPoolExecutor # with ProcessPoolExecutor() as exc: # author_emoji, opponent_emoji = list(exc.map(rps_dm_helper, [(ctx.author, opponent), (opponent, ctx.author)])) ### -- END ATTEMPTS if author_emoji is None: await ctx.send(f"```diffn- RPS: {ctx.author} timed outn```") return if opponent_emoji is None: await ctx.send(f"```diffn- RPS: {opponent} timed outn```") return author_idx = emojis.index(author_emoji) opponent_idx = emojis.index(opponent_emoji) if author_idx == opponent_idx: winner = None elif author_idx == (opponent_idx + 1) % 3: winner = ctx.author else: winner = opponent # send to main channel await ctx.send([f'{winner} won!', 'Tie'][winner is None]) bot.run(os.environ.get("BOT_TOKEN"))
Note
1 Contrast the asynchronous scenario to a non-asynchronous one:
Scenario (Non-Asynchronous): 1. User A sends ".rps @User_B" 2. Bot DMs User A. 3. User A reacts to his/her DM. 4. Bot DMs User B. 5. User B reacts to his/her DM. 6. Bot processes reactions and outputs winner.
This wasn’t too hard to implement:
... @bot.command() async def rps(ctx, opponent: discord.User = None): """ Play rock-paper-scissors! """ ... author_emoji = await rps_dm_helper(ctx.author, opponent) if author_emoji is None: await ctx.send(f"```diffn- RPS: {ctx.author} timed outn```") return opponent_emoji = await rps_dm_helper(opponent, ctx.author) if opponent_emoji is None: await ctx.send(f"```diffn- RPS: {opponent} timed outn```") return ...
But IMHO, the non-async makes for bad UX. :-)
Advertisement
Answer
You should be able to use asyncio.gather
to schedule multiple coroutines to execute concurrently. Awaiting gather
waits for all of them to finish and returns their results as a list.
from asyncio import gather @bot.command() async def rps(ctx, opponent: discord.User = None): """ Play rock-paper-scissors! """ if opponent is None: opponent = bot.user author_helper = rps_dm_helper(ctx.author, opponent) # Note no "await" opponent_helper = rps_dm_helper(opponent, ctx.author) author_emoji, opponent_emoji = await gather(author_helper, opponent_helper) ...