如何让 Discord 机器人异步等待对多条消息的反应?

How to Make a Discord Bot Asynchronously Wait for Reactions on Multiple Messages?

tl;博士 我的机器人如何异步等待对多条消息的反应?


我正在向我的 Discord 机器人添加剪刀石头布 (rps) 命令。用户可以调用命令可以通过输入 .rps 和可选参数来调用,指定要使用的用户。

.rps @TrebledJ

当被调用时,机器人将向调用它的用户和目标用户(通过参数)发送直接消息 (DM)。然后,这两个用户使用 ✊、 或 ✌️ 对他们的 DM 作出反应

现在我正试图让它异步工作。具体来说,机器人将向两个用户发送 DM(异步)并等待他们的反应(异步)。分步方案:

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.

(另见:注释 1)

由于目标是收听等待来自多个消息的反应,我尝试创建两个单独的 threads/pools。这是三个尝试:

不幸的是,这三个都没有成功。 (也许我实施不正确?)

以下代码显示了命令函数 (rps)、辅助函数 (rps_dm_helper) 和三个(不成功的)尝试。这些尝试都使用了不同的辅助函数,但底层逻辑是相同的。为方便起见,第一次尝试已取消注释。

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"```diff\n- RPS: {ctx.author} timed out\n```")
        return

    if opponent_emoji is None:
        await ctx.send(f"```diff\n- RPS: {opponent} timed out\n```")
        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"))


备注

1 异步场景与非异步场景对比:

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.

这并不难实现:

...
@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"```diff\n- RPS: {ctx.author} timed out\n```")
        return

    opponent_emoji = await rps_dm_helper(opponent, ctx.author)
    if opponent_emoji is None:
        await ctx.send(f"```diff\n- RPS: {opponent} timed out\n```")
        return

    ...

但是恕我直言,非异步会导致糟糕的用户体验。 :-)

您应该能够使用 asyncio.gather 安排多个协程同时执行。正在等待 gather 等待所有这些完成并 returns 他们的结果作为列表。

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)
    ...