【Python】学習時間記録Discord Bot作成（Heroku Postgres連携Ver）

# 概要

Discord Botを改修した。
以前学習時間記録Discord Botを作成した。
特定のボイスチャンネルを監視し、ユーザ入退室時間を記録、滞在時間を計算して通知するbotである。

https://qiita.com/asami0420/items/bfab99b0ca665875887d

上記ではプロセス上の変数に情報を保存していたが入室時刻はDBに保存してデータが消失しないようする。

# 方法

## Bot作成
以下の記事を参考にDiscord Botを作成する。

https://qiita.com/asami0420/items/bfab99b0ca665875887d

## DB作成
HerokuにDBを作成する。

https://qiita.com/asami0420/items/d757f4bcedb04ee3565b

## Botのコード追加・修正
(コード全文は一番下に掲載)

### PythonからHerkouDB接続

PythonでHeroku上のDBに接続する例は以下の記事を参考にした。
以下の記事ではコード内にDB接続情報を埋め込んでいる。
DB接続情報の確認はHerokuの[Setting]→[Database Credentials]から確認できる。
(上記DB作成の記事にあったHerokuCLIの接続情報と同じ場所)

http://kakedashi-xx.com:25214/index.php/2021/12/27/post-3984/

追加したDB接続用コードは以下。  
- DB接続情報は以前のDiscord Bot Tokenと同じでheroku上の環境変数に登録している
- `execute`で実行するSQL分のパラメータは個数に関係なくリスト(orタプル)にしないといけないらしい：[参考](https://owatata.com/2022/01/16/pythonpsycopg2-parameters/)
- INSERT, DELETE後はcommitを忘れない

```Python
import psycopg2

def sqlInit():
    # 設定値
    users = getenv('HEROKU_POSTGRESQL_USERS')         # DBにアクセスするユーザー名(適宜変更)
    dbnames = getenv('HEROKU_POSTGRESQL_DBNAMES')     # 接続するデータベース名(適宜変更)
    passwords = getenv('HEROKU_POSTGRESQL_PASSWORDS') # DBにアクセスするユーザーのパスワード(適宜変更)
    host = getenv('HEROKU_POSTGRESQL_HOST')           # DBが稼働しているホスト名(適宜変更)
    port = getenv('HEROKU_POSTGRESQL_PORT')           # DBが稼働しているポート番号(適宜変更)

    # PostgreSQLへ接続
    conn = psycopg2.connect("user=" + users +" dbname=" + dbnames +" password=" + passwords, host=host, port=port)
    return conn

def sqlExit(conn):
    # PostgreSQLの接続を終了
    conn.close()
    return

def INSERT(conn, id, time):
    cur = conn.cursor()
    cur.execute("INSERT INTO pretime (id, time) VALUES (%s, %s)", [id, time])
    conn.commit()
    cur.close()
    return

def SELECT(conn, id):
    cur = conn.cursor()
    cur.execute("SELECT * FROM pretime WHERE id = %s", [id])
    result = cur.fetchone()
    cur.close()
    return result

def DELETE(conn, id):
    cur = conn.cursor()
    cur.execute("DELETE FROM pretime WHERE id = %s", [id])
    conn.commit()
    cur.close()
    return
```

## イベント処理

改修したコードは以下。

- `SEND_CHANNEL_ID`, `WATCH_CHANNEL_ID`も環境変数化した
- 以前はプログラム内変数に保存していた情報をDB保存にした

```Python
import discord
import datetime
from os import getenv

client = discord.Client()
SEND_CHANNEL_ID = int(getenv('SEND_CHANNEL_ID'))   # 通知を送信するチャンネルID
WATCH_CHANNEL_ID = int(getenv('WATCH_CHANNEL_ID')) # 入退室を監視するチャンネルID
DIFF_JST_FROM_UTC = 9

@client.event
async def on_ready():
    print('Logged in as')
    print(client.user.name)
    print(client.user.id)
    print('------')

@client.event
async def on_voice_state_update(member, before, after):

    # 入室時
    if before.channel is None:
        if after.channel.id != WATCH_CHANNEL_ID:
            return

        # 入室時刻を取得
        dt_now = datetime.datetime.utcnow() + datetime.timedelta(hours=DIFF_JST_FROM_UTC)
        dt_now_text = dt_now.strftime('%Y-%m-%d %H:%M:%S')

        # 入室者ID, 入室時刻をDBに保存
        try:
            conn = sqlInit()
            INSERT(conn, str(member.id), str(dt_now_text))
            sqlExit(conn)
            send_text = f'**{member.name} ({member.nick})** \n  入室： {dt_now_text}'
        except Exception as e:
            send_text = f'{e}'

        # 入室時は特に通知はしない(HerokuLogには出力する)
        print(send_text)

    # 退室時
    elif after.channel is None:
        if before.channel.id != WATCH_CHANNEL_ID:
            return

        # 退室時刻を取得
        dt_now = datetime.datetime.utcnow() + datetime.timedelta(hours=DIFF_JST_FROM_UTC)
        dt_now_text = dt_now.strftime('%Y-%m-%d %H:%M:%S')

        # 退室者IDを用いてDBから入室時刻を取得
        # 滞在時間を計算
        try:
            conn = sqlInit()
            result = SELECT(conn, str(member.id))
            if result is None:
                print('error: result is None')
                sqlExit(conn)
                return
            DELETE(conn, str(member.id))
            sqlExit(conn)

            dt_now_before = result[1]
            dt_now_before_text = dt_now_before.strftime('%Y-%m-%d %H:%M:%S')

            time_text = getDurationTimeText(dt_now_before, dt_now)
            send_text = f'**{member.name} ({member.nick})** \n  入室： {dt_now_before_text}\n  退室： {dt_now_text}\n  勉強時間： {time_text}'
        except Exception as e:
            send_text = f'{e}'

        print(send_text)

        # 指定のテキストチャンネルに通知
        send_channel = client.get_channel(SEND_CHANNEL_ID)
        await send_channel.send(send_text)

# token にDiscordのデベロッパサイトで取得したトークンを入れてください
token = getenv('DISCORD_BOT_TOKEN')
client.run(token)

```

### コード全文

```Python
import discord
import datetime
from os import getenv
import psycopg2

def sqlInit():
    # 設定値
    users = getenv('HEROKU_POSTGRESQL_USERS')         # DBにアクセスするユーザー名(適宜変更)
    dbnames = getenv('HEROKU_POSTGRESQL_DBNAMES')     # 接続するデータベース名(適宜変更)
    passwords = getenv('HEROKU_POSTGRESQL_PASSWORDS') # DBにアクセスするユーザーのパスワード(適宜変更)
    host = getenv('HEROKU_POSTGRESQL_HOST')           # DBが稼働しているホスト名(適宜変更)
    port = getenv('HEROKU_POSTGRESQL_PORT')           # DBが稼働しているポート番号(適宜変更)

    # PostgreSQLへ接続
    conn = psycopg2.connect("user=" + users +" dbname=" + dbnames +" password=" + passwords, host=host, port=port)
    return conn

def sqlExit(conn):
    # PostgreSQLの接続を終了
    conn.close()
    return

def INSERT(conn, id, time):
    cur = conn.cursor()
    cur.execute("INSERT INTO pretime (id, time) VALUES (%s, %s)", [id, time])
    conn.commit()
    cur.close()
    return

def SELECT(conn, id):
    cur = conn.cursor()
    cur.execute("SELECT * FROM pretime WHERE id = %s", [id])
    result = cur.fetchone()
    cur.close()
    return result

def DELETE(conn, id):
    cur = conn.cursor()
    cur.execute("DELETE FROM pretime WHERE id = %s", [id])
    conn.commit()
    cur.close()
    return

def getDurationTimeText(dt_now_before, dt_now):
    duration_time = dt_now_before - dt_now
    duration_time_adjust = int(duration_time.total_seconds()) * -1
    hours = duration_time_adjust // 3600
    remain = duration_time_adjust - (hours * 3600)
    minutes = remain // 60
    seconds = remain - (minutes * 60)
    time_text = '{:02}:{:02}:{:02}'.format(int(hours), int(minutes), int(seconds))
    return time_text

client = discord.Client()
SEND_CHANNEL_ID = int(getenv('SEND_CHANNEL_ID'))   # 通知を送信するチャンネルID
WATCH_CHANNEL_ID = int(getenv('WATCH_CHANNEL_ID')) # 入退室を監視するチャンネルID
DIFF_JST_FROM_UTC = 9

@client.event
async def on_ready():
    print('Logged in as')
    print(client.user.name)
    print(client.user.id)
    print('------')

@client.event
async def on_voice_state_update(member, before, after):

    # 入室時
    if before.channel is None:
        if after.channel.id != WATCH_CHANNEL_ID:
            return

        # 入室時刻を取得
        dt_now = datetime.datetime.utcnow() + datetime.timedelta(hours=DIFF_JST_FROM_UTC)
        dt_now_text = dt_now.strftime('%Y-%m-%d %H:%M:%S')

        # 入室者ID, 入室時刻をDBに保存
        try:
            conn = sqlInit()
            INSERT(conn, str(member.id), str(dt_now_text))
            sqlExit(conn)
            send_text = f'**{member.name} ({member.nick})** \n  入室： {dt_now_text}'
        except Exception as e:
            send_text = f'{e}'

        # 入室時は特に通知はしない(HerokuLogには出力する)
        print(send_text)

    # 退室時
    elif after.channel is None:
        if before.channel.id != WATCH_CHANNEL_ID:
            return

        # 退室時刻を取得
        dt_now = datetime.datetime.utcnow() + datetime.timedelta(hours=DIFF_JST_FROM_UTC)
        dt_now_text = dt_now.strftime('%Y-%m-%d %H:%M:%S')

        # 退室者IDを用いてDBから入室時刻を取得
        # 滞在時間を計算
        try:
            conn = sqlInit()
            result = SELECT(conn, str(member.id))
            if result is None:
                print('error: result is None')
                sqlExit(conn)
                return
            DELETE(conn, str(member.id))
            sqlExit(conn)

            dt_now_before = result[1]
            dt_now_before_text = dt_now_before.strftime('%Y-%m-%d %H:%M:%S')

            time_text = getDurationTimeText(dt_now_before, dt_now)
            send_text = f'**{member.name} ({member.nick})** \n  入室： {dt_now_before_text}\n  退室： {dt_now_text}\n  勉強時間： {time_text}'
        except Exception as e:
            send_text = f'{e}'

        print(send_text)

        # 指定のテキストチャンネルに通知
        send_channel = client.get_channel(SEND_CHANNEL_ID)
        await send_channel.send(send_text)

# token にDiscordのデベロッパサイトで取得したトークンを入れてください
token = getenv('DISCORD_BOT_TOKEN')
client.run(token)
```