24
24

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

More than 1 year has passed since last update.

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

Last updated at Posted at 2023-09-16

まえがき

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

基礎編

この基礎編では、$Helloと投稿したらBotからHello!と投稿する単純なBotを作成します!
24時間運用までやります!

DISCORD DEVELOPER PORTALに登録

こちらのリンクにアクセスしてください
New ApplicationをクリックしてBotの写真とか名前を設定します
image.png
左のメニューのBotに移動
image.png
PUBLIC BOT→off
(自分だけがBotをサーバーに追加できる設定)
image.png
MESSAGE CONTENT INTENT→on
(Botがメッセージテキストを取得するための設定)
Reset TokenからTokenを取得できるのでコピーして保存しときましょう
(Resetすると古いTokenは無効となります)
ここまでで初期設定は完了です

サーバーに追加

OAuth2URL Generator
SCOPES:bot
BOT PERMISSIONS:必要な権限つける
(今回はSend MessagesRead Message History
下に出てくるURLの場所にアクセスして任意のサーバーに追加する
image.png
認証が終了するとDiscord側からBotがサーバーに参加していることが確認できます
image.png
(私のBotの名前は働かない社畜にしてます)

ロールの作成

ここでBotにロールを与えると良いと思います
Botは作成した時点でそのBot名と同じ名前のロールが与えられますが、それ以外にBot用のロールを作成しておくことをお勧めします
image.png
今回は投稿を取得してテキストを投稿するだけなので作成したロールに権限は特に与えていません

pythonでBotを制御

Botを作るライブラリはいくつかありますが、今回はdiscord.pyを使います

公式ドキュメント
python以外の言語
今回のコード

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
python3 -V
> Python 3.11.5

pythonのバージョンは3.8以降でないとのちに使うコードが動かなくなる可能性があります

新規フォルダを作成し、pythonファイルを作成しましょう!
私はdiscordというフォルダの中にmain.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=MTE1MTg2NjE4ODIzMDU3MDAxNA.GBqPpE.sNjU_Hufy4OIxjtb1FDEJ09GW8KkqJWgWQ

このコードは自分のTokenに書き換えてください
これでmain.pyからos.getenv('TOKEN')でTokenを受け取ることができます

terminal
cd discord
python3 main.py

実行すると、ログインしました: <Botの名前>と表示されるはずです
Discordに移動すると追加したBotがオンライン状態になっていることも確認できます
確認できたら任意のチャンネルで$Helloと投稿してみましょう
image.png
このようにBotから返信が返ってくると成功です

24時間運用

ReplとGoogleAppsScriptを使って完全無料でBotを24時間運用していきます!

Replの使い方

replitに登録
私はgoogleアカウントで登録しました
Creat ReplからReplを作成します
image.png
言語はPythonを指定し、Titleはテキトーで大丈夫です!
image.png
エディタが表示されたら先ほどのmain.pyに書かれているコードをそのままreplのmain.pyにコピペしましょう

環境変数の登録

Screenshot 2023-09-15 23.22.57.png
ToolsのSecretsをクリックし、New SecretからTOKENを設定します
image.png
入力したらAdd Secret
image.png
登録できると、TOKENの取得方法が表示されるので少しmain.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'))
+ client.run(os.environ['TOKEN'])

Runを押すとResource足りませんみたいな警告が出て、課金を勧められましたが、バツを押して待機していると遅いですが動きました

ログインしました: <Botの名前>

image.png

常時稼働

flaskファイルを作成します
New fileからkeep.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を書き換えます

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

表示されたviewのURLをコピーして保存しておきましょう

ここまでできればreplを閉じてもBotが動いていることが確認できます

GASでサーバーを動かし続ける

ここからはGoogleAppsScriptで先ほど立ち上げたwebサーバーに一定時間毎にアクセスすることでBotを動かし続けます
replはwebサーバーに一定時間アクセスがないと止まってしまうからです
マイドライブに移動して新規→その他→GoogleAppsScriptでGASファイルを作成します
プロジェクトの設定からスクリプトプロパティを追加します
Screenshot 2023-09-16 0.28.28.png
replURLを先ほど立てたwebサーバーのURLに設定します

コード.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

注意
権限の確認をされたら、詳細から安全ではないページへ移動してください

Screenshot 2023-09-16 0.32.54.png

次にトリガーの設定
image.png
分ベースのタイマーを5分おきにして保存すれば終了です

エラー格闘日記

1.message.contentが表示されない

Clientのインスタンス、

client = discord.Client()

これを作成しただけでは投稿されたメッセージの中身を取得することができませんでした
なのでintentsを定義しました

intents.message_content = True

これがメッセージの中身を取得するために必要になります
こっちでもいけました↓↓

- 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
Discord DeveloperのBotのMESSAGE CONTENT INTENTをonにして、Tokenを再発行するとうまく動きました

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

発展編

現在のBotでは$Helloと投稿するとHello!と返信するだけでつまらないので他の機能も実装してみました!

ネガポジランキング作成

感情分析AI

投稿されたテキストを感情分析AIを用いてネガティブかポジティブか判定していこうと思います

参考記事

まずはローカルで挙動を確かめます!

terminal
pip install transformers
エラー地獄になった方へ
terminal
conda create nlp python=3.7
conda activate nlp
terminal
conda install pandas 
conda install PyTorch
pip install transformers
pip install fugashi
pip install unidic-lite

このように一時的にpythonのversionを3.7に下げて、色々インストールすると私は動きました

model.py
from transformers import pipeline

text = '毎日がエブリデイ'
nlp = pipeline(
    model="lxyuan/distilbert-base-multilingual-cased-sentiments-student",
    return_all_scores=True)
print(nlp(text))
実行結果
[[{'label': 'positive', 'score': 0.536488950252533},
{'label': 'neutral', 'score': 0.2318655103445053},
{'label': 'negative', 'score': 0.2316455841064453}]]

このようにnlptextを渡すと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の作成

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

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にまとめます!

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

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

Discordに投稿された情報を受け渡す

Discordに投稿された情報をmain.pyで受け取ってmodel.pyのrecord_text_sentiment関数に渡して、DBに記録させていきます!

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
ReplのShellから以下のコマンドを入力する

pip uninstall torch
pip uninstall transformers
pip install torch
pip install transformers

終わったらもう一度Runするとうまく動きました

ランキング作成

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

やること

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

毎週・毎月リマインド

  1. サーバーidと日付が与えられたら今日の日付から与えられた日数のサーバー内で投稿された投稿をデータベースから取得する
  2. userごとにpositiveとnegativeの平均をとる
  3. ランキングを作成する
  4. 日曜日か判定して任意のチャンネルに送信する
    新しくget_ranking.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ごとにネガポジの平均を取っていきます

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日にランキングを作成して送信していきます

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
Seacretsを追加で登録して、Runしてみましょう
設定した時間になったら流れてくるはずです!
image.png
Noneになってますねww
これは、権限かと思ったのですが、権限ではありませんでした
get_user()はキャッシュから情報を取得するためほとんど意味がなかったです。
代わりに、fetch_user()を使うとうまくいきました!

await self.fetch_user(userId)

参考

最後に一言

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

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

24
24
1

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
24
24

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?