DiscordのBotをPythonで無料で作った話

https://www.youtube.com/watch?v=SPTfmiYiuok
freeCodeCamp.orgが提供しているBot作成の動画を、自分でも試してみました。

今回作りたいBotは、このようなものです。
•通知を送る特定の人物を、テキストチャンネルからコマンドで追加・削除できる。
•ボイスチャンネルに誰かが入室すると、テキストチャンネルに、特定の人物に通知を送る。
•Magic; the Gatheringの裁定をWebページ上から取得し、それをDeepLで和訳したものを表示する。

ReplitからBotを作成することができます。

#DiscordのBot用のアカウントを作成する。
https://discord.com/developers/applications
Developer Portalに行き、Bot用のアカウントを作ります。

![NewApplication.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/1851956/bdd93816-49b9-4184-958d-fcff742ec98a.png)

New Applicationをクリックします。

![NameCreate.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/1851956/919dc611-e8f4-f7bc-e5bb-e82d8bf9af9a.png)

BotのNameを決めてから、Createを押します。

![GenealInformatino.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/1851956/20e2db71-100e-67f0-8984-4609c056e611.png)

そうすると、BotのGeneral Information（一般情報）が得られます。

![addbot.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/1851956/d9f9b70d-3345-eda6-0982-208a2b1a46b5.png)

左のメニューのBotを押し、Add Botで、ボットに命を吹き込みます。

![irrevocable.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/1851956/f008dcef-3e74-46b1-1bae-b0514d6f2baf.png)

「この手続きは取り返しがつかない」と注意されます。熟慮の後に、Yes, do it!を選びます。

![野生のボットが現れた.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/1851956/caf964f0-0358-012f-07ac-c5bde83d564b.png)

野生のBotが現れた！（A wild bot has appeared!）

続けて、Botの設定を進めていきます。

Botをサーバーに招くには、invite URLを作らねばなりません。
そのために、OAuth2をクリックします。
![OAuth2.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/1851956/85cf9fb7-04f1-462a-880d-bf47ea4e3ce5.png)


![scope_bot.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/1851956/9ddcec99-0f04-7a7f-f315-78083d71db40.png)

画面を下にスクロールし、Scopesを設定します。
Botを作っているので、botを選択します。
botを選択すると、下にURLが現れます。

![BOTS_PERMISSIONS.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/1851956/e4b5c204-059f-f0ef-b70e-cb8f9e84bde7.png)

Botに与える権限(Permissions)を設定します。
今回作りたいBotは、このようなものです。
•通知を送る特定の人物を、テキストチャンネルからコマンドで追加・削除できる。
•ボイスチャンネルに誰かが入室すると、テキストチャンネルに、特定の人物に通知を送る。
•Magic; the Gatheringの裁定を和訳したものを表示する。
適切な権限を与えていきます。
自作のサーバーで自作のBotを動かすなら、全部のPermissionsを与えてもいいんじゃないでしょうか。

![URL_copy.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/1851956/5ba91fb2-caca-d3cf-802b-f5f453872a35.png)

BotのPermissionsを設定し終えたら、Scopes直下に表示されているURLをCopyします。

![INVITE_BPT.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/1851956/c2e9a8ae-c9c0-c50a-a6e0-60ce3a76258a.png)
さあ、Botをサーバーに招待しましょう！
ブラウザから先ほどのURLに行き、好きなサーバーに追加します。


![認証しました.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/1851956/300b275d-a9d4-4ab6-9d69-c7fd3b371e81.png)

認証しましたの画面まで進むと、Botがサーバーに登録されます。
ただし、まだオフラインです。

![オフライン.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/1851956/b30aa1fc-ee44-a9ac-97f0-a3c08f87dc57.png)

#Botをreplitからオンラインにする

では、どうやってオンラインにするのでしょうか？
オンラインにするために今回は、replit上でpythonを動かします。

![replit.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/1851956/6180d40e-b8a8-7351-9cad-b50273d8443a.png)

https://replit.com/
replitにサインイン後、ログインしましょう。
replitは、無料で使えるオンラインIDEです。
自分のPCをシャットダウンしても、replit上でBotを動かし続けられます。

![NewRepl.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/1851956/c7d56e19-ff36-8b21-7e3d-cc7f8ee280bb.png)

Ξマーク、+New replの後、言語を選択します。
私はPythonを選択します。
有料のUpgradeをしない限り、replがpublicで作成されることに注意してください。

![NamedAndCreate.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/1851956/dfc0bcb0-2719-8a4f-b007-4e9a67bca48d.png)
名前を入力してから、Create replを押します。

![IDEOPENED.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/1851956/edb2403f-bafc-dbfe-d8cb-73f2d006887c.png)
IDEの画面が開きました。


![testcode.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/1851956/a9885f76-9c50-57cd-6183-cd96c72b4e34.png)
試しに何かコードを書いてみましょう。
真ん中にコードを書き、上のRunを押すと、右のConsoleに標準出力が表示されます。


![secrets.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/1851956/83340e31-2bce-33c5-9a60-fd273322d4fa.png)
replitは無料だとpublicになります。
そのため、keyやpasswordのようなものは、main.pyには書かず、環境変数として非公開にする必要があります。

左の錠のマークから、環境変数を作成します。

![TOKEN.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/1851956/e5971997-fba1-4006-b12a-0221cbe64ff1.png)

Discord Developer Portalから取得したTOKENを登録します。


![add_new_secret.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/1851956/fcee8442-af67-8e5a-cde4-c58e07e01f0f.png)
キーの名前と、その値を入力して、Add new secretします。

Clientクラスは、Discordに接続するクライアント接続を表します。Clientクラスのインスタンスを作り、それをいじっていきます。

Botを作るためには、discordというライブラリを使います。
https://discordpy.readthedocs.io/ja/latest/index.html

```py
# discordのボットに必要なライブラリ
import discord
# 環境変数を読み込むのに必要なライブラリ
import os

# 環境変数から、DISCODE_TOKENを取得
discord_token = os.environ['DISCORD_TOKEN']

# Clientのインスタンスを作成
client = discord.Client()

# ログインしたときに、コンソールにメッセージを表示する。
@client.event
async def on_ready():
    print(f'{client.user}としてログインしました。')

# Discordに接続する。
client.run(discord_token)

```

![login_on_repl.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/1851956/02b4cd62-05fa-4937-3b79-5bcffeca3461.png)
![login_on_discord.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/1851956/a7539248-f275-4b6f-6529-e6f2a9383179.png)
ReplitのコンソールとDiscordの画面の両方でログインが確認できました。

#通知を送る特定の人物を、テキストチャンネルからコマンドで追加・削除する。

```py

# メンション先を集合として用意します。
# このBotは、個人か使用する1つだけのサーバーで使用するのでこの方法で問題ありません。
# しかし、複数のサーバーでこのBotを使用すると、メンション先を1つの集合で管理しているので、不具合が出ます。
mention_to = set()

@client.event
async def on_message(message):
    # globalを使って、関数の外の変数を書き込めるようにします。
    global mention_to

    # メッセージが「$add_me」から始まる場合
    if message.content.startswith('$add_me'):
      # message.author.id:　メッセージを書いた人のID。int。
      new_user = message.author.id
      # メンション先を保存している集合に、new_userを加える。
      mention_to.add(new_user)

      #msg:　ボットが書き込むメッセージ
      msg = ""
      # メンション先を、ボットが書き込むメッセージに加えます。
      for user in mention_to:
        # Discordのメンションは、<@!{ユーザーIDを表す整数}>で飛ばすことができます。
        msg += f'<@!{user}> '

      msg += f'{message.author}がメンション先に加わりました。'
      # on_massage関数の引数として与えられたmessageのチャンネル（channel）に、msgを送る(send) 
      await message.channel.send(msg) 

    # メッセージが「$remove_me」から始まる場合
    if message.content.startswith('$remove_me'):
      old_user = message.author.id

      if old_user in mention_to:
        mention_to.remove(old_user)

        msg = f'\nあなたをメンション先から削除しました。'
        await message.channel.send(msg) 

      else:
        await message.channel.send(f'あなたはメンション先として登録されていないため、メンション先から削除できません。')

```

![add_remove_me.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/1851956/6d57004d-3ea2-98b3-ac3f-18e90aeb0b01.png)
このようになります。

#ボイスチャンネルに誰かが入室すると、テキストチャンネルに、特定の人物に通知を送る。

ボイスチャンネルの通知については、こちらの記事を参考にしました。
https://qiita.com/coolwind0202/items/34c8e3bee3680bde15ad

#Magic; the Gatheringの裁定をWebページ上から取得し、それをDeepLで和訳したものを表示する。
Scrythonではなくウェブスクレイピングの手法で、情報を取得します。
また、取得した情報をDeepL APIに送ります。

Magic; the Gatheringのカードの情報を取得するために、Scryfallが使われることがしばしばあります。
以下の内容は、scryfallのボットをすでにサーバーに招いていることを前提にしています。
https://scryfall.com/bots

Scryfallの情報をPythonで取得するためのライブラリとして、Scrythonがあります。
https://github.com/NandaScott/Scrython
今回も、Scrythonが利用できることが理想でした。
しかし、ReplitでScrythonをインポートしようとすると、Scrythonの依存先であるAsyncioのインポート時にエラーを起こします。そのため、今回はスクレイピングの手法を使って情報を取得します。

```py

# BeautifulSoup: HTMLの操作を楽にするライブラリ
from bs4 import BeautifulSoup
# requests： 通信を行ってHTMLを取得するライブラリ
import requests

@client.event
async def on_message(message):

    # ルーリングの取得
    # メッセージがScryfall Botが示すScryfallのURLであった場合に、情報の取得を開始します。
    # 今回はmessage.contentだけを条件としているものの、message.authorを条件に加えたほうがより厳密です。
    if message.content.startswith('https://scryfall.com') and message.content.endswith('utm_source=discord'):
      try:
        url = message.content
        # urlの内容を、requestsを使用してrとして読み込みます。
        r = requests.get(url)
        # 読み込んだ内容を、扱いやすいように、BeautifulSoupのインスタンスにします。
        soup = BeautifulSoup(r.text,'html.parser')

        #　find_allメソッドは、HTMLタグのAttributeが条件に合う要素を探してくれます。
        #  結果は、見つかった要素のリストです。
        #  今回は、タグ内に「class = "rulings-item"」と書かれているものを探します。
        #  class ではなく class_ とアンダーバーが付いているのは、pythonがすでに使用しているclassという語とぶつかり合わないためです。
        td_list = soup.find_all(class_ = "rulings-item") 

        #  find_allとちがい、findは、見つかった要素そのものを返します。
        #  .textは、その要素のtextを返します。
        #  たとえば「<h1 class = "card-text-title">Grizzly Bears</h1>」のtextは、「Grizzly Bears」です。
        cardname = soup.find(class_="card-text-title").text

        # td_listというリストとして得ている内容を、textという名前のstrにします。
        text = ''
        for item in td_list:
          text += item.text

        #replitに登録しているDEEPL_TOKENを引き出します。
        DEEPL_TOKEN = os.environ['DEEPL_TOKEN']

        # DeepL APIに与える値
        # 「textを、原文（source_lang）の英語（EN）から、目標言語（target_lang）の日本語（JA）に翻訳してもらいたい」ということを言っています。
        deepl_params = {
          "auth_key": DEEPL_TOKEN,
          "text": text,
          "source_lang": 'EN',
          "target_lang": 'JA' 
        }

        # requests.postを使って、DeepLにお願いをdataとして送ります。
        deepl_request = requests.post("https://api-free.deepl.com/v2/translate", data=deepl_params)
        # DeepLからかえって来たjsonの中から、翻訳された内容を取り出します。
        deepl_result = deepl_request.json()["translations"][0]["text"]

        # 翻訳された内容をDiscordに書き込みます。書き込み先は引数messageが書かれたのと同じchannelです。
        await message.channel.send(f'{cardname} \n【裁定】:\n' + deepl_result)

        # 原文をDiscordに書き込みます。書き込み先は引数messageが書かれたのと同じchannelです。
        await message.channel.send('【原文】:\n' + text)
        

      except:
        await message.channel.send(f'カードが見つかりません。')


```

![Fireball.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/1851956/483b3344-63e1-2f4a-67d1-53e85cea3d38.png)
Scryfallのコマンドを使ってDiscordに書き込むと、Scryfall botが該当するカードを表示します。

![開発者ツール.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/1851956/b5d3cfcf-c101-6557-d362-3ade32cc1a55.png)
ここで、今回のようなBotを作る際に、Webページ上で得たい要素を我々人間が探す方法について説明します。
Google Chromeで要素を探したいページに行き、CTRL + SHIFT + Cを押すと、開発者ツールが表示されます。
この状態で、Botに取得させたい要素をクリックすると、その部分のHTMLがクローズアップされます。
取得したい要素のclassやidの法則を根性で見つけ出してください。

今回は、欲しい要素が classで抜けそうだったので、「td_list = soup.find_all(class_ = "rulings-item")」や「cardname = soup.find(class_="card-text-title").text」とコーディングしています。

![DeepLAPI_FREE.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/1851956/fee2cfd9-0e1a-a5ad-ac9e-da13cfc948c5.png)
DeepLをBotで無料で使用するために、DeepL API Freeのアカウントを作ります。

![DeepL_Key.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/1851956/fce11814-0645-2c7c-91c3-d4f7a15352a6.png)

アカウントの画面を下にスクロールすると、「DeepL APIで使用する認証キー」を取得できます。

![secrets_deepL.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/1851956/e3a129bc-b820-2a5f-8ccb-9a97e61691b2.png)
取得した「DeepL APIで使用する認証キー」についても、左の錠のマークから環境変数を作成します。

![ensnaring_bridge.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/1851956/f0740b3e-b18d-46cc-367b-ee21b5c2a6c4.png)
このようになります。


#Botを延命する。
一定時間リクエストがないと、このReplitで作ったBotはスリープしてしまいます。
タブを閉じても動き続けるように、別の巡回ロボットがReplitのbotを見張るようにします。
まず、見張り先のURLを用意します。

![keep_alive.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/1851956/e473a62c-69e4-4c91-1b5e-8af79f17e41a.png)

Add fileから「keep_alive.py」というファイルを作り、以下のように書きます。

```py

from flask import Flask
from threading import Thread

app = Flask('')

@app.route('/')
def home():
  return 'Hello. I am alive!'

def run():
  app.run(host = '0.0.0.0', port = 8080)

def keep_alive():
  t = Thread(target = run)
  t.start()

```

また、main.pyにも以下のように書き、keep_aliveを実行できるようにします。

```py

from keep_alive import keep_alive

keep_alive()

```

![URL.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/1851956/84246f6c-6cc0-74cd-bf15-7eb948a6e465.png)

このようにして上のRunボタンを押すと、画面右上にURLが表示されます。
このURLに何者かが訪れるたびに、Botは「Hello, I am alive!」と表示します。

このURLを訪れる何者かを用意するために、 https://uptimerobot.com/ を使用します。

![uptimerobot.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/1851956/a3893c1b-6028-fe7d-9225-0c08e0f5516f.png)
UptimeRobotにログイン後、+Add New Monitorをクリックします。
Monitor Type:  HTTP(s)
Friendly  Name:任意
URL: 先ほどのURL

これらを入力し、右下のCreate Monitorをクリックします。

#終わり
これで、Pythonで無料のBotを作った話は終わりです。

以下が、コードの全文です。

```py
import discord
# 環境変数を読み込むのに必要なライブラリ
import os
# gathererのルーリングはScrythonから入手できますが、scrythonをインストールすると
# 現状のreplitではasyncioのインストールでエラーを起こすため、
# requestとbs4を使用して
# ルーリングを取得します。
import requests
from bs4 import BeautifulSoup
import re
# ボットの延命
from keep_alive import keep_alive


# 環境変数から、DISCODE_TOKENを取得
discord_token = os.environ['DISCORD_TOKEN']

# Clientのインスタンスを作成
client = discord.Client()

# ログインしたときに、コンソールにメッセージを表示する。
@client.event
async def on_ready():
    print(f'{client.user}としてログインしました')

mention_to = set()
text_channel = ""

# ボイスチャンネルの通知については、こちらの記事を参考にしています。
# https://qiita.com/coolwind0202/items/34c8e3bee3680bde15ad

@client.event
async def on_vc_start(member,channel):
    msg = ''
    for user in mention_to:
      msg += f'<@!{user}> '
    msg += f"\n{member.name}が{channel.name}でボイスチャットを開始しました。"

    print(text_channel, type(text_channel))

    sending_channel = client.get_channel(text_channel)

    try:
      await sending_channel.send(msg) 
    except:
      print('チャンネルの設定がされていません。')

@client.event
async def on_vc_end(member,channel):
    msg = ''
    for user in mention_to:
      msg += f'<@!{user}> '
    msg += f"\n{member.name}が{channel.name}のボイスチャットを終了しました"

    sending_channel = client.get_channel(text_channel)

    try:
      await sending_channel.send(msg) 
    except:
      print('チャンネルの設定がされていません。')

@client.event
async def on_voice_state_update(member,before,after):
    if before.channel != after.channel:
        # before.channelとafter.channelが異なるなら入退室
        if after.channel and len(after.channel.members) == 1:
            # もし、ボイスチャットが開始されたら
            client.dispatch("vc_start",member,after.channel) #発火！

        if before.channel and len(before.channel.members) == 0:
            # もし、ボイスチャットが終了したら
            client.dispatch("vc_end",member,before.channel) #発火！



@client.event
async def on_message(message):
    global mention_to
    global text_channel

    if message.content.startswith('$add_me'):
      print('message reveived')
      new_user = message.author.id
      mention_to.add(new_user)

      msg = ""
      for user in mention_to:
        msg += f'<@!{user}> '

      msg += f'{message.author}がメンション先に加わりました。'
      await message.channel.send(msg) 

    if message.content.startswith('$remove_me'):
      print('message reveived')
      old_user = message.author.id

      if old_user in mention_to:
        mention_to.remove(old_user)

        msg = f'\nあなたをメンション先から削除しました。'
        await message.channel.send(msg) 

      else:
        await message.channel.send(f'あなたはメンション先として登録されていないため、メンション先から削除できません。')

    if message.content.startswith('$change_channel_id'):
      text_channel = message.channel.id
      msg = ""
      for user in mention_to:
        msg += f'<@!{user}> '

      msg += f'通話の通知先をこのチャンネルに変更しました。'
      await message.channel.send(msg)

    'ルーリングの取得'
    if message.content.startswith('https://scryfall.com') and message.content.endswith('utm_source=discord'):
      try:
        url = message.content
        r = requests.get(url)
        soup = BeautifulSoup(r.text,'html.parser')

        td_list = soup.find_all(class_ = "rulings-item") 
        print(td_list, '=td_list')

        cardname = soup.find(class_="card-text-title").text
        print(cardname, '= cardname')

        text = ''
        for item in td_list:
          print(item, '= item')
          text += item.text

        print(text, '=text')

        DEEPL_TOKEN = os.environ['DEEPL_TOKEN']
        # DeepL APIに与える値
        print('133')

        deepl_params = {
          "auth_key": DEEPL_TOKEN,
          "text": text,
          "source_lang": 'EN',
          "target_lang": 'JA' 
        }

        deepl_request = requests.post("https://api-free.deepl.com/v2/translate", data=deepl_params)
        print(143)
        deepl_result = deepl_request.json()["translations"][0]["text"]

        print(deepl_result, '=deepl_result')

        await message.channel.send(f'{cardname} \n【裁定】:\n' + deepl_result)

        print(150)
        await message.channel.send('【原文】:\n' + text)
        

      except:
        await message.channel.send(f'カードが見つかりません。')

keep_alive()
# Discordに接続する。
client.run(discord_token)
```