【超簡単】DiscordのBotを0から作成24時間運用してみた

# まえがき
Discord始めて3日目の一般大学生が試行錯誤しながら無料で24時間稼働するDiscordBotを作成した日記です
不正確な情報が混ざってるかもしれませんがBotを作りたい誰かの助けになればなと思っています！

# 基礎編
この基礎編では、`$Hello`と投稿したらBotから`Hello!`と投稿する単純なBotを作成します！
24時間運用までやります！
### DISCORD DEVELOPER PORTALに登録
[こちらのリンク](https://discordapp.com/developers/applications/)にアクセスしてください
`New Application`をクリックしてBotの写真とか名前を設定します
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2586059/0ba16579-7590-6fb7-fc0d-e6332c14810a.png)
左のメニューのBotに移動
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2586059/65c3e094-8349-2abf-dc17-3cc6ebc9aec2.png)
`PUBLIC BOT`→off
（自分だけがBotをサーバーに追加できる設定）
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2586059/cbca43ef-e24d-9c8f-b411-28126a2488c2.png)
`MESSAGE CONTENT INTENT`→on
（Botがメッセージテキストを取得するための設定）
`Reset Token`からTokenを取得できるのでコピーして保存しときましょう
（Resetすると古いTokenは無効となります）
ここまでで初期設定は完了です

### サーバーに追加
`OAuth2`→`URL Generator`
SCOPES：bot
BOT PERMISSIONS：必要な権限つける
（今回は`Send Messages`と`Read Message History`）
下に出てくるURLの場所にアクセスして任意のサーバーに追加する
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2586059/3552e383-d7ea-8345-64d6-7e7489ca7bdd.png)
認証が終了するとDiscord側からBotがサーバーに参加していることが確認できます
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2586059/915b05ab-397e-dd06-93b5-b5c9ceb92927.png)
（私のBotの名前は働かない社畜にしてます）

### ロールの作成
ここでBotにロールを与えると良いと思います
Botは作成した時点でそのBot名と同じ名前のロールが与えられますが、それ以外にBot用のロールを作成しておくことをお勧めします
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2586059/19209942-55ab-688f-0db5-cd39645022c8.png)
今回は投稿を取得してテキストを投稿するだけなので作成したロールに権限は特に与えていません

### pythonでBotを制御
Botを作るライブラリはいくつかありますが、今回はdiscord.pyを使います
> [公式ドキュメント](https://discordpy.readthedocs.io/ja/latest/quickstart.html#a-minimal-bot)
[python以外の言語](https://discord.com/developers/docs/topics/community-resources)
[今回のコード](https://github.com/naoya25/DiscordBot)

```terminal:terminal
# 仮想環境の作成、有効化
conda create -n discord
conda activate discord

# dotenvをインストール
conda install python-dotenv

# discord.pyのインストール(音声サポートなし)
# Linux/macOS
python3 -m pip install -U discord.py

# Windows
py -3 -m pip install -U discord.py
```
```terminal
python3 -V
> Python 3.11.5
```
:::note warn
pythonのバージョンは3.8以降でないとのちに使うコードが動かなくなる可能性があります
:::

新規フォルダを作成し、pythonファイルを作成しましょう！
私は`discord`というフォルダの中に`main.py`ファイルを作成しました
```py:main.py
import discord
import os
from dotenv import load_dotenv
load_dotenv()

class MyClient(discord.Client):
    async def on_ready(self):
        print(f'ログインしました: {self.user}')

    async def on_message(self, message):
        print(f'送信: {message.author}: {message.content}')
        if message.author == self.user:
            return

        if message.content == '$Hello':
            await message.channel.send('Hello!')

intents = discord.Intents.default()
intents.message_content = True

client = MyClient(intents=intents)
client.run(os.getenv('TOKEN'))
```
次に、BotのTokenを.envファイルに格納しましょう
フォルダに`.env`ファイルを作成します
```.env
TOKEN=***********************
```
このコードは自分のTokenに書き換えてください
これで`main.py`から`os.getenv('TOKEN')`でTokenを受け取ることができます
```terminal:terminal
cd discord
python3 main.py
```
実行すると、`ログインしました: <Botの名前>`と表示されるはずです
Discordに移動すると追加したBotがオンライン状態になっていることも確認できます
確認できたら任意のチャンネルで`$Hello`と投稿してみましょう
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2586059/ba91c948-4dec-e7f5-a006-5a161a5ee73d.png)
このようにBotから返信が返ってくると成功です

### 24時間運用
ReplとGoogleAppsScriptを使って完全無料でBotを24時間運用していきます！
### Replの使い方
[replit](https://replit.com/)に登録
私はgoogleアカウントで登録しました
`Creat Repl`からReplを作成します
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2586059/00d3800d-0eda-90cb-3072-4914f48572c3.png)
言語はPythonを指定し、Titleはテキトーで大丈夫です！
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2586059/4cef3aa2-1f33-4c39-d151-272939440216.png)
エディタが表示されたら先ほどのmain.pyに書かれているコードをそのままreplのmain.pyにコピペしましょう
### 環境変数の登録
![Screenshot 2023-09-15 23.22.57.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2586059/252b265c-3696-5455-387f-0d323b1c56eb.png)
ToolsのSecretsをクリックし、New SecretからTOKENを設定します
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2586059/9b56cb81-d347-5d01-f33a-f4d2a3fe33ce.png)
入力したらAdd Secret
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2586059/502b34ed-24ef-c770-2676-1ab263e4ed57.png)
登録できると、TOKENの取得方法が表示されるので少し`main.py`の中身を書き換えます

```diff_python:main.py
import discord
import os
- from dotenv import load_dotenv
- load_dotenv()

class MyClient(discord.Client):
    async def on_ready(self):
        print(f'ログインしました: {self.user}')

    async def on_message(self, message):
        print(f'送信: {message.author}: {message.content}')
        if message.author == self.user:
            return

        if message.content == '$Hello':
            await message.channel.send('Hello!')

intents = discord.Intents.default()
intents.message_content = True

client = MyClient(intents=intents)
- client.run(os.getenv('TOKEN'))
+ client.run(os.environ['TOKEN'])
```
Runを押すとResource足りませんみたいな警告が出て、課金を勧められましたが、バツを押して待機していると遅いですが動きました
```console
ログインしました: <Botの名前>
```
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2586059/881b1b62-e1df-4d7c-ea7b-11d769f60f1e.png)

### 常時稼働
flaskファイルを作成します
New fileから`keep.py`ファイルを作成します
```py:keep.py
from flask import Flask
from threading import Thread

app = Flask('')

@app.route('/')
def main():
    return "Bot is alive"

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

def keep_alive():
    server = Thread(target=run)
    server.start()
```
`main.py`を書き換えます
```diff_python:main.py
import discord
import os
+ from keep import keep_alive

class MyClient(discord.Client):
    async def on_ready(self):
        print(f'ログインしました: {self.user}')

    async def on_message(self, message):
        print(f'送信: {message.author}: {message.content}')
        if message.author == self.user:
            return

        if message.content == '$Hello':
            await message.channel.send('Hello!')

intents = discord.Intents.default()
intents.message_content = True

client = MyClient(intents=intents)
- client.run(os.environ['TOKEN'])
+ keep_alive()
+ try:
+     client.run(os.environ['TOKEN'])
+ except:
+     os.system("kill")
```
RunするとViewが表示されるはずです
![Screenshot 2023-09-15 23.59.16.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2586059/c2c87bce-d342-66c3-94c2-11e972c143df.png)

:::note warn
表示されたviewのURLをコピーして保存しておきましょう
:::
ここまでできればreplを閉じてもBotが動いていることが確認できます

### GASでサーバーを動かし続ける
ここからはGoogleAppsScriptで先ほど立ち上げたwebサーバーに一定時間毎にアクセスすることでBotを動かし続けます
replはwebサーバーに一定時間アクセスがないと止まってしまうからです
マイドライブに移動して新規→その他→GoogleAppsScriptでGASファイルを作成します
プロジェクトの設定からスクリプトプロパティを追加します
![Screenshot 2023-09-16 0.28.28.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2586059/3b222f03-f114-a952-3dd6-11a46964df63.png)
`replURL`を先ほど立てたwebサーバーのURLに設定します
```js:コード.gs
const keepRepl = () => {
  const replURL = PropertiesService.getScriptProperties().getProperty('replURL')
  const data = {}
  const headers = { 'Content-Type': 'application/json; charset=UTF-8' }
  const params = {
    method: 'post',
    payload: JSON.stringify(data),
    headers: headers,
    muteHttpExceptions: true
  }

  response = UrlFetchApp.fetch(replURL, params);
  console.log(response)
}
```
このコードでは中身が空っぽの`data`を立てたwebサーバーに送るだけのGASです
keepReplを実行すると、特に意味はないですけどログが出てきます
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2586059/6ff57187-af71-3a2f-c349-de53cca37109.png)

:::note warn
注意
権限の確認をされたら、詳細から安全ではないページへ移動してください
:::
![Screenshot 2023-09-16 0.32.54.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2586059/ba6703bd-c3a7-0764-3d26-0fec4dc2ce61.png)

次にトリガーの設定
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2586059/3ec2234f-f035-a899-548f-a56dbbfe6e33.png)
分ベースのタイマーを5分おきにして保存すれば終了です

# エラー格闘日記
####  1.message.contentが表示されない
Clientのインスタンス、
```
client = discord.Client()
```
これを作成しただけでは投稿されたメッセージの中身を取得することができませんでした
なので`intents`を定義しました
```
intents.message_content = True
```
これがメッセージの中身を取得するために必要になります
こっちでもいけました↓↓
```diff_python
- intents = discord.Intents.default()
- intents.message_content = True

- client = MyClient(intents=intents)
+ client = MyClient(intents=discord.Intents.all())
```
#### 2.intentsの謎のエラー
```
Traceback (most recent call last):
  File "main.py", line 14, in <module>
    intents.message_content = True
AttributeError: 'Intents' object has no attribute 'message_content'
```
intentsが必要だから書いたのにまたエラーかあああ
このエラーはDiscord側の設定が問題です
![Screenshot 2023-09-15 14.09.46.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2586059/7dad9ca1-0263-e8e4-9871-cda1fa3712c6.png)
Discord DeveloperのBotの`MESSAGE CONTENT INTENT`をonにして、Tokenを再発行するとうまく動きました

:::note warn
pythonのバージョンが3.8より前だとここでエラー出ます
:::

# 発展編
現在のBotでは`$Hello`と投稿すると`Hello!`と返信するだけでつまらないので他の機能も実装してみました！
### ネガポジランキング作成
#### 感情分析AI
投稿されたテキストを感情分析AIを用いてネガティブかポジティブか判定していこうと思います
> [参考記事](https://huggingface.co/lxyuan/distilbert-base-multilingual-cased-sentiments-student)

まずはローカルで挙動を確かめます！
```terminal:terminal
pip install transformers
```
<details><summary>エラー地獄になった方へ</summary>

```terminal:terminal
conda create nlp python=3.7
conda activate nlp
```
```terminal:terminal
conda install pandas 
conda install PyTorch
pip install transformers
pip install fugashi
pip install unidic-lite
```
このように一時的にpythonのversionを3.7に下げて、色々インストールすると私は動きました
</details>

```py:model.py
from transformers import pipeline

text = '毎日がエブリデイ'
nlp = pipeline(
    model="lxyuan/distilbert-base-multilingual-cased-sentiments-student",
    return_all_scores=True)
print(nlp(text))
```
```terminal:実行結果
[[{'label': 'positive', 'score': 0.536488950252533},
{'label': 'neutral', 'score': 0.2318655103445053},
{'label': 'negative', 'score': 0.2316455841064453}]]
```
このように`nlp`に`text`を渡すとpositive度合い、neutral度合い、negative度合いを取得することができました！

### DB作成
判定したネガポジを保存するためのDatabaseをsqliteで作成します

#### Table
|カラム名|役割|
|---|---|
|id|id|
|guildid|サーバーid|
|channelid|投稿されたチャンネルのid|
|userid|投稿したuserのid|
|body|投稿内容|
|positive|positive度合い|
|neutral|neutral度合い|
|negative|negative度合い|
|created_at|投稿日|

#### Tableの作成
```py:create_db.py
import sqlite3

with sqlite3.connect('discord_db') as connection:
    cursor = connection.cursor()

    cursor.execute("""
    CREATE TABLE IF NOT EXISTS Post (
        id INTEGER PRIMARY KEY,
        guildid INTEGER,
        channelid INTEGER,
        userid INTEGER,
        body STRING,
        positive REAL,
        neutral REAL,
        negative REAL,
        created_at TIMESTAMP
    )
    """)
```
実行すると、`discord_db`というファイルができていると思います
#### Tableにレコードをinsert
```py:create_db.py
import sqlite3

with sqlite3.connect('discord_db') as connection:
    cursor = connection.cursor()
    insert_query = """
    INSERT INTO Post (guildid, channelid, userid)
    VALUES (?, ?, ?, ?, ?, ?, ?)
    """
    cursor.execute(insert_query, [1, 2, 3])
```
これで、
guildidが1、channelidが2、useridが3、のように新しいレコードを追加できます

#### `model.py`にまとめる
ここまでのネガポジ判定とDB作成、レコード追加までをReplの`model.py`にまとめます！
```py:model.py
from transformers import pipeline
import sqlite3

def record_text_sentiment(guildid, channelid, userid, body, created_at):
    nlp = pipeline(
        model="lxyuan/distilbert-base-multilingual-cased-sentiments-student",
        return_all_scores=True
    )
    negaposi_list = [nlp(body)[0][i]['score'] for i in range(3)]

    try:
        with sqlite3.connect('discord_db') as connection:
            cursor = connection.cursor()

            cursor.execute("""
            CREATE TABLE IF NOT EXISTS Post (
                id INTEGER PRIMARY KEY,
                guildid INTEGER,
                channelid INTEGER,
                userid INTEGER,
                body STRING,
                positive REAL,
                neutral REAL,
                negative REAL,
                created_at TIMESTAMP
            )
            """)

            insert_query = """
            INSERT INTO Post (guildid, channelid, userid, body, created_at, positive, neutral, negative)
            VALUES (?, ?, ?, ?, ?, ?, ?, ?)
            """
            cursor.execute(insert_query, [guildid, channelid, userid, body, created_at] + negaposi_list)
    except sqlite3.Error as e:
        print('DBのエラー: ', e)

    return negaposi_list

text = '毎日がエブリデイ'
print(record_text_sentiment(5,5,5,text))
```

:::note warn
`negaposi_list`には要素数3のリストが入っています
positive, neutral, negativeです
例 [0.536488950252533, 0.2318655103445053, 0.2316455841064453]
:::

### Discordに投稿された情報を受け渡す
Discordに投稿された情報を`main.py`で受け取って`model.py`のrecord_text_sentiment関数に渡して、DBに記録させていきます！
```diff_python:main.py
import discord
import os
from keep import keep_alive
+ from model import record_text_sentiment

class MyClient(discord.Client):
    async def on_ready(self):
        print(f'ログインしました: {self.user}')

    async def on_message(self, message):
        if message.author == self.user:
            return
+         negaposi = record_text_sentiment(message.guild.id, message.channel.id, message.author.id, message.content, message.created_at)
+         print(f'guildid: {message.guild.id}')
+         print(f'channelid: {message.channel.id}')
+         print(f'userid: {message.author.id}')
+         print(f'body: {message.content}')
+         print(f'created_at: {message.created_at}')
+         print(f'negaposi: {negaposi}')

intents = discord.Intents.default()
intents.message_content = True
client = MyClient(intents=intents)

keep_alive()
try:
    client.run(os.environ['TOKEN'])
except:
    os.system("kill")
```
ReplでRunすると、すごい赤文字で出てきましたww
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2586059/493b7e11-5d64-aed5-0724-9652fd3ec5d8.png)
ReplのShellから以下のコマンドを入力する
```
pip uninstall torch
pip uninstall transformers
pip install torch
pip install transformers
```
終わったらもう一度Runするとうまく動きました

## ランキング作成
作成したDBからネガポジ等取得してランキングを作成します！

#### やること
- 毎週日曜日に今週のポジティブランキングとネガティブランキングをチャンネルに投稿
- 毎月月末に同様のランキング作成して投稿

#### 毎週・毎月リマインド
1. サーバーidと日付が与えられたら今日の日付から与えられた日数のサーバー内で投稿された投稿をデータベースから取得する
1. userごとにpositiveとnegativeの平均をとる
1. ランキングを作成する
1. 日曜日か判定して任意のチャンネルに送信する
新しく`get_ranking.py`を作成
```py:get_ranking.py
import sqlite3
from datetime import datetime, timedelta

def getRecentData(guildid, pastDays):
    start_date = datetime.utcnow() - timedelta(days=pastDays)

    with sqlite3.connect('discord_db') as connection:
        cursor = connection.cursor()

        query = "SELECT * FROM Post WHERE guildid = ? AND created_at >= ?"
        cursor.execute(query, (guildid, start_date))
        posts = cursor.fetchall()
        return posts
```
このgetRecentData関数で`pastDays`日間のデータを取得できます
次に取得したレコードをuserごとにネガポジの平均を取っていきます
```diff_python:get_ranking.py
import sqlite3
from datetime import datetime, timedelta
+ import numpy as np

def getRecentData(guildid, pastDays):
    start_date = datetime.utcnow() - timedelta(days=pastDays)

    with sqlite3.connect('discord_db') as connection:
        cursor = connection.cursor()

        query = "SELECT * FROM Post WHERE guildid = ? AND created_at >= ?"
        cursor.execute(query, (guildid, start_date))
        posts = cursor.fetchall()
        return posts

# 追記
def calculateUserSentiment(posts):
    user_sentiment = {}

    for post in posts:
        userid = post[3]
        sentiment = np.array(post[5:8])

        if userid in user_sentiment:
            user_sentiment[userid].append(sentiment)
        else:
            user_sentiment[userid] = [sentiment]
    for userid, sentiments in user_sentiment.items():
        user_sentiment[userid] = sum(sentiments) / len(sentiments)
    positive_ranking = sorted(user_sentiment.items(), key=lambda x: x[1][0], reverse=True)
    negative_ranking = sorted(user_sentiment.items(), key=lambda x: x[1][2], reverse=True)
    return positive_ranking[:3], negative_ranking[:3]
```
これで、positive、negativeごとにランキングが出来上がりました！
次に、main.pyから毎週日曜日と毎月1日にランキングを作成して送信していきます
```py:main.py
import discord
import os
import asyncio
from datetime import datetime, timedelta
from keep import keep_alive
from model import record_text_sentiment
from get_ranking import getRecentData, calculateUserSentiment


class MyClient(discord.Client):

  async def send_ranking(self):
    await self.wait_until_ready()
    while not self.is_closed():
      now = datetime.now() + timedelta(hours=9)
      if now.weekday() == 6 and now.hour == 12:
        channel = self.get_channel(int(os.environ['CHANNELID']))
        posts = getRecentData(int(os.environ['GUILDID']), 7)
        positive_ranking, negative_ranking = calculateUserSentiment(posts)
        text = '今週のモチベランキング\npositive\n'
        text += '\n'.join([
            f'> {i}. {self.get_user(p[0])}'
            for i, p in enumerate(positive_ranking)
        ])
        text += '\nnegative\n'
        text += '\n'.join([
            f'> {i}. {self.get_user(n[0])}'
            for i, n in enumerate(negative_ranking)
        ])
        await channel.send(text)
      if now.day == 1 and now.hour == 12:
        channel = self.get_channel(int(os.getenv('CHANNELID')))
        posts = getRecentData(int(os.getenv('GUILDID')), 30)
        positive_ranking, negative_ranking = calculateUserSentiment(posts)
        text = '今月のモチベランキング\npositive\n'
        text += '\n'.join([
            f'> {i}. {self.get_user(p[0])}'
            for i, p in enumerate(positive_ranking)
        ])
        text += '\nnegative\n'
        text += '\n'.join([
            f'> {i}. {self.get_user(n[0])}'
            for i, n in enumerate(negative_ranking)
        ])
        await channel.send(text)
      await asyncio.sleep(60*60)

  async def on_ready(self):
    print(f'ログインしました: {self.user}')
    self.loop.create_task(self.send_ranking())

  async def on_message(self, message):
    if message.author == self.user:
      return
    negaposi = record_text_sentiment(message.guild.id, message.channel.id,
                                     message.author.id, message.content,
                                     message.created_at)
    print(f'guildid: {message.guild.id}')
    print(f'channelid: {message.channel.id}')
    print(f'userid: {message.author.id}')
    print(f'body: {message.content}')
    print(f'created_at: {message.created_at}')
    print(f'negaposi: {negaposi}')


intents = discord.Intents.default()
intents.message_content = True
client = MyClient(intents=intents)

keep_alive()
try:
  client.run(os.environ['TOKEN'])
except:
  os.system("kill")
```
毎週日曜日の12時と毎月1日の12時にそれぞれランキングを送信するように設定しています
最後！
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2586059/26fe5f4e-7df7-74b5-997c-fd5c036e194c.png)
Seacretsを追加で登録して、Runしてみましょう
設定した時間になったら流れてくるはずです！
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2586059/a12b205f-0d75-28c3-1cb7-e6f174a60178.png)
Noneになってますねww
これは、権限かと思ったのですが、権限ではありませんでした
`get_user()`はキャッシュから情報を取得するためほとんど意味がなかったです。
代わりに、`fetch_user()`を使うとうまくいきました！
```py
await self.fetch_user(userId)
```
> [参考](https://stackoverflow.com/questions/61112322/get-userid-cant-find-user-returns-none-self-bot-discord-py)

#### 最後に一言
ここまでめちゃめちゃ長かったですが最後までご覧いただきありがとうございます！
発展編からは完全に趣味にのめり込んでしまった感が否めないですが、AI、sql周りのことも学べたかなと思います！
今後の展望としては、スラッシュコマンドを使ってランキングを作れるようにしたり、おそらくデータベースが重くなると全ての処理が重くなってしまうので処理の高速化についてアップグレードしたりしたいなと思っております
コードが全体的に汚いですが、頑張ってわかりやすいように書いたのでお許しください

それでは〜バイバイきん〜〜