💡

Discord Bot経由でレシートをGoogle Drive/Sheetに保存したい

に公開

作りたいもの

Discord Botに次のような機能を搭載したものが欲しい

  • レシートの値段をspread sheet等に保存
  • 買ったもののカテゴリーも上と同じように保存
  • 画像をGoogle Driveに保存

作ろうと思ったわけ

プログラミングを始めて2年になりますが、個人開発や業務でのプログラミング経験があまりないため、何か自分で作ってみたいと思いました。普段は競技プログラミングに取り組んでいますが、実用的なツールにも挑戦してみたいと考えています。

私情ですが、いつもGoogle Driveにレシートの画像をアップロードし、月末にまとめてGoogle Spreadsheetに記録して(もらって)いるのですが、量が多くなりすぎて大変です。

そこで、日常的に使っているDiscord上で、簡単にレシートの操作できるBotを作ってみようと思いました。Discordは私の普段の連絡手段でもあり、Botの開発経験も少しあるため、選びました。

実装

今回はDiscord.pyを使っていきます。

メインの機能として

  1. ユーザーがレシートの画像を送信
  2. Botがそれに返信し、ユーザーがTextInputに値段及びカテゴリーを入力する
  3. BotがDriveに画像を保存、スプレッドシートに入力された内容を記録

必要な機能に

  1. レシートを送信するチャネルの選択
  2. 編集するSpreadsheetのURLの設定
  3. Upload先のURLの設定

Config Class

Config Classを作り、アプリの各種設定を一括管理していきます。

主な機能として

  • 設定ファイル(.env/.json)の読み込み
    読み取り専用の.envファイルにBotのTokenや利用できるユーザーのIDを保存します。
    利用できるユーザーのIDを定めておくことにより、他の人がBotを通して各種Googleアプリにアクセスするのを防いでみます。
    .jsonはBotを操作時に変更可能なURLやIDを保存するためにしようします。

  • Google Workspace APIの認証
    DriveやSpreadsheetにアクセスできるようにします。
    こちらを参考にしながら作りました。
    https://developers.google.com/workspace/sheets/api/quickstart/python?hl=ja

  • 各種設定を.jsonに保存

class Config:
    def __init__(self, config_file='config.json', token_file='token.json'):
        self.config_file = config_file
        self.token_file = token_file

        load_dotenv()
        self.allowed_user_id = int(os.getenv('allowed_user_id', '0'))
        self.bot_token = os.getenv('BOT_TOKEN')

        try:
            with open(self.config_file, 'r', encoding='utf-8') as f:
                data = json.load(f)
        except Exception as e:
            print(f"Error loading config file: {e}")
            exit(1)

        self.google_drive_folder_id = data.get('google_drive_folder_id', '')
        self.spreadsheet_id = data.get('spreadsheet_id', '')
        self.reciept_channel_id = int(data.get('reciept_channel_id', 0))
        cats = data.get('categories', [])
        self.categories = cats if isinstance(cats, list) else [c.strip() for c in cats.split(',') if c.strip()]

        scopes = [
            'https://www.googleapis.com/auth/spreadsheets',
            'https://www.googleapis.com/auth/drive.file',
        ]
        try:
            self.creds = service_account.Credentials.from_service_account_file(
                self.token_file, scopes=scopes
            )
            self.creds.refresh(Request())
        except Exception as e:
            print(f"Google API credentials error: {e}")
            exit(1)

    def _save(self, key, value):
        try:
            with open(self.config_file, 'r', encoding='utf-8') as f:
                data = json.load(f)
            data[key] = value
            with open(self.config_file, 'w', encoding='utf-8') as f:
                json.dump(data, f, ensure_ascii=False, indent=4)
        except Exception as e:
            print(f"Error updating config file: {e}")

    def set_folder_id(self, folder_id):
        self.google_drive_folder_id = folder_id
        self._save('google_drive_folder_id', folder_id)

    def set_spreadsheet_id(self, spreadsheet_id):
        self.spreadsheet_id = spreadsheet_id
        self._save('spreadsheet_id', spreadsheet_id)

    def set_receipt_channel_id(self, channel_id):
        self.reciept_channel_id = channel_id
        self._save('reciept_channel_id', channel_id)

    def add_category(self, category):
        if category not in self.categories:
            self.categories.append(category)
            self._save('categories', self.categories)

set_id系を助長に書いてしまったのは反省です。あまり良い実装が思いつきませんでした。

DriveとSheetにUploadするクラス

こちらも認証同様、この2つを参考に書きました。

https://developers.google.com/workspace/drive/api/guides/manage-uploads?hl=ja#python

https://developers.google.com/workspace/sheets/api/quickstart/python?hl=ja

# ----- Google Drive Uploader -----
class GoogleDriveUploader:
    def __init__(self, folder_id, config):
        self.folder_id = folder_id
        self.config = config

    def upload_file(self, file_path):
        try:
            drive = build('drive', 'v3', credentials=self.config.creds)
            meta = {'name': os.path.basename(file_path), 'parents': [self.folder_id]}
            media = MediaFileUpload(file_path, mimetype='image/jpeg')
            res = drive.files().create(body=meta, media_body=media, fields='id').execute()
            return f"https://drive.google.com/uc?id={res.get('id')}"
        except Exception as e:
            return f"[Error uploading file]: {e}"

# ----- Spreadsheet Recorder -----
class SpreadSheetRecorder:
    def __init__(self, spreadsheet_id, config):
        self.spreadsheet_id = spreadsheet_id
        self.config = config

    def record_data(self, date, image_url, category, price):
        try:
            gc = gspread.service_account(filename=self.config.token_file)
            sheet = gc.open_by_key(self.spreadsheet_id).sheet1
            sheet.append_row([date, image_url, category, price])
            return "saved"
        except Exception as e:
            return f"[Error recording data]: {e}"

ドキュメント通りに書いたらちゃんと動きました。よかったです。

Discord Bot側の実装

Discord Bot側の実装は以前の作品から引っ張ってきたので割愛します。

このような感じになりました。

写真を送るとBotが反応して、カテゴリーをユーザーが選択

ボタンを押すとフォームが出てくるので金額を入力する

アップロードに成功すると、このようなメッセージが返ってくる
(黒塗り部分はリンク)

今はこのような感じに保存しています。

アップロードもちゃんとできてますね。

良さそうです。

感想

金額入力も少しばかり手間なので、精度はあまり良くなくて良いので、画像認識等で値段を抽出するようにしたいですね。また今度時間ができたらやってみます。

Discussion