涼しい風

Discord.pyでdiscordbotを作っていた人のブログです。開発のお役に立てれば幸いです。2019/11/10を持って更新は終了しました。

Discord上でリバーシができる「Discord Reversi」

Discord上のゲームBOTには様々なものがありますが、私はリバーシを作成することにしました。

リバーシといっても表立ってオセロというのは権利的に良くない気がしただけで、中身はほぼオセロです。)



- BOTの招待リンクはこちらからどうぞ

discordapp.com


プレイ風景

f:id:coolwind0202:20191110151013p:plain
8×8(通常)

f:id:coolwind0202:20191110150925p:plain
6×6(変則)

遊び方

BOTを上記リンクから招待

② 遊びたいチャンネルで .start メンション f:id:coolwind0202:20191110151147p:plain

.put x座標 y座標 で自分の石を置く位置を決定

④ 以降、これの繰り返しで勝敗を決定

コマンドの一覧

  • .start メンション 対局を開始します。
  • .start メンション 6 6×6の変則対局を開始します。
  • .put x y (x,y)に該当するマスに自分の石を配置します。
  • .info 現在の盤面の状況を確認します。
  • .retire 対局を強制的に終了します。
  • .about BOT自身の情報(参加サーバー数、招待リンクなど)を表示します。

  • .restart メンション 対局が異常終了したとき、直前に対局を行っていた相手にメンションすることで対局の再開を試みます。

補足・注意点

  • BOTの利用は無料です。
  • 必要な権限は「メッセージ履歴を読む」「メッセージの送信」「ファイルの添付」「埋め込みリンク」です。
  • メッセージの送信以外にサーバーを操作することはありません。
  • 開発言語にはPython、ラッパーにはDiscord.py を使用しています。

  • 現在Herokuの無料プラン(クレジットカード未登録)で構築しているため、月末にBOTの動作が停止する可能性があります。

  • BOTの停止は翌月1日から復旧します。
  • オセロ・Othello登録商標です
  • TM&© Othello,Co. and MegaHouse

公式サポートサーバー

このBOT用の公式サーバーを用意しています。 不明な点やバグについては #質問・不具合報告 で、また #play-1#play-2 …で実際に対局を試すこともできます。

最後に

運営開始から執筆時点で2週間が経過しました。 より多くの人に遊んでいただければ幸いです。

Discord.pyでリンク展開Botを作る

Discord公式APIのラッパー、Discord.py を使って、テキストチャンネルに投稿されたメッセージリンクを展開するBotを作成します。

リンク展開Botとは

PC版ではメッセージをクリックすると表示されるボタンから「リンクをコピースマートフォンではメッセージを長押しし表示されるボタンから「共有

でDiscord上のメッセージリンクを取得することができます(OSとかで微妙に違うかもしれない)。

このリンク文字列からメッセージ内容を取得し、それを別途投稿するBotをここではリンク展開Botと定義します。 メッセージリンクはDMチャンネルを除いて以下の形式になります。

https://discordapp.com/channels/[サーバーID]/[チャンネルID]/[メッセージID]

実装

ここでは正規表現を使って実装しようと思います。 Pythonにおける正規表現の処理では re モジュールを使うことができます。 re.findall( 正規表現 , 文字列 ) で一致する全ての文字列を取得することができます。 正規表現についての詳細はここでは省きます。

import discord
import re
client = discord.Client()

@client.event
async def on_message(message):
    url_re = r"https://discordapp.com/channels/(\d{18})/(\d{18})/(\d{18})"
    url_list  = re.findall(url_re,message.content)
    
    for url in url_list:
        guild_id,channel_id,message_id = url
        channel = client.get_channel(int(channel_id))

        if channel is not None:
            got_message = await channel.fetch_message(message_id)

            if got_message is not None:
                await message.channel.send(embed=open_message(got_message))
        
def open_message(message):
    """
    メッセージを展開し、作成した埋め込みに各情報を添付し返す関数

    Args:
        message (discord.Message) : 展開したいメッセージ

    Returns:
        embed (discord.Embed) : メッセージの展開結果の埋め込み
    """

    embed = discord.Embed(title=message.content,description=f"[メッセージリンク]({message.jump_url})",color=0x7fbfff)

    embed.set_author(name=message.author.display_name, icon_url=message.author.avatar_url) #メッセージ送信者
    embed.set_footer(text=message.guild.name, icon_url=message.guild.icon_url) #メッセージのあるサーバー
    embed.timestamp = message.created_at #メッセージの投稿時間

    if message.attachments:
        embed.set_image(url=message.attachments[0].url) #もし画像があれば、最初の画像を添付する
    return embed

client.run("your token here")

[Discord.py]チャンネルの権限設定方法まとめ

Discord公式APIのラッパー、Discord.py で、テキストチャンネルやボイスチャンネルに対してどのように権限設定を行うのかをまとめました。

PermissionOverwriteオブジェクト

Discord.pyには、権限の上書き設定を行うためのデータクラスが存在します。先に紹介しておきます。

その名もdiscord.PermissionOverWrite

普通、チャンネル内の権限はサーバーにおけるロールの設定やカテゴリ内の権限設定に同期されますが、 それを上書きするために必要なクラスになります。

discordpy.readthedocs.io

そして、この PermissionOverwriteインスタンスを作成する際に、どの権限を許可するか示したキーワード引数を渡す事ができます。

overwrite = discord.PermissionOverWrite(read_messages=True, send_messages=True, embed_links=True)

このコードでは、「メッセージを読む」「メッセージの送信」「埋め込みリンクの送信」の3つの権限が許可された discord.PermissionOverWriteインスタンスを作成しています。


設定可能な権限名の一覧については以下の項目の create_intant_invite 以降の見出しから参照できます。

discordpy.readthedocs.io

チャンネル作成時に設定

本題です。

チャンネル作成には guild.create_text_channel などを使用しますが、これの overwrites 引数に、メンバーや役職がキー、その権限設定が値の辞書を渡す事が出来ます。

discordpy.readthedocs.io

import discord
client = discord.Client()

@client.event
async def on_message(message):
    if message.content == "!create":
        overwrites = { 
            message.guild.default_role: discord.PermissionOverwrite(read_messages=False),
            message.author: discord.PermissionOverwrite(read_messages=True)
        }

        await message.guild.create_text_channel("new_channel", overwrites=overwrites)

client.run("your token here")

このコードでは、もし !create という内容のメッセージが送信されたら、そのサーバー内に new_channel という名前の、@everyone はメッセージを読むことができず、!create の送信者のみがメッセージを読むことができるチャンネルを作成しています。

ただし、サーバーの管理者は必ず全ての権限を保持していますし、@everyone以外の役職に「メッセージを読む」権限が与えられていればこの限りではありません。

既存のチャンネルに対して設定

既存のチャンネルに対しては、set_permissions で権限設定することができます。

discordpy.readthedocs.io

ただし、チャンネル作成における overwrites 引数とは使い方が異なるので注意が必要です。

@client.event
async def on_message(message):
    if message.content == "!secret":
        overwrite = discord.PermissionOverwrite(read_messages=False)
        await message.channel.set_permissions(message.guild.default_role,overwrite=overwrite)

set_permissions メソッドは、権限設定したいターゲットを第一引数に指定し、その後に overwrite あるいは各権限を直接指定します。

各権限を直接指定する例も見てみましょう。

@client.event
async def on_message(message):
    if message.content == "!open":
        await message.channel.set_permissions(message.guild.default_role,read_messages=True,send_messages=True)

このコードでは、メッセージの送信されたチャンネルを全員が閲覧、投稿できるようにしています。


この記事ではチャンネルの権限設定方法をまとめました。活用いただければ幸いです。

Discord BOTはサーバーを共有していないユーザーに対して何が出来るか?

Discordはサーバーによってコミュニティが分割されているという特徴があります。

これによりユーザーは相手のユーザー名やユーザーIDを知らなければ相手にアクションすることが出来ません。

しかし、逆に言えばユーザー名やユーザーIDさえ分かってしまえば相手の情報にアクセスすることは可能なはずです。

そこでこの記事では、Discord BOTがサーバーを共有していないユーザーに対して何ができ、そして何ができないのかを分析します。

ただし、


この記事は特定ユーザーに嫌がらせするためのものではありません。

あくまでも開発の際に必要な情報として共有するまでです。

ユーザー情報の取得

Discord公式APIのラッパーの一つ、Discord.pyでは以下の方法でユーザーのデータにアクセスできます。

import discord
client = discord.Client()

shared_user = client.get_user(ユーザーID) #サーバーを共有している場合
not_shared_user = await client.fetch_user(ユーザーID) #サーバーを共有していない場合

client.run("your token here")

サーバーを共有していないユーザー

サーバーを共有していないユーザーに対しては、基本的に以下の操作が行えます(例外が発生しない)。
※行頭にawaitとあるものはコルーチン、行末に()とあるものはメソッドです。それ以外は属性です。

await create_dm()
await pins()
async for history()
await trigger_typing()
async with typing()
relationship
avatar
avatar_url
avatar_url_as()
bot
color
colour
created_at
default_avatar
default_avatar_url
discriminator
display_name
dm_channel
id
is_avatar_animated
is_blocked
is_friend
mention
name

以下の操作は許可されていないか、例外が発生します。

await send()
permissions_in()
mentioned_in()

以下の操作はBOTでの使用が許可されていません。

await block()
await send_friend_request()
await mutual_friends()
await remove_friend()
await unblock()
await profile()

以下の操作は実行できますが、基本的に例外のみが発生します。

await fetch_message()

なお内部的にはあと4つ、ラッパー側で用意された属性が存在するようです。

サーバーを共有しているメンバーに対しては、上記で

  • 以下の操作は許可されていないか、例外が発生します。
  • 以下の操作は実行できますが、基本的に例外のみが発生します。

となっている操作を正常に実行できます(普通は)

Discord.pyでリアクションで遷移できるヘルプコマンドを作る

Discord公式APIのラッパー、Discord.py で、ヘルプコマンドを送信するとヘルプが表示され、リアクションを押すと他のページに移動できる形式のヘルプコマンドを実装してみます。

追記:一部処理が抜けていたので修正しました (2019/11/07)

実装の前に

必要なのは以下の動作です。

  1. helpコマンドを受信する
  2. ヘルプを送信する
  3. リアクションを付ける
  4. リアクションが新たに付くのを待つ
  5. ヘルプを書き換える

実装

import discord

client = discord.Client()

@client.event
async def on_message(message):
    if message.content == "!help":
        page_count = 0 #ヘルプの現在表示しているページ数
        page_content_list = ["ヘルプコマンドです。\n➡絵文字を押すと次のページへ",
            "ヘルプコマンド2ページ目です。\n➡絵文字で次のページ\n⬅絵文字で前のページ",
            "ヘルプコマンド最後のページです。\n⬅絵文字で前のページ"] #ヘルプの各ページ内容
        
        send_message = await message.channel.send(page_content_list[0]) #最初のページ投稿
        await send_message.add_reaction("➡")

        def help_react_check(reaction,user):
            '''
            ヘルプに対する、ヘルプリクエスト者本人からのリアクションかをチェックする
            '''
            emoji = str(reaction.emoji)
            if reaction.message.id != send_message.id:
                return 0
            if emoji == "➡" or emoji == "⬅":
                if user != message.author:
                    return 0
                else:
                    return 1

        while not client.is_closed():
            try:
                reaction,user = await client.wait_for('reaction_add',check=help_react_check,timeout=40.0)
            except asyncio.TimeoutError:
                return #時間制限が来たら、それ以降は処理しない
            else:
                emoji = str(reaction.emoji)
                if emoji == "➡" and page_count < 2:
                    page_count += 1
                if emoji == "⬅" and page_count > 0:
                    page_count -= 1

                await send_message.clear_reactions() #事前に消去する
                await send_message.edit(content=page_content_list[page_count])

                if page_count == 0:
                    await send_message.add_reaction("➡")
                elif page_count == 1:
                    await send_message.add_reaction("⬅")
                    await send_message.add_reaction("➡")
                elif page_count == 2:
                    await send_message.add_reaction("⬅")
                    #各ページごとに必要なリアクション

client.run('token')

Discord.py ドキュメントの歩き方

Discord公式APIのラッパー、Discord.py には当然公式のドキュメントが存在します。そして有志の手によって日本語訳も行われています。

しかし、その翻訳率は50%を下回っています(2019/10/1現在)。

そこで個人的にドキュメントの読み方をまとめました。

メッセージが送信されたギルドのチャンネル数を調べる例

先に答えを言っておきましょう。

len(discord.Message.guild.channels)

です。

まず、

import discord

client = discord.Client()

@client.evnt
async def on_message(message):
    #処理

client.run(token)

このような単純なコードがあるとします。

on_message関数の引数になっているmessageですが、名前からdiscord.Messageクラスのクラスインスタンスであることがわかりますね。

discordpy.readthedocs.io

discord.Messageクラスのドキュメントの一つ上のレベルの見出しは「Discordモデル」となっていますね。これは文章の通りDiscord側から受け取ったクラスです。

discord.Message → サーバー?

それではdiscord.Messageにサーバー(APIにおいてはGuildと表現されます)の情報が含まれているか探します。

f:id:coolwind0202:20191001205651p:plain
guild属性を発見

少し下の方にありました。まだ翻訳が行われていませんが、文章をふんわり訳すと以下の内容です。

メッセージがギルドに属しているなら、そのGuildを表します。

そしてその直下に、

Type - optional[https://discordpy.readthedocs.io/ja/latest/api.html#discord.Guild]

と書かれています。Typeはそのままタイプ、プログラミング用語でいう「」「クラス」のことです。

この場合、guild属性はGuildクラスのようです、青文字で表示されたGuildをクリックすると、実際にdiscord.Guidクラスのドキュメントにリンクしていると思います。

しかし、optionalとはなんでしょう?

optionalは、その属性が存在するかもしれないし、Noneであるかもしれない…という意味です(ざっくり)。

メッセージはDMに送信される場合もありますからね。DMには属するサーバーが存在しません。

discord.Guild → チャンネルの一覧?

メッセージからそれが送信されたサーバー(Guild)を取得することができました。

次はサーバーに属するチャンネル数を取得したい所です。

discord.Guildを参照してみましょう。

discordpy.readthedocs.io

うーん、チャンネル数を直接取得することはできないようです、メンバーならmember_count属性が存在しますが…

しかし、チャンネルのリストなら取得できるようです。

f:id:coolwind0202:20191001211847p:plain

説明欄は英語ですが、またふんわり訳すと以下のような内容です。

そのギルドに属するチャンネルのリストを返します。

リストって何?と思った方は先にpythonの文法について簡単に学習してから読むと理解が早いと思います。

Type見出しを見てみると、

List[ https://discordpy.readthedocs.io/ja/latest/api.html#discord.abc.GuildChannel ]

とあります。どうやらabc.GuildChannelクラスのインスタンスのリストのように見えますが、abcとは何でしょう?

オブジェクト指向についてご存知の方はお分かりだと思います、抽象クラスです(抽象基底クラス)。

抽象基底クラスは、「他のクラス」に継承されることが前提のクラスです。この「他のクラス」とは何なのか。その答えがリンク先にあります。

List[abc.GuildChannel] → チャンネル数を取得

f:id:coolwind0202:20191001212753p:plain
abc.GuildChannelのリンク先

discordpy.readthedocs.io

何やら難しそうな文章ですね。ふんわり訳します。

このABC(抽象基底クラス)は、Discordにおけるサーバーのチャンネルに対する操作を取得するためのものです。 以下のクラスはこのABCに実装しています。 - TextChannel - VoiceChannel - CategoryChannel このABCはsnowflakeも実装している必要があります。

更にかみ砕くと、つまりdiscord.TextChannelなどのクラスは、このabc.GuildChannelを継承し、機能を受け継いだたもの、ということです。

また、abc.GuildChannelはSnowFlakeを実装しているとありますが、SnowFlakeとは、idcreated_at、この二つの属性を持った抽象基底クラスです。

ちなみにこのSnowFlakeほぼ全てのDiscordモデルに該当するクラスです。さっき出てきたGuildも、Memberも、全てSnowFlakeクラスを継承しています。


少し脱線してしまいました。チャンネルの数を取得して終わりにしましょう。

リストの要素数は組み込み関数であるlen()で取得できますね。

@client.event
async def on_message(message):
    print(len(message.guild.channels))

ところで、channelsはギルド内のabc.GuildChannelを満たす全てのチャンネルを取得するわけですから、これにはチャンネルカテゴリも含まれますね。

チャンネルカテゴリはdiscord.CategoryChannelクラスのインスタンスです、必要であれば内包表記で除いてみましょう。

new_list = [ch for ch in message.guild.channels if not isinsance(ch,CategoryChannel)]
print(new_list)

Discord.pyでチーム分けを自動化

Discord公式APIのラッパー、Discord.py でゲーム内でありがちなランダムなチーム分けを自動で行えるようにしてみます。

もしコードが動かないなどありましたらお知らせください

準備

メッセージ送信者のが参加しているボイスチャンネルの参加者をランダムで割り振りましょう。 ランダム部分は標準モジュールのrandomを使って実装します。

ボイスチャンネルの取得

わかる人は飛ばしていいよ。

import discord

client = discord.Client()

@client.event
async def on_message(message):
    if message.content == "!voicestate":
        print(message.author.voice)
        #送信者のボイスの状況がコンソールに表示される。もしボイスチャンネルにいなければ ''Noneを返す''。

    if message.content == "!voicechannel":
        if message.author.voice is not None:
            print(message.author.voice.channel.members)
            #送信者がボイスチャンネルにいれば、そのメンバーのリストが表示される。

コードの流れを真面目に解説すると、 message.authorはdiscord.Memberのクラスインスタンスに該当します。 https://discordpy.readthedocs.io/ja/latest/api.html#member ドキュメントを見ると、voice属性を参照できるようです。これはVoiceStateクラスオブジェクトを返します。 なお、ドキュメントのoptional(オプション)とは、Noneなどの値になる可能性があるということです。 f:id:coolwind0202:20191001175224p:plain

今度はVoiceStateクラスを見てみます。channel属性を参照できるようです。 f:id:coolwind0202:20191001175509p:plain

channel属性、つまりVoiceChannelからメンバーのリストを取得できます。 f:id:coolwind0202:20191001175826p:plain

message.author.voice.channel.members

ランダムな整数リストを生成

ここでは、事前に整数のリストを生成しておき、それをランダムに並べ替え、その各要素にメンバーを当てはめていくように実装してみます。

例えば、人数nに対してmチーム分のリストを生成します。チームごとの人数はn/mで求められるはずです。

import math

n = 8 #人数
m = 2 #チーム数

tmp = math.floor(n/m)

l = [team for team in range(m) for n in range(tmp)]
print(l)

始めたばかりの人には見慣れない書き方かもしれません。リスト内包表記と言います。

tmpは各チームの人数、各チームごとに人数をカウントアップし、チームの番号をリストに格納してみました。

しかし、実行してみると分かるのですが、これでは人数がチーム数で割り切れないときでも不足を補えません。

l += [i for i in range(n-len(l))]

nは人数で、len(l)は現在リストに追加されている人数ですから、その差が不足分と考えられます。その不足分をチーム番号としてこのように追加しておきましょう。

実際に8人に対して3チームと設定すれば[0, 0, 1, 1, 2, 2, 0, 1]というリストが生成されるはずです。


こうしてできた整数のリストをランダムに並べ替えましょう。

ランダムに並べ替えるには、randomモジュールの機能であるshuffleを使用します。

result = random.shuffle(l)

実装

ランダムなリストが生成できました。最後に各メンバーに割り当てます。

import discord
import math
import random

client = discord.Client()

@client.event
async def on_message(message):
    if message.content.startswith("!team"):
        args = message.content.split(' ')

        if len(args)>1:
            await message.channel.send("チーム数を入力してください。") 
            return

        if message.author.voice is None:
            await message.channel.send("先にボイスチャンネルに接続してください。")
            return

        member_list = message.author.voice.channel.members
        n = len(member_list) #ボイスチャンネルの参加人数
        m = int(args[1]) #空白区切りのチーム数を取得

        tmp = math.floor(n/m)
        l = [team for team in range(m) for n in range(tmp)] #整数リスト
        l += [i for i in range(n-len(l))] #不足分補正
        result = random.shuffle(l) #シャッフル

        reply = "" #メッセージ
        c_num = ord("A")
       
        for num,member in enumerate(member_list):
            team_chr = chr(c_num + result[num])
            reply += f"{member.name} さん / {team_chr}チーム"

        await message.channel.send(reply)

client.run(token)

メンバーをボイスチャンネルに移動

このコードではボイスチャンネルから参加メンバーを取得しているわけですから、各メンバーをボイスチャンネルに移動させたくないですか?

ボイスチャンネルでの指定はBot Commands Frameworkで簡単に行えるので、そちらを併用します。

コードのみの掲載となりますが、以下の記事でチャンネル名の変換、メンバーの移動を解説しています。

import discord
from discord.ext import commands
import math
import random

bot = commmands.Bot(command_prefix="!")

@bot.command
async def team(ctx,m:int,*args):
    if len(args)==0:
        await ctx.send("移動先のチャンネル名を指定してください。")
        return

    if ctx.author.voice is None:
        await ctx.send("先にボイスチャンネルに入ってください。")
        return

    member_list = ctx.author.voice.channel.members

    ch_convert = commands.VoiceChannelConverter()
    ch_list = []

    for arg in args:
        ch = await ch_converter.convert(ctx, arg)
        ch_list.append(ch)
        member_list += ch.members

    n = len(members) #ボイスチャンネルの参加人数

    tmp = math.floor(n/m)
    l = [team for team in range(m) for n in range(tmp)] #整数リスト
    l += [i for i in range(n-len(l))] #不足分補正
    result = random.shuffle(l) #シャッフル

    reply = ""

    for i in result:
        member = member_list[i] #操作するメンバー
        await member.move_to(ch_list[i]) #チャンネルリストのi番目に送り込む

    c_num = ord("A")

    for a in l:
        team_chr = chr(c_num + a)
        reply += f"{member_list[a].name} さん / {team_chr}チーム"

    await ctx.send(reply)
    await ctx.send("Succeeded!")