🦔

Discordを通じてスプレッドシートで収支管理をするBotを作ってみる

14 min read 2

はじめに

この記事は「あんまDiscord.pyについて書いてる人居ない気がするし、自分が作ったものの備忘録としても書いておいて損はないんじゃないか」という動機を元に、PythonでDiscord.pyとgspreadを利用してBotを作る過程を記録したものです。
自分自身、いつどこでPythonを習ったか忘れたくらいの適当加減だし、プログラムを書く人間としてはあまりにも世界を知らなさすぎているので、こういったアウトプットを経て色々と得られるものがあると感じた為書き残しています。
Pythonに対して強いこだわりがあるわけではなく、たまたま書けるのがPythonだった、というだけだし、他の言語もよく知らないので、Pythonの長所を潰す書き方をしていたり、「その書き方するなら○○言語でいいでしょ」といったケースも多々あるかなと思います。Python以前の問題や、言葉の使い方が適切でないというものもあると思います。
ただ、そういった点について自分で気付くことも出来ていない現状を考えると、とりあえずアウトプットを行うことは重要かなと感じました。

何をするのか

収支管理があまりにもガバガバで、アプリや手帳にメモをしようとしても面倒くさがって頓挫するという経験を経て、とりあえず身近に扱っているコミュニケーションツールのDiscordで動くBot作ってそいつに報告するだけのBot作ってみたら流石に管理するんじゃないかなと思った為、Discord.pyライブラリを用いたDiscordのBotを作り、そいつにgspreadライブラリを用いてスプレッドシートへの書き込みをしてもらおうという試み。
以前にもDiscordのBotは何度も作っており、gspreadを利用したスプレッドシートへの干渉もちょっと経験があったので、何かと勉強にもなるし丁度良いかなと思った。

具体的なBotの挙動

Botにダイレクトメッセージを送ることで、収入と支出の記録をさせます。
テストということでとりあえず年明けに買ったFINAL FANTASY VII REMAKE(とてもたのしい)とか、Amazon Primeの学生価格での支払いとかを入力してみました。

"収入,名目,値段"や"支出,名目,値段"のフォーマットでBotにメッセージを送ることで、Botが月ごとに分かれたシートで収支を記録します。
"今月の収入"、"今月の支出"と送ることでBotからも当月分の収支を確認出来るようにしていますが、基本的にはスプレッドシートにアクセスして確認すればいいかなと思ったので、そこら辺の作りは結構雑です。

そして、収支を記録した際、月ごとのシートとはまた別にまとめのシートに月ごとの収支を一覧で表示させています。

とても大雑把ですが、これでちまちま記録をぶっこめば月ごとに収支を管理できるという寸法です。
まあ正直、Discordとか用いずに既存のアプリを利用したほうが速いだろうし見やすいと思います。

コードを書く前の準備

コードを書く前にDiscordのBotを作成したり、GoogleのAPIとかアレコレしたりしないと始まりません。

DiscordのBotを作成する。

Discord Developer Portalにアクセスして、まずアプリケーションを作成します。
右上にあるNew Applicationのボタンから、適当に名前をつけてアプリケーションを作成します。
この際、アプリケーションの名前がそのままBotの名前になるわけではないのでわかりやすい名前にでもしておきましょう。

今回は適当にMoney Keeperというアプリケーションを作成しました。
次に、今度は実際に稼働するBotを作成します。
作成したアプリケーションをクリックした後、左側の一覧からBotのタブを開いて、出てきたページでBotを作成します。
Botを作成したら、後で使うのでBotのトークンをメモっておきます。

次に、とりあえず一度Botを適当なサーバーに置いて置く必要があるので、OAuth2のタブを開きScope一覧からBotのチェックボックスをつけて認証URLを生成します。
一覧の下部に出てきたURLを開き、どこでもいいのでサーバーに配置します。

これでBot側の準備は完了です。

スプレッドシートにアクセスする準備をする

この工程は二度目なんですが、自分も参考文献を眺めながら適当にやった形なので詳しい部分を把握しきれていません。
参考リンクとして最後にも書いてありますが、こちらの記事を参考に導入しています。

実際のコードについて

何個かに分けて説明したいと思います。あまり美しいコードではないと思います。
というか、人にコードを見せるのが初めてです。

準備

まず冒頭の準備的な部分について

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

TOKEN = '*************************************************************'
client = discord.Client()

scope = ['https://spreadsheets.google.com/feeds','https://www.googleapis.com/auth/drive']

credentials = ServiceAccountCredentials.from_json_keyfile_name('*************************', scope)

gc = gspread.authorize(credentials)

SPREADSHEET_KEY = '**********************************'
workbook = gc.open_by_key(SPREADSHEET_KEY)

今回使用するdiscord.pyとgspreadと、gspreadの使用に伴うoauthのやつをimportしています。
シートを日付ごとに管理しているため、それに関連してdatetimeも。
TOKENの部分には先程取得したDiscordのBotのトークンが入ります。
使うのはまた後になりますが、とりあえずこの辺の並びに入れておきます。
scopeの部分は参考記事に書いてあった通りに書いているだけなので詳細な仕様はわからないのですが、この2つのAPIを記述しておかないとリフレッシュトークンを3600秒ごとに発行しなきゃいけないそうです。
そんでもって、認証情報をまとめてスプレッドシートにつなげています。
SPREADSHEET_KEYにはスプレッドシートのURLから拾ってきたものが入ります。
異なる何枚かのシートを運用することになりますが、ブック自体は一つだけしか利用しないので、ここでシートを開いておきます。
これで、DiscordのBotとスプレッドシートの準備ができました。

シート書き込みなどの諸々の関数

シートに書き込みを行ったりする部分についてはごちゃごちゃしがちなので、関数を色々作って分けてみることにしました。

まず、日付を確認して当月分のワークシートが存在するかどうかを確認し、あればシートのクラスを、なければ新しくシートを作成してそのクラスを返す関数を作ります。

def monthcheck():
    worksheet_list = workbook.worksheets()              #ワークシートの一覧を取得
    today = datetime.date.today().strftime('%Y%m')    #今日の日付を取得し文字列の形で記録する
    exist = False
    for current in worksheet_list :
        if current.title == today :
            exist = True                                #今月の分のシートがあればフラグを立てる
    if exist == False :                                 #今月の分のシートがなければここで作成する
         workbook.add_worksheet(title=today, rows = 100, cols = 4)      #余裕を持って行数は100行、幅は4行のシートを新規作成する
         newsheet = workbook.worksheet(today)           #作成したシートの初期値を設定する
         newsheet.update('A1','収入')
         newsheet.update('C1','支出')
    return workbook.worksheet(today)                #作成したシートを戻り値として返す。

まず、ブックからワークシートの一覧を取得します。
シート名は2020年の9月分なら202009、2021年1月分なら202101、といった形に日付を文字列にそのまま変換したものを用いています。
datetimeを利用して今日の日付を取得、それとstrftime関数で上記のフォーマットの文字列に変換し、当月分のシートがあるかを確認しています。
もしなかった場合は、確認に使った文字列をそのままシートの名前として利用し新しいシートを作成し、最低限の見出しを入力します。

return workbook.worksheet(today)

当月分がなければ直前にシートを作成する、という工程を挟むことで、戻り値はこの書き方をするだけで自動的に当月分のシートを指定してくれることになります。
他の関数は、この関数で取得したシートをぶん投げて用いることになります。

次に、受け取った収支の情報を書き込む関数です。

def add_income(worksheet, name, amount):#引数で受け取ったシートに引数で受け取った収入を記録する関数
    lists = worksheet.get_all_values()  #シートの内容を配列で取得
    rows = len(lists) + 1               #入力されているデータの数を取得し、末端に書き込むときのインデックスとして利用する為+1する
    worksheet.update_cell(rows,1,name)  #引数で受け取った名前をセルに入力
    worksheet.update_cell(rows,2,amount)#引数で受け取った金額をセルに入力
    
def add_spending(worksheet, name, amount):#引数で受け取ったシートに引数で受け取った支出を記録する関数
    lists = worksheet.get_all_values()  #シートの内容を配列で取得
    rows = len(lists) + 1               #入力されているデータの数を取得し、末端に書き込むときのインデックスとして利用する為+1する
    worksheet.update_cell(rows,3,name)  #引数で受け取った名前をセルに入力
    worksheet.update_cell(rows,4,amount)#引数で受け取った金額をセルに入力

あまりややこしい感じでは無いと思います。
引数として受け取ったシートの内容を配列として取得し、要素数を数えることでシートの末端がどこかを探り当てます。
要素数=末端のインデックスとなるので、そこに+1をし、uppdate_cellで受け取った名目、金額をセルの末端に書き込みする形です。
この後、合計などの部分を確認する関数はまた別の処理として用意しています。
おそらく、その部分の処理をこれらの関数の一環として呼び出すことも出来たと思いますが、構造がごちゃごちゃしそうで怖かったので、あまり合理的な理由ではないですが分けています。

合計を計算し、シートの上部やまとめのシートの部分を更新する関数が最もごちゃごちゃしています。一つの関数にまとめられていますが、内部での処理は「当月分のシートの収支の合計の記録」と、「まとめのシートの収支の合計の記録」で分かれています。
前者は非常に短く済んでいます。

def check_total(worksheet):             #引数で受け取ったシートに収支の合計を記録する関数
    lists = worksheet.get_all_values()  #シートの内容を配列で取得
    rows = len(lists)                   #入力されているデータの数を取得
    worksheet.update('B1','=SUM(B2:B'+str(rows)+')',value_input_option='USER_ENTERED')  #SUM関数を用いて収入の合計をセルに入力
    worksheet.update('D1','=SUM(D2:D'+str(rows)+')',value_input_option='USER_ENTERED')  #SUM関数を用いて支出の合計をセルに入力

シートの内容を配列として取得して要素の数を確認し、冒頭の1セルを除いた収支の列の合計をSUM関数で計算するようにしています。この際、セルへの書き込みにvalue_input_option='USER_ENTERED'を添えておかないと、SUM関数を書いても=SUMという文字列として捉えられてしまい関数として正常に動かないため注意が必要です。

上記の部分から続いて、check_total関数の残りの処理です。

    
    today = datetime.date.today().strftime('%Y/%m')          #今日の日付を取得し文字列の形で記録する
    con_worksheet = workbook.worksheet('まとめ')                    #記録をまとめているシートを取得する
    conclusion = con_worksheet.get_all_values()
    exist = False
    index = 1
    for day in conclusion :                                     #まとめに今月の分の記載があるかを確認する
        if day[0] == today :
            exist = True
            break
        index = index + 1
   
    if exist == False :                                         #記載がなければ、末端に追加する準備をする
        index = len(conclusion) + 1
        con_worksheet.update_cell(index,1,today)
            
    con_worksheet.update_cell(index,2,worksheet.acell('B1').value)
    con_worksheet.update_cell(index,3,worksheet.acell('D1').value)

    conclusion = con_worksheet.get_all_values()                 #まとめが記載し終わった後のシートの要素数を確認する
    con_rows = len(conclusion) 
    
    con_worksheet.update('B2','=SUM(B3:B'+str(con_rows)+')',value_input_option='USER_ENTERED')  #SUM関数を用いて収入の合計をセルに入力
    con_worksheet.update('C2','=SUM(C3:C'+str(con_rows)+')',value_input_option='USER_ENTERED')  #SUM関数を用いて支出の合計をセルに入力
    return

まず最初に、こちらでも日付を確認し、文字列に変換します。
今回は年/月の形でスラッシュを含む文字列になります。
次に、まとめが記録されているシートを変数con_worksheetに取得し、そのままシート全体を配列に落とし込みます。
二次元配列として格納されたシートをfor文で順番に回り、今日の日付が書かれている行があるかを確認します。記録が確認されれば変数existの値にTrueが代入され、その時点でループを中断します。ループ中のカウントで、もし記録が確認された場合はどの行にあったかを把握しています。
記録が確認されなかった場合、末端に追加するために要素数+1で行を指定し、そこに今月の日付を書きます。(年/月の形)
最後に、共通の処理としてSUM関数を用いた当月の収支合計をまとめのシートに書き込みます。

あとは、処理を簡略化するためのちょっとしたおまけの関数です。



def check_income(worksheet):            #引数で受け取ったシートの収入合計を返す関数
    return worksheet.acell('B1').value  #受け取ったワークシートの収入合計の部分を返す

def check_spending(worksheet):          #引数で受け取ったシートの支出合計を返す関数
    return worksheet.acell('D1').value  #受け取ったワークシートの支出合計の部分を返す

そのまま、当月分の収入と支出をそれぞれ返す関数です。

DiscordのBotとしての挙動

最後に、Botとしてどう振る舞うかを書けばこのBotは完成します。


@client.event
async def on_message(message):          #メッセージを受け取ったときの挙動
    if message.author.bot :             #拾ったメッセージがBotからのメッセージだったら(=Bot自身の発言だったら弾く)
        return

    if type(message.channel) == discord.DMChannel : #受け取ったメッセージがDMであることを確認する(置いてあるサーバーでむやみに動かないようにする)

最初の@client.eventについてですが、discord.pyでbotを動かすコードを書く際それぞれの関数ごとに書かないと動かず、結局何なんだろうと思って調べた時「よくわからん……」となってしまい、要するにお恥ずかしながら何が起きてるのかあんま解っていません。
続いてasync def on_message(message):から、Botがメッセージを受け取ったときの振る舞いを書くことになります。このメッセージというのは、要するにBotから見えている全てのメッセージです。Botが配置されているサーバーの発言は全部吸い込むし、DMも吸い込みます。
今回はDMでの運用になるため、後に判断して他を切り捨てるようにしています。
messageオブジェクトにはいろんなパラメータがありますが、それについてはDiscord.pyの公式ドキュメントなどに目を通せば比較的容易に解るかと思うので、今回は実際に利用したものだけについて書いていきます。

message.author.botにメッセージの発言主がBotかどうかがBoolean型で記録されています。
なので、それをそのまま利用しメッセージがBotによるものであればそこで処理を終了するようにしています。

Botの発言を弾いたら、DMチャンネルでのメッセージかどうかを判別し、DMだった場合のみ処理を行うようにします。

2021/05/02 追記

コメント欄にて、このDMかどうかの判別についてdiscord.ext.commandに含まれるデコレータ@commands.dm_only()を用いることでも判別を行うことが出来るという指摘を頂いた為確認しました。
こちらを用いる場合、

@client.event
async def on_message(hoge):
   ...

という記述ではなく、commands.botを用いた

@client.command()
@commands.dm_only()
async def command(hoge,huge):
   ...

といった別の書き方が必要になりますが、こちらは接頭詞を指定することでコマンドを定義し引数を受け取ることが出来る為、今回のBotについてもon_messageから内容を取得し判別するより簡略化が可能であると考えます。
これに伴い書き換えるとなると追記の域を超えてしまうので、落ち着いてから処理を書き直したものを新たに作り、その際にまた記事をアップロードしようと思います。

        worksheet = monthcheck()

先程作った関数で、今回のやり取りに利用するシートを取得します。

        if message.content == 'シート' :
            await message.channel.send('https://docs.google.com/spreadsheets/d/******************************************/edit?usp=sharing')
            return
        if message.content == '今月の収入' :         #収入の確認だったら取得し返信して処理を閉じる
            await message.channel.send('今月の収入は'+str(int(check_income(worksheet)))+'円です。')
            return
            
        if message.content == '今月の支出' :         #支出の確認だったら取得し返信して処理を閉じる
            await message.channel.send('今月の支出は'+str(int(check_spending(worksheet)))+'円です。')
            return

最初に、収支の書き込み以外を処理するためにとりあえずmessage.contentに格納されたメッセージの本文を伺います。
シートと書かれていれば収支のシートのURLを、今月の収入/今月の支出と書かれていればそれぞれを関数を用いて取得し返します。
await message.channel.send('本文')でメッセージをBotから送り返せます。
メッセージからやり取りがされたチャンネルを引き出し、チャンネルのオブジェクトからsend関数を引き出している状態です。(正しい認識かどうか不安)

        receipt = message.content.split(',')

        if len(receipt) != 3 :                      #支出、収入の入力がフォーマットに沿ってなかったら弾く
            await message.channel.send('入力が無効')
            return
        receipt[2] = receipt[2].replace('円','')     #金額に円と付いてたらその部分を取り除く
        if receipt[0] == '収入' :
            add_income(worksheet,str(receipt[1]),int(receipt[2]))   #収入を書き込む
            check_total(worksheet)  #収支の合計をチェックし入力させる
            await message.channel.send(''+receipt[1]+'による収入'+receipt[2]+'円を記録しました。\r\n記録後の今月の収入は'+str(int(check_income(worksheet)))+'円です。')
            return
        elif receipt[0] == '支出' :
            add_spending(worksheet,str(receipt[1]),int(receipt[2]))   #収入を書き込む
            check_total(worksheet)  #収支の合計をチェックし入力させる
            await message.channel.send(''+receipt[1]+'による支出'+receipt[2]+'円を記録しました。\r\n記録後の今月の支出は'+str(int(check_spending(worksheet)))+'円です。')
            return
        else :
            await message.channel.send('入力が無効')
            return

次に、そのどれも該当しなかった場合は、収支の記録の処理に移ります。
まず、コンマ区切りで本文を分割して配列に収め、コンマで分けた際配列の要素数が3でなければ入力がおかしいためこの時点でカットし入力が無効と返します。
次に、頭の要素の文字列をif文やelif文で判別して処理を分け、どれでもなければこれもまた入力が無効と返しています。
シートとコンマ区切りした配列を引数として渡してadd_income関数、add_spending関数を走らせ、その後にcheck_total関数でそれぞれのシートの合計を確認させます。
それらの処理を終えたら、先程と同じようにメッセージを返します。

最後に、Botを動かします。

client.run(TOKEN)

最初に取得したBotのトークンをここでようやく回収します。
この文が実行されると、Botのステータスもオンラインになり、処理を待ち受ける状態になるようです。

まとめ終わっての感想や反省点など

今まで何度もDiscordのbotを作ってきたけれど、いざまとめるぞ!となると、結構処理がブラックボックス状態で何が起きているのかわかっていないけど動いている、という部分が多いことが判明しました。
今まで通りただ書いているだけではわからなかった点なので、見直す機会としてとてもいいきっかけになったと思う。

コード的な反省点で言うと、該当するシートがあるか、要素があるか、という点についてin演算子とか使えばもっとうまく出来るような気もするな、と思いつつも、楽な方法に逃げてしまったり、「もうちょっとなんとかなる気もするけどまあいいか」と妥協している部分は自覚できる範囲でも残っているので、そういった点も今のままでいいやとしないようにしたい。

追記

コードの区切りが少なく長くなりがちだったので、見やすくするためにもう少し小分けにしました。また、参考リンク(Google Cloud Platform関連)を割愛したままだったので追加しました。
ついでに、感想を少し整理。
もっと色々使うかと思っていたら、全然Discord.pyの機能を引き出せずに話が終わってしまったため、別の機会があればDiscord.pyについてもう少し踏み込んだ話をしたいと思います。

参考にしたリンクなど

gspreadの公式ドキュメント
Discord.pyの公式ドキュメント
【もう迷わない】Pythonでスプレッドシートに読み書きする初期設定まとめ
gspreadライブラリの使い方まとめ!Pythonでスプレッドシートを操作する(gspreadの基本的な扱いについて)
note.nkmk.me(Pythonの基本的な文法などについて)

Discussion

@client.eventはデコレータで、これらを関数前に書くと関数に機能が追加された状態で実行されます。
特にdiscord.ext.commandsの方に色々あって、例えば@commands.dm_only()をつけるとDMでだけ動作するようになりますよ。

https://discordpy.readthedocs.io/ja/latest/ext/commands/api.html#checks

ご教授いただきありがとうございます!
この記事を書いた後に@client.eventの部分がデコレータというものであることは調べたんですが、discord.pyにおいて@client.event以外のデコレータを使ったことがなかった(知らなかった)ので、この機会に少し触って記事本文に追記をしようと思います。

ログインするとコメントできます