Discord と Google API で予定・タスク・家計簿を一元管理する自分専用 Bot を作ってみた

Discord と Google API で予定・タスク・家計簿を一元管理する自分専用 Bot を作ってみた

Google APIと連携して Discord で予定・タスク・支出を一元管理する Bot を作成しました。 Discord Bot の作成から Google API との連携、実装のポイントまでを解説しています。
2026.07.24

Google API と連携し、Discord 上で予定・タスク・支出などを一元管理できる Bot を作成しました。
仕組みはシンプルです。Discord のチャンネルに投稿した内容を、Google スプレッドシートや Google カレンダーへ自動で記録します。
本記事では、この Bot を一から作る手順を、Discord Bot の作成Google API との連携(サービスアカウント) を中心にまとめます。

作ったもの(機能一覧)

用途ごとに Discord チャンネルを分けています。

  • #タスク:タスク登録/一覧/完了(✅リアクション)/削除・編集・延期(スヌーズ)/繰り返しタスクの登録・一覧・削除
  • #予定:自然文から Google カレンダーへ登録(時刻を省略すると終日予定)/直近の予定一覧/繰り返し予定
  • #家計簿本 880 のような入力で支出を記録/今月合計/今月一覧
  • #欲しいもの:商品名・URL・金額を順不同で登録/冷却期間チェック(期間終了後に Bot が確認し、リアクションで「買った✅/いらない🗑️/延長⏳」を選択)/購入報告で削除
  • #一般:毎朝のサマリー、予定の 30 分前リマインド、サブスク引き落とし前アラート、週末の振り返りなど自動通知

操作例

例えば、#予定 チャンネルに予定を投稿すると、Bot が内容を解析して Google カレンダーへ登録します。
discord bot 動作画面discord bot 動作画面
上の例では、Discord に投稿した予定を Bot が解析し、Google カレンダーへの登録結果を返しています。
操作系のコマンドは /list /add /del のように /+英語 で統一し、別名(/del=/rm など)も受け付けます。/help でチャンネルごとのコマンド一覧を出せるほか、/undo で直前の操作を取り消せます。

補足:本記事の /list などは、Discord の アプリケーションコマンド(いわゆるスラッシュコマンド)ではなく、「/ で始まる通常のメッセージ」 を Bot が自前で解釈するものです。/ は Discord クライアントがスラッシュコマンドを呼び出すトリガーとして使う文字のため、入力時にコマンド候補の UI が開き、サーバー内に同名のスラッシュコマンドが存在するとそちらが優先されて通常メッセージとして送信されないことがあります。個人サーバーでの運用であれば大きな問題にはなりにくいですが、気になる場合はプレフィックスを ! などに変えるか、正式なスラッシュコマンド(discord.pyapp_commands)への移行を検討してください。

環境

  • 言語:Python 3.11 以上(動作確認: Python 3.13)
  • 主なライブラリ:discord.py / gspread / google-api-python-client / google-auth / dateparser
  • データの保存先:Google スプレッドシート(タスク・家計簿・欲しいもの・サブスク・繰り返しタスク)+ Google カレンダー

事前準備

1. Discord Bot を作成する

  1. Discord Developer Portal にアクセスし、「New Application」でアプリを作成します。Discord Developer Portal のアプリケーション作成画面Discord Developer Portal のアプリケーション作成画面
  2. 左メニューの「Bot」を開き、Bot のトークンを取得します(このトークンは他人に見せない・公開しないこと)。これが Discord 側の認証情報にあたります。テキストファイルなどに平文で保存せず、後述の手順で macOS のキーチェーンに登録します。
  3. 同じ「Bot」ページの Privileged Gateway IntentsMessage Content Intent を有効化します。

これを有効にしないと、サーバーのチャンネルに投稿された通常メッセージについては、Bot は message.content のほか、ユーザー入力由来の embedsattachmentscomponents などを受け取れず、空値になることがあります(※Bot への DM・Bot へのメンション・Bot 自身が送信したメッセージなどは例外として、この Intent がなくても本文を受け取れます。本 Bot のように「チャンネルへの投稿を読み取る」用途では有効化が必須です)。Discord Developer Portal のアプリケーション作成画面
4. 「OAuth2 > URL Generator」で bot スコープを選び、必要な権限(チャンネルの閲覧、メッセージ送信、リアクション追加、メッセージ履歴の閲覧、必要に応じてメッセージ管理など)を付与した招待 URL を生成し、自分のサーバーに Bot を招待します。Discord Developer Portal のアプリケーション作成画面

補足:Developer Portal で Intent を有効にするだけでは不十分で、コード側でも intents.message_content = True を設定する必要があります(後述のコード参照)。

2. Google Cloud 側の準備(サービスアカウント)

スプレッドシートとカレンダーを「Bot(=人間ではない)」から操作するため、本記事ではサービスアカウントを使います。なお、サービスアカウントは通常の Google アカウントとは別の主体として扱われるため、操作対象のスプレッドシートやカレンダーをサービスアカウントに明示的に共有する必要があります。個人のメインカレンダーを操作する場合は、対象カレンダー側でサービスアカウントに予定の変更権限を付与します。

  1. Google Cloud Console でプロジェクトを作成(または既存のものを選択)。
  2. 「API とサービス > ライブラリ」から、次の API を有効化します。Google Cloud console画面
    • Google Sheets API
    • Google Calendar API(以下、Calendar API)
    • Google Drive API(※gc.open("スプレッドシート名") のように名前でスプレッドシートを開く場合のみ必要です。本記事では ID で開くため必須ではありません)
  3. 「API とサービス > 認証情報」→「認証情報を作成 > サービスアカウント」でサービスアカウントを作成します。Google Cloud console画面
  4. 作成したサービスアカウントの「キー」タブで「鍵を追加 > 新しい鍵を作成」を選び、キーのタイプとして JSON を選択して鍵ファイルをダウンロードします。この鍵ファイルも認証情報のため、後述の手順でキーチェーンに登録し、登録後は平文の JSON ファイルを削除します。
  5. JSON の中の client_emailxxxx@xxxx.iam.gserviceaccount.com)を控えておきます。このメールアドレスにスプレッドシートとカレンダーを共有するのがポイントです。

※なぜ「名前で開く」ときだけ Drive API が要るのか?
Sheets API は「スプレッドシート ID を指定して中身を読み書きする」API で、「“家計簿” という名前のスプレッドシートはどれ?」という名前検索の機能は持っていません。名前と ID の対応やファイル検索は Drive が管理する領域なので、gc.open("スプレッドシート名") は、内部で Drive API を使ってスプレッドシート名から ID を取得してから開きます。したがって名前でスプレッドシートを開くなら Drive API の有効化と Drive スコープが必要です。本記事では、同名ファイルの誤検出を避けられ、権限も最小化できる open_by_key(ID) を採用します。

3. スプレッドシートを用意して共有する

1 つのスプレッドシート内に、以下のシート(タブ)を作成します。シート名と 1 行目のヘッダー(列名)は、コードが参照している名前と完全に一致させてください。 ここがズレると get_all_records() のキーが合わず、検索や集計が動きません。

シート名 1 行目のヘッダー(列)
タスク 日時 / タスク内容 / ステータス / 期限 / 優先度
家計簿 日時 / 項目 / 金額
サブスク 毎月の支払日 / 項目 / 金額
欲しいもの 日時 / 商品名 / 金額 / URL / ステータス
繰り返しタスク パターン / タスク内容 / 優先度 / 最終生成日
作成したら、次の 2 点を行います。
  1. スプレッドシートの「共有」から、先ほど控えたサービスアカウントのメールアドレスを「編集者」として追加します。これを忘れると、API 認証自体は成功していても対象スプレッドシートにアクセスできません。gspread 公式ドキュメントでは、サービスアカウントにスプレッドシートを共有していない場合、gspread.exceptions.SpreadsheetNotFound が発生すると説明されています。スコープ不足や API 未有効化の場合は、別途 403 系の APIError になることがあります。
  2. スプレッドシートの URL(https://docs.google.com/spreadsheets/d/<スプレッドシートID>/edit)からスプレッドシート ID を控えておきます。これを後述の SPREADSHEET_ID に設定します。

4. Google カレンダーを共有してカレンダー ID を取得する

  1. Google カレンダーの「設定」→ 対象カレンダーの「特定のユーザーやグループと共有」で、サービスアカウントのメールアドレスを追加し、権限を「予定の変更」にします。
  2. 同じ設定画面の「カレンダーの統合」にある カレンダー ID(自分用なら通常は Gmail アドレス)を控えます。これを CALENDAR_ID に設定します。

実装

ライブラリのインストール

pip install "discord.py>=2.3" gspread dateparser google-api-python-client google-auth keyring

設定と認証

認証情報(Discord Bot トークンとサービスアカウントキー)は、ローカル PC に平文テキストとして保存せず、macOS のキーチェーンに保存して実行時に読み出します。Python からは keyring ライブラリ経由でキーチェーンにアクセスできます(macOS では既定のバックエンドがキーチェーンになります)。
まず、取得した認証情報をキーチェーンへ登録します(初回のみ)。このとき、トークンを python3 -c のワンライナーやコマンドライン引数に直接書くと、シェルの履歴ファイル(~/.zsh_history~/.bash_history)にトークンが平文で残ってしまい、キーチェーンに移行する意味が薄れてしまいます。そこで、getpass による対話入力で登録します(シェルの設定で行頭スペース付きコマンドを履歴に残さない方法(bash の HISTCONTROL=ignorespace、zsh の setopt HIST_IGNORE_SPACE)もありますが、本記事では確実な対話入力を採用します)。
以下を register_secrets.py などとして保存し、1 回だけ実行します。

import getpass
import json
import pathlib
import keyring

KEYCHAIN_SERVICE = "life-bot"

# Discord Bot トークン(入力内容は画面に表示されず、履歴にも残らない)
token = getpass.getpass("Discord Bot Token: ")
keyring.set_password(KEYCHAIN_SERVICE, "discord_token", token)

# サービスアカウントキー(JSON)の中身を登録
sa_path = input("service_account.json のパス: ").strip()
sa_text = pathlib.Path(sa_path).read_text()
json.loads(sa_text)  # JSON として読めるかの形式チェック
keyring.set_password(KEYCHAIN_SERVICE, "google_service_account", sa_text)

print("キーチェーンへの登録が完了しました。")

登録が終わったら、平文の鍵ファイルを削除します(登録スクリプト自体に秘密情報は含まれませんが、不要になったら合わせて削除して構いません)。

rm service_account.json

コード側では、キーチェーンから読み出した値をそのまま使います。認証情報以外の動作パラメータ(スプレッドシート ID やチャンネル ID など)は認証情報ではないため、直書きのままで問題ありません。

import json
import keyring

KEYCHAIN_SERVICE = "life-bot"

DISCORD_TOKEN = keyring.get_password(KEYCHAIN_SERVICE, "discord_token")
_sa_info = json.loads(keyring.get_password(KEYCHAIN_SERVICE, "google_service_account"))

SPREADSHEET_ID    = "YOUR_SPREADSHEET_ID"   # URL の /d/ と /edit の間の文字列
CALENDAR_ID       = "YOUR_CALENDAR_ID"
NOTIFY_CHANNEL_ID = 123456789012345678   # #一般 のチャンネル ID(整数)
JST = ZoneInfo("Asia/Tokyo")
COOLING_DAYS = 3        # 欲しいものの冷却期間(日)
MAX_UNDO = 20           # Undo 履歴の保持数

認証では、必要最小限のスコープを使います。スプレッドシートは名前ではなく ID で開く open_by_key() を使うことで、Drive API による名前検索を避け、Sheets API のスコープを中心に構成できます。Calendar API も、より広い calendar ではなく、イベント操作に必要な calendar.events(https://www.googleapis.com/auth/calendar.events) を使います。サービスアカウントの資格情報は、鍵ファイルのパスを渡す from_service_account_file() ではなく、キーチェーンから読み出した JSON(辞書)を渡す from_service_account_info() で生成します。

SCOPES = [
    "https://www.googleapis.com/auth/spreadsheets",
    "https://www.googleapis.com/auth/calendar.events",
]
creds = Credentials.from_service_account_info(_sa_info, scopes=SCOPES)
gc = gspread.authorize(creds)

Discord 側は、Developer Portal での有効化に加えて、コード側でも Message Content Intent を有効化します。

intents = discord.Intents.default()
intents.message_content = True
client = discord.Client(intents=intents)

同期 I/O をブロックさせない

gspread や Calendar API はいずれも 同期(ブロッキング) なので、イベントループ内で直接呼び出すと Bot 全体の応答が遅くなる可能性があります。そのため、同期呼び出しは別スレッドへ逃がします。
ここで 1 点注意があります。google-api-python-client が内部で利用する httplib2.Httpスレッドセーフではなく、公式ドキュメントでも「リクエストを行うスレッドごとに独自の httplib2.Http() インスタンスが必要」とされています。asyncio.Lock による直列化は同時実行の抑止には有効ですが、asyncio.to_thread() は共有スレッドプールを使うため実行スレッドが毎回同じとは限らず、スレッドセーフ性の要件を満たすものではありません。そこで本記事では、Calendar API 呼び出しを専用の単一スレッド executor に集約します(スレッドごとにサービスオブジェクトを作成する方法でも構いません)。

from concurrent.futures import ThreadPoolExecutor
_calendar_executor = ThreadPoolExecutor(max_workers=1)
_calendar_service = None
def _get_calendar_service():
    global _calendar_service
    if _calendar_service is None:
        _calendar_service = build(
            "calendar",
            "v3",
            credentials=creds,
            cache_discovery=False,
        )
    return _calendar_service
def _calendar_list_sync(**kwargs):
    service = _get_calendar_service()
    return service.events().list(
        calendarId=CALENDAR_ID,
        **kwargs,
    ).execute()
async def calendar_list(**kwargs):
    loop = asyncio.get_running_loop()
    return await loop.run_in_executor(
        _calendar_executor,
        lambda: _calendar_list_sync(**kwargs),
    )

サービスオブジェクトの生成も executor 上で初回のみ行い、以降は同じ単一スレッドからのみ触るようにしています。カレンダーの登録・削除(calendar_insert / calendar_delete)も同様に、この専用 executor 経由でラップします。
あわせて、ワークブックを毎回開くのは非効率なので、初回だけ open_by_key() で開いてキャッシュし、以降は worksheet(name) だけで済ませています。取得は asyncio.Lock で直列化しています。

_workbook = None
_wb_lock = asyncio.Lock()
async def get_worksheet(name: str):
    global _workbook
    def _fetch():
        global _workbook
        if _workbook is None:
            _workbook = gc.open_by_key(SPREADSHEET_ID)   # 初回のみ開いてキャッシュ
        return _workbook.worksheet(name)
    async with _wb_lock:
        return await asyncio.to_thread(_fetch)
async def sheet_call(fn, *args, **kwargs):
    return await asyncio.to_thread(fn, *args, **kwargs)

コマンド設計(/+英語+別名+ヘルプ)

入力の表記ゆれを減らすため、操作系コマンドは / で始まる英語に統一し、別名の辞書 ALIASES/del=/rm=/remove/list=/ls/snooze=/delay など)で吸収します。全角 も受け付けます(前述のとおり、これらはスラッシュコマンドではなく通常メッセージとして Bot が解釈します)。

def parse_command(content: str):
    content = content.strip()
    if content.startswith(("/", "/")):
        body = content[1:].lstrip()
        if not body:
            return "help", ""
        parts = body.split(None, 1)
        cmd = parts[0].lower()
        rest = parts[1].strip() if len(parts) > 1 else ""
        return ALIASES.get(cmd, cmd), rest
    return None, content   # スラッシュ無し=クイック登録

ヘルプは「チャンネル名 → (コマンド, 説明) のリスト」という辞書 HELP を唯一の定義元にして /help の表示を自動生成しているので、コマンドを増やしてもヘルプが自動で追従します。未対応のコマンドが来たときも /help へ誘導します。

日本語の相対日付を正確に変換する

「来週の月曜」「明後日」などを、現在の曜日から逆算して datetime として扱える形に変換します。表示やシート保存時に YYYY/MM/DD 形式へ整形します。週の境界は「今週の月曜+7日+曜日オフセット」で計算すると全曜日で破綻しません(「再来週」は+14日)。どのパターンにも当てはまらない入力は dateparser にフォールバックします。ただし、自然文の日付解析は入力によって解釈が揺れることがあるため、重要な予定では「この日時で登録します」と Bot 側で確認メッセージを返す設計にすると安全です。

def parse_task_deadline(text: str):
    text = text.strip()
    now = now_jst()
    fixed = {"今日": 0, "きょう": 0, "明日": 1, "あした": 1, "あす": 1,
             "明後日": 2, "あさって": 2}
    if text in fixed:
        return now + timedelta(days=fixed[text])
    m = re.search(r"([月火水木金土日])", text)
    if m:
        target = WEEKDAYS[m.group(1)]
        this_monday = now - timedelta(days=now.weekday())
        if "再来週" in text:
            return this_monday + timedelta(days=14 + target)
        if "来週" in text:
            return this_monday + timedelta(days=7 + target)
        days_ahead = (target - now.weekday()) % 7 or 7
        return now + timedelta(days=days_ahead)
    return dateparser.parse(
        text,
        settings={"TIMEZONE": "Asia/Tokyo", "RETURN_AS_TIMEZONE_AWARE": True,
                  "PREFER_DATES_FROM": "future"},
    )

予定(#予定 チャンネル)側はもう少し凝った解析をしていて、7/24今月24日来週火曜 などの日付部分と、16時16時半9-12 などの時刻・時間帯部分を分けて解釈します(parse_event_when)。時刻が省略された場合は終日予定としてカレンダーに登録します。Calendar API で終日予定を登録する場合は、start.dateend.date を使い、1 日だけの予定でも end.date には翌日の日付を指定します。

定期通知は「毎分ループ」と「時刻指定ループ」を使い分ける

毎朝 7:00 などの定時通知を「毎分ループ+minute == 0 判定」でやると、処理の遅延で 1 分ズレて取りこぼしたり、二重送信したりします。日次のジョブは @tasks.loop(time=...) で時刻指定にすると扱いやすくなります。なお、tasks.loop(time=...) に渡す datetime.timetzinfo を指定しない場合は UTC として扱われるため、日本時間(JST)で実行したい場合は dtime(hour=7, minute=0, tzinfo=JST) のように tzinfo を指定します。
本 Bot では、繰り返しタスク生成(6:55)・朝のサマリー(7:00)・サブスクアラート(9:00)・欲しいもの冷却チェック(12:00)・週次レポート(20:00、日曜のみ実行)を時刻指定ループにしています。
予定の 30 分前リマインドだけは毎分監視(@tasks.loop(seconds=60))にし、通知済みのイベント ID を set で持って多重送信を防ぎます。この set は毎回「直近に取得したイベント ID」との積集合を取って掃除し、無限に成長しないようにしています。

@tasks.loop(time=dtime(hour=7, minute=0, tzinfo=JST))
async def morning_summary():
    ...

なお、各ループには before_loopclient.wait_until_ready() を仕掛けて、Discord への接続が完了する前にループが走らないようにしています。
また、on_ready() は再接続時に複数回呼ばれることがあるため、on_ready() 内でループを start() する場合は、すでに起動済みかを確認して二重起動を防ぐようにします。discord.py 2 系では、起動時の初期化処理を setup_hook() に寄せる構成も選択肢になります。

Undo(直前の操作の取り消し)

/undo で直前の操作を戻せるようにしています。追加→行削除、削除→値の復元、更新→旧値へ戻す、という具合に、操作ごとに「逆操作」をメモリ上のスタックに積んでおく方式です(保持は直近 20 件、Bot 再起動でクリアされる簡易版)。シートへの追加・削除・更新はそれぞれ append_with_undo / delete_row_with_undo / update_cells_with_undo というヘルパーに集約し、どの操作からでも同じ仕組みで Undo できるようにしています。カレンダーへの予定登録も、イベント ID を控えておいて /undo で削除できます。

動作確認

実行をしてみます。

python3 bot.py

discord bot 動作画面
discord bot 動作画面
これで Bot の動作が確認できました。

ハマりどころまとめ

Discord 関連

  • メッセージ本文が空:Developer Portal の Message Content Intent と、コード側の intents.message_content = True の両方が必要(DM や @メンションは例外的に Intent なしでも本文が取れるため、「DM だと動くのにチャンネルだと空」という現象は多くの場合これが原因)。
  • Bot がリアクションを付けられない:招待 URL 生成時に Add ReactionsRead Message History の権限を付与しているか確認。
  • / で始まるコマンドが送信されない/ はスラッシュコマンドを呼び出すトリガー文字のため、サーバー内に同名のスラッシュコマンドが存在するとそちらが優先されることがある(冒頭の補足を参照)。

Google API 関連

  • 403: insufficient authentication scopes:名前でスプレッドシートを開く gc.open() は Drive API を使うため、https://www.googleapis.com/auth/drive スコープが必要。本記事のように ID で開く open_by_key() なら Drive スコープは不要。
  • シートにアクセスできない:スプレッドシート/カレンダーをサービスアカウントのメールアドレスに共有しているか確認。共有忘れの場合は gspread.exceptions.SpreadsheetNotFound、スコープ不足や API 未有効化の場合は 403 系の APIError になることがある。
  • シート名・列名の表記ゆれ、誤字get_all_records() は 1 行目のヘッダーを辞書のキーとして扱うため、コード側の列名と一致しないと、r["列名"] の添字アクセスでは KeyError になり、r.get("列名") では None(空)になって検索や集計が静かに空振りする。
  • httplib2 のスレッドセーフ性google-api-python-client を複数スレッドから使うのは NG。呼び出しを専用の単一スレッド executor に集約するか、スレッドごとにサービスオブジェクトを作る。

運用関連

  • 定時通知の取りこぼし/二重送信:日次は tasks.loop(time=...)、毎分監視系は ID 重複チェックで防ぐ。ループ開始前には wait_until_ready() を挟む。
  • タイムゾーンdatetime.now()(naive)と UTC の混在は事故のもと。ZoneInfo("Asia/Tokyo") で統一。

まとめ

Discord を入力インターフェースにし、Google スプレッドシートと Google カレンダーをバックエンドにすることで、日常の細かな記録を一箇所に集約できました。現在はローカルでの動作のみになっているので、24 時間稼働できるようにクラウド上での稼働に切り替えていきたいと考えています。
同じように、自分専用の管理 Bot を作りたい方の参考になれば幸いです。

参考

クラスメソッドオペレーションズ株式会社について

クラスメソッドグループのオペレーション企業です。
運用・保守開発・サポート・情シス・バックオフィスの専門チームが、 IT・AI をフル活用した「しくみ」を通じて、お客様の業務代行から課題解決や高付加価値サービスまでを提供するエキスパート集団です。
当社は様々な職種でメンバーを募集しています。
「オペレーション・エクセレンス」と「らしく働く、らしく生きる」を共に実現するカルチャー・しくみ・働き方にご興味がある方は、クラスメソッドオペレーションズ株式会社 コーポレートサイトをぜひご覧ください。
※2026 年 1 月 アノテーション㈱から社名変更しました。

この記事をシェアする

関連記事