[python]Discord Botを使って勉強時間を計測するオンライン自習室環境を作ってみた

# はじめに
こんにちは、hiroと言います。

この記事では、Discord.pyとherokuとGoogle Spread Sheet APIを用いたオンライン自習室環境を統制してくれるbotの作成方法について解説したいと思います。

# 完成品

![20220725214336.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/166275/c4904e8a-8c3b-d33e-2aa0-2707f59a4776.png)
![20220523115029.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/166275/ccf80dba-cadc-8c5d-5c0d-e98fb09e7fb5.png)
![20220523105710.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/166275/8e7ff066-23bb-a1b3-4301-41e4a1d9061e.png)
![2023-03-01 (2).png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/166275/273d7a6a-6ce7-39b7-dc7a-ee6c833904c9.png)
![20220725214835.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/166275/7d5d9e2a-c54c-4fa5-23d6-00ad151ffc69.png)



# 前提知識

前提知識として、以下の記事を読んでからこの記事を読んでください。

## Discord botの作成とheorkuの使い方

https://qiita.com/1ntegrale9/items/9d570ef8175cf178468f

## Pythonを使ったGoogleスプレッドシートの操作方法

https://qiita.com/164kondo/items/eec4d1d8fd7648217935

# 作り方

## 環境

* Discordに時間計測したいボイスチャンネル、コマンドを受け付けるテキストチャンネルを作る
* Discordのロールに称号を追加する（弊サーバでは8つ）
* python実行ディレクトリに「img」フォルダを作成し、その中に各種ロールの画像を追加する
* Discordと紐づけたスプレッドシートの1シート目に月ごとの合計時間（1年分見れる）、2シート目～13シート目に日ごとの合計時間（1か月分見れる）を記録する為のシートを作る

## ソースコード


```python: time_record.py
TOKEN = "ここにDiscord botのTOKENを貼る"

import discord
import gspread
import datetime
from oauth2client.service_account import ServiceAccountCredentials
import os

intents = discord.Intents.all()
client = discord.Client(intents=intents)
pretime_dict = {}

acvc01_id = "時間計測するボイスチャンネルのIDを貼る"  # 自習室
ch_id =  "コマンドを受け付けるテキストチャンネルのIDを貼る" # 進捗報告

ID_ROLE1 = "称号のロールIDを貼る"
ID_ROLE2 = "称号のロールIDを貼る"
ID_ROLE3 = "称号のロールIDを貼る"
ID_ROLE4 = "称号のロールIDを貼る"
ID_ROLE5 = "称号のロールIDを貼る"
ID_ROLE6 = "称号のロールIDを貼る"
ID_ROLE7 = "称号のロールIDを貼る"
ID_ROLE8 = "称号のロールIDを貼る"

def connect_gspread(jsonf, key):
    scope = ['https://spreadsheets.google.com/feeds', 'https://www.googleapis.com/auth/drive']
    credentials = ServiceAccountCredentials.from_json_keyfile_name(jsonf, scope)
    gc = gspread.authorize(credentials)
    SPREADSHEET_KEY = key
    worksheet_sum = gc.open_by_key(SPREADSHEET_KEY).get_worksheet(0)
    return worksheet_sum

def connect_gspread_individual(jsonf, key):
    today = datetime.date.today()
    print(today.day)
    month = today.month 
    scope = ['https://spreadsheets.google.com/feeds', 'https://www.googleapis.com/auth/drive']
    credentials = ServiceAccountCredentials.from_json_keyfile_name(jsonf, scope)
    gc = gspread.authorize(credentials)
    SPREADSHEET_KEY = key
    ss = gc.open_by_key(SPREADSHEET_KEY)
    if   month == 1:
        worksheet = ss.get_worksheet(1)
    elif month == 2:
        worksheet = ss.get_worksheet(2)
    elif   month == 3:
        worksheet = ss.get_worksheet(3)
    elif   month == 4:
        worksheet = ss.get_worksheet(4)
    elif month == 5:
        worksheet = ss.get_worksheet(5)
    elif month == 6:
        worksheet = ss.get_worksheet(6)
    elif month == 7:
        worksheet = ss.get_worksheet(7)
    elif month == 8:
        worksheet = ss.get_worksheet(8)
    elif month == 9:
        worksheet = ss.get_worksheet(9)
    elif month == 10:
        worksheet = ss.get_worksheet(10)
    elif month == 11:
        worksheet = ss.get_worksheet(11)
    elif month == 12:
        worksheet = ss.get_worksheet(12)
    return worksheet

jsonf = "スプレッドシートを操作する為のjsonファイルを貼る"
spread_sheet_key = "スプレッドシートのキー（URLから取得）を貼る"
ws = connect_gspread(jsonf, spread_sheet_key)

jsonf = "スプレッドシートを操作する為のjsonファイルを貼る"
spread_sheet_key = "スプレッドシートのキー（URLから取得）を貼る"
ws_idv = connect_gspread_individual(jsonf, spread_sheet_key)


def next_available_row(ws):
    str_list = list(filter(None, ws.col_values(1)))
    return str(len(str_list) + 1)

def next_available_col(ws_idv):
    str_list = list(filter(None, ws_idv.row_values(1)))
    return str(len(str_list) + 1)

@client.event
async def on_voice_state_update(member, before, after):
    today = datetime.date.today()

    # 監視するボイスチャンネル(チャンネルID)
    announceChannelIds = acvc01_id

    if ((before.self_mute is not after.self_mute) or (
            before.self_deaf is not after.self_deaf)) and after.channel.id == announceChannelIds:
        return

    if (before.channel is None) and (after.channel.id == announceChannelIds):
        pretime_dict[member.name] = datetime.datetime.now()

    elif (after.channel is None) and (before.channel.id == announceChannelIds):
        duration_time = pretime_dict[member.name] - datetime.datetime.now()
        duration_time_adjust = int(duration_time.total_seconds()) * -1 / 60
        time = round(duration_time_adjust)

        month = int(today.month + 1)  # スプレッドシート上での月（列）を取得
        day = int(today.day + 1) #スプレッドシート上での日（行）を取得

        if ws.find(member.name) is None:
            next_row = next_available_row(ws)
            ws.update_cell(next_row, 1, member.name)
            ws.update_cell(next_row, month, time)

        else:
            cell = ws.find(member.name)
            nowtime = ws.cell(cell.row, month).value
            if nowtime == None:
                nowtime = 0
            cum_time = int(nowtime) + time
            ws.update_cell(cell.row, month, cum_time)
            
        if ws_idv.find(member.name) is None:
            next_col = next_available_col(ws_idv)
            ws_idv.update_cell(1, next_col, member.name)
            ws_idv.update_cell(day, next_col, time)

        else:
            cell = ws_idv.find(member.name)
            nowtime = ws_idv.cell(day, cell.col).value
            if nowtime == None:
                nowtime = 0
            cum_time = int(nowtime) + time
            ws_idv.update_cell(day, cell.col, cum_time)


@client.event
async def on_message(message):
    today = datetime.date.today()
    print(today.day)
    month = int(today.month + 1)  # スプレッドシート上での月（列）を取得
    day = int(today.day + 1) #スプレッドシート上での日（行）を取得

    role1 = message.guild.get_role(ID_ROLE1)
    role2 = message.guild.get_role(ID_ROLE2)
    role3 = message.guild.get_role(ID_ROLE3)
    role4 = message.guild.get_role(ID_ROLE4)
    role5 = message.guild.get_role(ID_ROLE5)
    role6 = message.guild.get_role(ID_ROLE6)
    role7 = message.guild.get_role(ID_ROLE7)
    role8 = message.guild.get_role(ID_ROLE8)

    if message.author.bot:  # 拾ったメッセージがBotからのメッセージだったら(=Bot自身の発言だったら弾く)
        return

    elif message.content.startswith("!time"):
        today = datetime.date.today()
        cell = ws.find(message.author.name)
        val = int(ws.cell(cell.row, month).value)
        cell_idv = ws_idv.find(message.author.name)
        val_idv = int(ws_idv.cell(day, cell_idv.col).value)
        user_id = int(message.author.id)

        current_role = message.author.roles
        if val < 180:
            user = await message.guild.fetch_member(user_id)
            await user.remove_roles(role1)
            await user.remove_roles(role2)
            await user.remove_roles(role3)
            await user.remove_roles(role4)
            await user.remove_roles(role5)
            await user.remove_roles(role6)
            await user.remove_roles(role7)
            await user.remove_roles(role8)

        elif val >= 180 and val < 300:
            user = await message.guild.fetch_member(user_id)
            await user.remove_roles(role2)
            await user.remove_roles(role3)
            await user.remove_roles(role4)
            await user.remove_roles(role5)
            await user.remove_roles(role6)
            await user.remove_roles(role7)
            await user.remove_roles(role8)
            await user.add_roles(role1)
            current_role = role1
            color_embed = 0x0067c0
            fname="ロールの画像ファイル"


        elif val >= 300 and val < 600:
            user = await message.guild.fetch_member(user_id)
            await user.remove_roles(role1)
            await user.remove_roles(role3)
            await user.remove_roles(role4)
            await user.remove_roles(role5)
            await user.remove_roles(role6)
            await user.remove_roles(role7)
            await user.remove_roles(role8)
            await user.add_roles(role2)
            current_role = role2
            color_embed = 0x008000
            fname="ロールの画像ファイル"

        elif val >= 600 and val < 900:
            user = await message.guild.fetch_member(user_id)
            await user.remove_roles(role1)
            await user.remove_roles(role2)
            await user.remove_roles(role4)
            await user.remove_roles(role5)
            await user.remove_roles(role6)   
            await user.remove_roles(role7)   
            await user.remove_roles(role8)
            await user.add_roles(role3)
            current_role = role3
            color_embed = 0xffde34
            fname="ロールの画像ファイル"

        elif val >= 900 and val < 1800:
            user = await message.guild.fetch_member(user_id)
            await user.remove_roles(role1)
            await user.remove_roles(role2)
            await user.remove_roles(role3)
            await user.remove_roles(role5)
            await user.remove_roles(role6)
            await user.remove_roles(role7)
            await user.remove_roles(role8)
            await user.add_roles(role4)
            current_role = role4
            color_embed = 0xee710a
            fname="ロールの画像ファイル"

        elif val >= 1800 and val < 3000:
            user = await message.guild.fetch_member(user_id)
            await user.remove_roles(role1)
            await user.remove_roles(role2)
            await user.remove_roles(role3)
            await user.remove_roles(role4)
            await user.remove_roles(role6)
            await user.remove_roles(role7)
            await user.remove_roles(role8)
            await user.add_roles(role5)
            current_role = role5
            color_embed = 0xff0000
            fname="ロールの画像ファイル"

        elif val >= 3000 and val < 6000:
            user = await message.guild.fetch_member(user_id)
            await user.remove_roles(role1)
            await user.remove_roles(role2)
            await user.remove_roles(role3)
            await user.remove_roles(role4)
            await user.remove_roles(role5)
            await user.remove_roles(role7)
            await user.remove_roles(role8)
            await user.add_roles(role6)
            current_role = role6
            color_embed = 0xa260bf
            fname="ロールの画像ファイル"

        elif val >= 6000 and val < 10800:
            user = await message.guild.fetch_member(user_id)
            await user.remove_roles(role1)
            await user.remove_roles(role2)
            await user.remove_roles(role3)
            await user.remove_roles(role4)
            await user.remove_roles(role5)
            await user.remove_roles(role6)
            await user.remove_roles(role8)
            await user.add_roles(role7)
            current_role = role7
            color_embed = 0xf2f2f2
            fname="ロールの画像ファイル"
            
        elif val >= 10800:
            user = await message.guild.fetch_member(user_id)
            await user.remove_roles(role1)
            await user.remove_roles(role2)
            await user.remove_roles(role3)
            await user.remove_roles(role4)
            await user.remove_roles(role5)
            await user.remove_roles(role6)
            await user.remove_roles(role7)
            await user.add_roles(role8)
            current_role = role8
            color_embed = 0xe9a28d
            fname="ロールの画像ファイル"

        chkrls = message.author.roles
        role_name_list = []

        for role in chkrls:  # roleにはRoleオブジェクトが入っている
            role_name_list.append(role.name)
            if "@everyone" in role_name_list:
                role_name_list.remove("@everyone")

        if val < 180:
            reply_text = f'{message.author.mention}さん' + "の" \
                         + str(today.month) + "月の勉強時間は" + str(val) + "分だよ！\n" \
                         + "今日の勉強時間は" + str(val_idv) + "分だよ！\n" \
                         + "合計180分以上勉強すると称号がもらえるよ！" \
                         + "頑晴れ!"
            channel = client.get_channel(ch_id)
            await channel.send(reply_text)

        else:
            val = round(val / 60, 1)
            val_idv = round(val_idv /60, 1)
            embed = discord.Embed(title=f'{message.author.mention}さん' + "の実績",\
                         url="",color=color_embed)
            embed.add_field(name= ":cloud:" + str(today.month) + "月" + str(today.day) + "日の勉強時間" + ":cloud:",value=str(val_idv) + "時間",inline=False)
            embed.add_field(name= ":cloud:" + str(today.month) + "月の勉強時間" + ":cloud:",value=str(val) + "時間",inline=False)
            embed.add_field(name= ":medal:" + "ユーザーランク",value=str(current_role.name),inline=False)
            
            PATH = os.path.join(os.path.dirname(__file__), 'img/' + fname)
            file = discord.File(PATH)
            embed.set_thumbnail(url=f"attachment://{fname}") # embedに画像を埋め込むときのURLはattachment://ファイル名
            channel = client.get_channel(ch_id)
            await channel.send(file=file, embed=embed)


    elif message.content.startswith("!plus"):
    # split関数を用いて文字列を分割,command[1] に追加したい時間が整数で入っているはず、これをチェック
        command = message.content.split()

        if command[1].isdecimal():
            addtime_min = int(command[1])
            #月合計時間スプレッドシートに時間を加算

            if ws_time.find(message.author.name) is None:
                next_row = next_available_row(ws_time)
                ws_time.update_cell(next_row, 1, message.author.name)
                ws_time.update_cell(next_row, month, addtime_min)

            else:
                cell = ws_time.find(message.author.name)
                nowtime = ws_time.cell(cell.row, month).value
                if nowtime == None:
                    nowtime = 0
                cum_time = int(nowtime) + addtime_min
                ws_time.update_cell(cell.row, month, cum_time)

            if ws_idv_time.find(message.author.name) is None:
                next_col = next_available_col_time(ws_idv_time)
                ws_idv_time.update_cell(1, next_col, message.author.name)
                ws_idv_time.update_cell(day, next_col, addtime_min) 

            else:           
                #日ごとのスプレッドシートに時間を加算
                cell0 = ws_idv_time.find(message.author.name)
                nowtime0 = ws_idv_time.cell(day, cell0.col).value
                if nowtime0 == None:
                    nowtime0 = 0
                cum_time0 = int(nowtime0) + addtime_min
                ws_idv_time.update_cell(day, cell0.col, cum_time0)
             
            reply_text = f'{message.author.mention}さん' + "の" \
                         + str(today.month) + "月の勉強時間に" + str(addtime_min) + "分を加算したよ！" 
            channel = client.get_channel(ch_id_shinchoku)
            await channel.send(reply_text)
            
        else:
            err_text = "plusの後にはスペースを置いて整数を入力してね！"
            channel = client.get_channel(ch_id_shinchoku)
            await channel.send(err_text)
            
    elif message.content.startswith("!minus"):
    # split関数を用いて文字列を分割,command[1] に追加したい時間が整数で入っているはず、これをチェック
        command = message.content.split()

        if command[1].isdecimal():
            subtracttime_min = int(command[1])
            #月合計時間スプレッドシートに時間を加算
            cell = ws.find(message.author.name)
            nowtime = ws.cell(cell.row, month).value
            if nowtime == None:
                nowtime = 0
            cum_time = int(nowtime) - subtracttime_min
            ws.update_cell(cell.row, month, cum_time)
            #日ごとのスプレッドシートに時間を加算
            cell0 = ws_idv.find(message.author.name)
            nowtime0 = ws_idv.cell(day, cell0.col).value
            if nowtime0 == None:
                nowtime0 = 0
            cum_time0 = int(nowtime0) - subtracttime_min
            ws_idv.update_cell(day, cell0.col, cum_time0)
             
            reply_text = f'{message.author.mention}さん' + "の" \
                         + str(today.month) + "月の勉強時間から" + str(subtracttime_min) + "分を減算したよ！"
            channel = client.get_channel(ch_id)
            await channel.send(reply_text)
            
        else:
            err_text = "plusの後にはスペースを置いて整数を入力してね！"
            channel = client.get_channel(ch_id)
            await channel.send(err_text)

client.run(TOKEN)
```

# おわりに
この記事では、時間計測、時間加算減算、称号付与機能に絞って紹介しました。
他にも、私が管理の一員を担っているDiscordサーバーでは、計測したスプレッドシートの値を使って様々なbotが動いています。

例えば、皆で合計した時間を元に、月の合計目標時間を目指して毎日定時に称号を付与するbotに、個人の週の合計勉強時間をランキング形式にして1位から5位まで発表するbot等です。

Qiita初投稿でした、不慣れな点もあったかと思いますが、最後までお読みいただきありがとうございました。