LINEボットでGoogleカレンダーに予定を追加するシステムを作ってみた

こんにちは！今回、私が挑戦したプロジェクトについて、皆さんに共有したいと思います。
LINEボットを使ってGoogleカレンダーに予定を追加するシステムです。正直、最初は「無理かも...」と思いましたが、なんとか形にすることができました！この記事を読んで、少しでも「私にもできるかも！」と思ってもらえたら嬉しいです。

## きっかけ

私は予定管理はいつもGoogleカレンダーを使っているんです。でも、いちいちアプリを開いて入力するのが面倒で...。「LINEで予定を入れられたら便利だな」と思ったのがきっかけです。

## 使った技術

まず、どんな技術を使えばいいか調べました。以下の技術を使うことにしました：

- Python：プログラミング言語
- Flask：Webアプリケーションフレームワーク
- LINE Messaging API：LINEボットを作るためのAPI
- Google Calendar API：Googleカレンダーを操作するためのAPI
- OpenAI GPT-3.5 Turbo：自然言語処理のためのAI

## 苦労したポイントと解決策

### 1. LINEボットの作成

LINEボットを作るのは初めてだったので、LINE Developersのドキュメントを何度も読み返しました。特に、Webhookの設定には苦戦しました。エラーメッセージとにらめっこの日々...。でも、StackOverflowやQiitaの先輩方の記事を参考にしながら、少しずつ理解を深めていきました。下記の記事を見て考えました。

https://qiita.com/fujimo-22/items/bde4d041b4cf4d43edd9



### 2. Google Calendar APIの認証

GoogleのAPIを使うのも初めてで、認証の仕組みがよく分かりませんでした。特に、サービスアカウントの概念が難しかったです。公式ドキュメントを読んでやっと理解できました。

### 3. 自然言語処理の実装

ユーザーが自由な形式で予定を入力できるようにしたかったんです。でも、そのための自然言語処理の実装が難関でした。最初は自前で実装しようとしましたが、正規表現だけではうまくいきません。そこで、OpenAIのGPT-3.5 Turboを使うことにしました。APIの使い方を学ぶのに時間がかかりましたが、結果的にはこの選択が功を奏しました！

## コードの解説

長くなりそうなので、主要な部分だけ説明します。

```python
import asyncio
from flask import Flask, request, abort
from linebot.v3 import WebhookHandler
from linebot.v3.exceptions import InvalidSignatureError
from linebot.v3.webhooks import MessageEvent, TextMessageContent
from linebot.v3.messaging import (
    Configuration,
    ApiClient,
    MessagingApi,
    ReplyMessageRequest,
    TextMessage
)
from google.oauth2 import service_account
from googleapiclient.discovery import build
import datetime
import openai
import json
import logging
import traceback
from concurrent.futures import ThreadPoolExecutor

app = Flask(__name__)

# LINE API設定
configuration = Configuration(access_token='YOUR_LINE_ACCESS_TOKEN')
handler = WebhookHandler('YOUR_LINE_CHANNEL_SECRET')

# Google Calendar API設定
SCOPES = ['https://www.googleapis.com/auth/calendar']
SERVICE_ACCOUNT_FILE = 'path/to/your/service_account_file.json'
credentials = service_account.Credentials.from_service_account_file(
    SERVICE_ACCOUNT_FILE, scopes=SCOPES)
service = build('calendar', 'v3', credentials=credentials)

# OpenAI API設定
openai.api_key = 'YOUR_OPENAI_API_KEY'

CALENDAR_ID = 'your_calendar_id@gmail.com'
```

このコードでは、必要なライブラリをインポートし、各APIの設定を行っています。LINE、Google Calendar、OpenAIのAPIキーは適切に管理する必要があります。これらの情報が漏洩すると大変なことになるので、気をつけましょう！

次に、重要な関数を見ていきます：

```python
def chat_with_gpt(prompt):
    try:
        response = openai.ChatCompletion.create(
            model="gpt-3.5-turbo",
            messages=[{"role": "user", "content": prompt}]
        )
        return response.choices[0].message.content
    except Exception as e:
        logging.error(f"Error in chat_with_gpt: {str(e)}")
        return None

def parse_schedule_info(user_input):
    # 省略（GPT-3.5 Turboを使って予定情報を抽出する処理）

def add_event_to_calendar(schedule_info):
    # 省略（Google Calendarに予定を追加する処理）

def process_message(message, reply_token):
    try:
        schedule_info = parse_schedule_info(message)
        if not schedule_info:
            return send_reply_message(reply_token, "申し訳ありません。スケジュール情報の解析に失敗しました。")

        success = add_event_to_calendar(schedule_info)
        if success:
            reply_message = f"予定「{schedule_info['event_title']}」をカレンダーに追加しました。"
        else:
            reply_message = "予定の追加中にエラーが発生しました。"

    except Exception as e:
        logging.error(f"An unexpected error occurred: {traceback.format_exc()}")
        reply_message = "予期せぬエラーが発生しました。"

    send_reply_message(reply_token, reply_message)
```

これらの関数が、システムの中核となる処理を担っています。特に`process_message`関数は、ユーザーからのメッセージを受け取ってから、カレンダーに予定を追加するまでの一連の流れを制御しています。

## 実際に動かしてみて

完成したシステムを動かしてみると、本当に感動しました！LINEに「明日の15時から1時間、ミーティング」と送ると、数秒後に「予定「ミーティング」をカレンダーに追加しました。」という返信が来て、実際にGoogleカレンダーを確認すると予定が追加されます

![スクリーンショット 2024-09-11 9.32.09.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2995657/54de020a-4dcc-bf0f-4a8e-3529c6f8aefa.png)

![スクリーンショット 2024-09-11 9.46.25.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2995657/c0e9e5c2-b3c6-b5e1-9527-37ce080f85c4.png)



## ここからは詳細の説明になります

## 1. Flaskアプリケーションの設定

まず、Flaskアプリケーションの基本的な設定から見ていきましょう。

```python
app = Flask(__name__)

@app.route("/callback", methods=['POST'])
def callback():
    signature = request.headers['X-Line-Signature']
    body = request.get_data(as_text=True)
    try:
        handler.handle(body, signature)
    except InvalidSignatureError:
        abort(400)
    return 'OK'

if __name__ == "__main__":
    app.run(debug=True)
```

ここでのポイントは：

- `@app.route("/callback", methods=['POST'])`: これはFlaskのデコレータで、"/callback"というURLにPOSTリクエストが来たときに、この関数が呼び出されることを指定しています。
- `signature = request.headers['X-Line-Signature']`: LINEからのリクエストが正当なものかを確認するための署名を取得しています。
- `handler.handle(body, signature)`: LINEからのWebhookイベントを処理します。
- `if __name__ == "__main__":`: このスクリプトが直接実行された場合にのみ、Flaskアプリケーションを起動します。

## 2. LINEボットのメッセージハンドリング

LINEボットがメッセージを受け取ったときの処理を見てみましょう。

```python
@handler.add(MessageEvent, message=TextMessageContent)
def handle_message(event):
    message = event.message.text
    reply_token = event.reply_token
    process_message(message, reply_token)
```

- `@handler.add(MessageEvent, message=TextMessageContent)`: これはLINE Bot SDKのデコレータで、テキストメッセージを受け取ったときにこの関数が呼び出されることを指定しています。
- `event.message.text`: 受け取ったメッセージの内容です。
- `event.reply_token`: メッセージに返信するために必要なトークンです。
- `process_message(message, reply_token)`: 実際のメッセージ処理を行う関数を呼び出しています。

## 3. メッセージ処理関数

`process_message`関数は、受け取ったメッセージを処理し、適切な返信を送る中心的な役割を果たします。

```python
def process_message(message, reply_token):
    try:
        with ThreadPoolExecutor() as executor:
            future = executor.submit(parse_schedule_info, message)
            schedule_info = future.result(timeout=25)  # 25秒のタイムアウトを設定

        if not schedule_info:
            return send_reply_message(reply_token, "申し訳ありません。スケジュール情報の解析に失敗しました。")

        success = add_event_to_calendar(schedule_info)
        if success:
            if schedule_info['is_all_day']:
                reply_message = f"スケジュール「{schedule_info['event_title']}」を{schedule_info['date']}の終日予定としてカレンダーに追加しました。"
            else:
                start_time = schedule_info['start_time'] or '不明'
                end_time = schedule_info['end_time'] or '不明'
                reply_message = f"スケジュール「{schedule_info['event_title']}」を{schedule_info['date']} {start_time}から{end_time}までの予定としてカレンダーに追加しました。"
        else:
            reply_message = "スケジュールの追加中にエラーが発生しました。"

    except TimeoutError:
        reply_message = "処理に時間がかかりすぎています。もう一度お試しください。"
    except Exception as e:
        logging.error(f"An unexpected error occurred: {traceback.format_exc()}")
        reply_message = "予期せぬエラーが発生しました。"

    send_reply_message(reply_token, reply_message)
```

ここでのポイントは：

- `ThreadPoolExecutor`: 処理を非同期で行い、タイムアウトを設定しています。これにより、処理が長引いた場合でもユーザーに適切な応答を返せます。
- エラーハンドリング: try-except文を使って様々なエラーに対処しています。
- 条件分岐: 終日予定かそうでないかで、返信メッセージを変えています。

## 4. スケジュール情報の解析

`parse_schedule_info`関数は、自然言語で入力された予定をGPT-3.5 Turboを使って解析します。

```python
def parse_schedule_info(user_input):
    today_at_japan = datetime.datetime.now(datetime.timezone(datetime.timedelta(hours=9)))

    prompt = f"""
    日本語で書かれた以下の予定情報から、スケジュールの詳細を抽出してください：
    "{user_input}"
    
    以下の形式でJSONオブジェクトを返してください：
    {{
        "event_title": "イベントのタイトル",
        "date": "イベントの日付（「YYYY-MM-DD」形式）",
        "start_time": "開始時間（HH:MM形式、24時間表記）。指定がない場合はnull",
        "end_time": "終了時間（HH:MM形式、24時間表記）。指定がない場合はnull",
        "is_all_day": "終日イベントかどうか（true/false）"
    }}
    """
    
    response = chat_with_gpt(prompt)
    if response:
        try:
            data = json.loads(response)
            # 日付の処理
            if 'date' not in data or not data['date']:
                data['date'] = '今日'
            data['date'] = parse_date(data['date']).isoformat()
            
            # 時間の処理
            if ('start_time' in data and data['start_time']) or ('end_time' in data and data['end_time']):
                data['is_all_day'] = False
            else:
                data['is_all_day'] = True

            return data
        except json.JSONDecodeError:
            logging.error(f"Failed to parse JSON from ChatGPT response: {response}")
    return None
```

ここでのポイントは：

- GPT-3.5 Turboへのプロンプト: JSON形式で回答を要求することで、構造化されたデータを取得しています。
- 日付と時間の処理: 相対的な日付（「今日」「明日」など）を絶対的な日付に変換し、時間情報の有無に応じて終日予定かどうかを判断しています。

## 5. Googleカレンダーへの予定追加

最後に、`add_event_to_calendar`関数でGoogleカレンダーに予定を追加します。

```python
def add_event_to_calendar(schedule_info):
    try:
        event_date = schedule_info['date']

        if schedule_info['is_all_day']:
            event_body = {
                'summary': schedule_info['event_title'],
                'start': {'date': event_date},
                'end': {'date': event_date},
            }
        else:
            start_time = schedule_info.get('start_time', '00:00')
            end_time = schedule_info.get('end_time')
            
            if not end_time:
                # 終了時間が指定されていない場合、開始時間の1時間後を設定
                start_dt = datetime.datetime.fromisoformat(f"{event_date}T{start_time}:00")
                end_dt = start_dt + datetime.timedelta(hours=1)
                end_time = end_dt.strftime("%H:%M")

            start_datetime = f"{event_date}T{start_time}:00"
            end_datetime = f"{event_date}T{end_time}:00"

            event_body = {
                'summary': schedule_info['event_title'],
                'start': {'dateTime': start_datetime, 'timeZone': 'Asia/Tokyo'},
                'end': {'dateTime': end_datetime, 'timeZone': 'Asia/Tokyo'},
            }
    
        event = service.events().insert(calendarId=CALENDAR_ID, body=event_body).execute()
        return True
    except HttpError as error:
        logging.error(f"An error occurred while adding event to calendar: {error}")
        return False
```

ここでのポイントは：

- 終日予定と時間指定予定の区別: `is_all_day`フラグに基づいて、異なる形式でイベントを作成しています。
- 終了時間の自動設定: 終了時間が指定されていない場合、開始時間の1時間後を自動で設定しています。
- タイムゾーンの指定: 日本時間（Asia/Tokyo）を明示的に指定しています。

# 感想と学び

## これからチャレンジしたいこと

1. ユーザーインターフェースの改善: より使いやすいLINEボットにするため、自然な会話のようなやり取りができるようにしたいです。

2. テスト駆動開発の導入: 自動テストの書き方を学んで、より安定したアプリケーションを作れるようになりたいです。

## 締めの言葉

まだまだ初心者で、分からないことだらけですが、一つずつ課題を解決していく過程が本当に楽しいです。これからも、好奇心を持ち続け、少しずつでも成長していきたいと思います。

ここまで読んでくださった皆様、本当にありがとうございました。この記事が誰かの「やってみよう」という気持ちにつながれば嬉しいです。