AWS Lambdaを使ってTimeTreeのイベントをGoogleCalenderに同期したい

家庭のカレンダーにTimeTreeを利用していて、予定を共有するのに重宝しているのですが、ロック画面上に予定を表示してくれるGalaxyのAlways On Displayには連携できず、不便だったため連携処理をAWS Lambda上で連携させてみました。

TimeTreeに他のカレンダーを表示することは可能ですが、逆のパターンはCalDAVには対応しておらず、1件づつコピーする方法しかありません。

[他のカレンダー（Google カレンダーなど）をTimeTree上で利用する](https://support.timetreeapp.com/hc/ja/articles/360000629341)

Galaxyに関わらず、TimeTreeを他のカレンダーに連携したい方にも役にたつかもしれないです。

普段Serverless framework + AWS Lambdaを利用しているので、今回もAWS Lambda上で連携してみたいと思います。

記事を書いてみてかなりの長文になってしまったので、実際のソースものせますので合わせて見ていただければと思います。

# Always On Displayとは
スリープ状態でもスマホ上に時計や予定を表示することが可能で、電源が入っていれば常に表示されます。
自分はGalaxy S21を使用しており、めちゃめちゃ便利な機能だと思っています。

https://www.galaxymobile.jp/explore/hint/what-is-alway-on-display/

時計のスタイルを変更することでカレンダーが表示できるのですが、スタイルのなかで一部のスタイルのみ対応しているので、分かりづらいですが設定することで表示できます。

カレンダーはGalaxyの標準のカレンダーが表示されるようで、CalDAVに対応するカレンダーであれば同期することができます。
つまりGoogleアカウントを標準カレンダーに登録することでGoogleアカウント上の予定がAlways On Display上に表示されます。

# TimeTree API
上で説明したとおり、CalDAVには対応しておりませんが、TimeTreeではAPIが公開されております。
このなかのイベント一覧を取得し、GoogleCalenderに連携すれば良さそうです。

https://developers.timetreeapp.com/ja/docs/api/oauth-app#get-an-event

## アプリケーションの選択
TimeTreeには3種類のアプリケーションが提供されています。
公式のドキュメントによると用途は以下のようになっています。

+ Calendar App
予定やコメントにアプリケーションとして表示したい
+ OAuth App
アプリケーションが複数のカレンダーにアクセスする
+ Personal access token
APIをとりあえず試したい、認証をシンプルにしたい

当初webhookが利用できると記載があったので、Calender Appを利用しようとしたのですが、
あくまでカレンダーがユーザに連携された際などでしか発火されませんでした。
汎用的に連携させるためには画面を作成して、OAuth認証をさせるべきなのですが、
個人利用なので、Personal access tokenを発行しました。

## Personal access token発行方法
[Personal access token発行ページ](https://timetreeapp.com/developers/personal_access_tokens) に遷移し、アクセストークンを発行します。
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/511411/6f9ac22d-3a54-d5e9-460d-fbe4d035b6d6.png)
トークンの作成からトークン名と読み取りにチェックをつけ作成します。
発行されたトークンは一度しか表示されないのであらかじめコピーしておきます。
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/511411/9a91f7c8-85ba-5c7c-e2cb-2d24d4799a17.png)

# EventAPIの取得
Pythonで一通り書いたあとに気がついたのですが、timetree用のSDKがJavaScript、Ruby、PHP、Goで用意されていました。型定義とかバリデーションが入っているのでPython以外では利用したほうが良いと思います。
https://github.com/jubilee-works/timetree-sdk-js/tree/master/web-api

Python版でイベントを取得するプログラムをSDK風に以下の通りにしてみました。
calender_idを取得するために`get_calender_list`を利用しているだけなので、calender_idが固定値で良ければ必要ありません。

```py
class Timetree:
    def __init__(self, personal_token) -> None:
        self.personal_token = personal_token
        self.request_header = {
            'Accept': 'application/vnd.timetree.v1+json',
            'Authorization': f'Bearer {personal_token}',
        }

    def get_calender_list(self) -> dict:
        res = requests.get(f'{base_url}/calendars',
                           headers=self.request_header)
        data = res.json()['data']
        print(data)
        return data

    def get_event_list(self, calender_id: str) -> List[CalenderEvent]:
        # daysパラメータから最大日数の7日分のイベントを取得している
        res = requests.get(
            url=f'{base_url}/calendars/{calender_id}/upcoming_events?days=7',
            headers=self.request_header
        )
        data = res.json()['data']
        print(data)

        return [dict(
            id=event['id'],
            title=event['attributes'].get('title'),
            all_day=event['attributes'].get('all_day'),
            start_at=datetime.fromisoformat(
                event['attributes']['start_at'].replace('Z', '+00:00')),
            start_timezone=event['attributes'].get('start_timezone'),
            end_at=datetime.fromisoformat(
                event['attributes']['end_at'].replace('Z', '+00:00')),
            end_timezone=event['attributes'].get('end_timezone'),
            updated_at=datetime.fromisoformat(
                event['attributes']['updated_at'].replace('Z', '+00:00')),
            created_at=datetime.fromisoformat(
                event['attributes']['created_at'].replace('Z', '+00:00')),
            category=event['attributes'].get('category'),
        ) for event in data]
```

使う側はこんな形です。これでTimeTreeからイベントがリストで受け取れます。
```python
def get_timetree_event():
    timetree = Timetree('<<タイムツリーPersonal Access Token>>')
    calender_list = timetree.get_calender_list()
    main_calender = next(
        filter(lambda x: x['attributes']['name'] == '<<カレンダー名>>', calender_list))

    if main_calender is None:
        raise Exception('calender not found')

    calender_id = main_calender['id']
    event_list = timetree.get_event_list(calender_id)
    print(event_list)

```
# GoogleCalender API
GoogleCalenderをAPIから操作するにはGCPのアカウントが必要になります。

## GoogleCalenderAPIの有効化
[Google Cloud Console](https://console.cloud.google.com)にログインし、ダッシュボードの検索窓から「Google Calendar API」を検索し、APIの有効化を押下します。
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/511411/1aad5275-b1d6-56c3-66de-891d5f6de8bb.png)

## サービスアカウントの発行
API経由でアクセスするためにはOauthもしくはサービスアカウントを発行する必要があります。
今回は画面による認証は行いたくないのでサービスアカウントを発行します。

[Google Cloud Console](https://console.cloud.google.com)にログインし、「APIとサービス」から「認証情報」を開きます。

サービスアカウントを作成から新規サービスアカウントを発行します。
ひとまず権限はデフォルトのまま作成します。
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/511411/eb42c133-f663-8e22-b1fa-c0b654da8b9a.png)

サービスアカウント作成後、「キー」タブから「鍵の追加」より秘密鍵の発行し、ダウンロードします。
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/511411/e59c74d0-49a8-6f51-602c-d634c5755fcf.png)

## AWS SecretsManagerにキー情報を登録
Lambdaで実行するにあたり、サービスアカウントの鍵情報をLambda上で読み込ませる必要性があります。
JSONファイルをそのまま置くこともできますが、Git上にも保存しなくてはいけません。
JSON形式で保存できるSecretsManagerが親和性が高いため、鍵情報をSecretsManagerに登録します。


SecretsManagerのサービスから「新しいシークレットを保存する」を選択します。
シークレットタイプを「その他のシークレットタイプ」とし、キー/値のペアに先ほどダウンロードしたサービスアカウントの鍵情報をコピー&ペーストします。
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/511411/352cf4bd-8dd6-244e-cf7f-91e26b31df20.png)
任意のシークレット名を入力し保存します。
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/511411/41a334c3-6397-c57b-e2dc-1a02bc109579.png)
これでサービスアカウント情報をLambdaから取得できるようになります。
```py
import os
import json
import boto3

secrets = boto3.client(
    service_name='secretsmanager',
    region_name='ap-northeast-1'
)


def get_gcp_service_role_json():
    res = secrets.get_secret_value(
        SecretId='<<先ほど登録したシークレットID>>'
    )['SecretString']
    return json.loads(res)
```

## イベント登録
GoogleCalenderAPIを使用するにあたり、以下のライブラリのインストールが必要になります。
pip等でインストールします。

+ google-api-python-client
+ google-auth-httplib2
+ google-auth-oauthlib

init時にサービスアカウントを読み込み、引数より渡ってきたイベントを登録するだけのクラスを定義します。`date`と`dateTime`が分かれているのは1dayの場合は`date`、時間指定の場合は`dateTime`に入るためです。

```py
from google.oauth2.service_account import Credentials
from googleapiclient.discovery import build

from lib.secrets_manager import get_gcp_service_role_json

service_role_json = get_gcp_service_role_json()


class GoogleCalender:
    def __init__(self, calender_id: str) -> None:
        self.credentials = Credentials.from_service_account_info(
            service_role_json)
        self.service = build('calendar', 'v3', credentials=self.credentials)
        self.calender_id = calender_id

    def create(self, event: dict):
        self.service.events().insert(
            body={
                'summary': event['summary'],
                'start': {
                    'date': event.get('start_date'),
                    'dateTime': event.get('start_datetime'),
                    'timeZone': event.get('start_timezone')
                },
                'end': {
                    'date': event.get('end_date'),
                    'dateTime': event.get('end_datetime'),
                    'timeZone': event.get('end_timezone')
                }
            }).execute()
```

## TimeTreeAPIから取得したEventをGoogleCalenderにマッピング

最後にEventAPIから取得する関数を改良してGoogleCalenderにマッピングします。
```py
def sync_timetree_to_google_calender(_event, _context):

    timetree = Timetree(secrets['TIMETREE_TOKEN'])
    calender_list = timetree.get_calender_list()
    main_calender = next(
        filter(lambda x: x['attributes']['name'] == '<<カレンダー名>>', calender_list))

    if main_calender is None:
        raise Exception('calender not found')

    calender_id = main_calender['id']
    event_list = timetree.get_event_list(calender_id)
    print(event_list)
    google_calender = GoogleCalender(calender_id='<<Googleアカウントのメールアドレス>>')
    for event in event_list:
        google_calender.create(event=mappings_calender_event(event))


def mappings_calender_event(event: CalenderEvent) -> GoogleCalenderEvent:
    return {
        'summary': event['title'],
        'start_date': event['start_at'].strftime('%Y-%m-%d'),
        'end_date': event['end_at'].strftime('%Y-%m-%d'),
    } if event['all_day'] else {
        'summary': event['title'],
        'start_datetime': event['start_at'].isoformat(timespec='seconds'),
        'start_timezone': event.get('start_timezone'),
        'end_datetime': event['end_at'].isoformat(timespec='seconds'),
        'end_timezone': event.get('end_timezone')
    }
```
# serverless deploy
libraryを色々追加しているので、`serverless-python-requirements`プラグインを入れてライブラリもまとめてLambdaにデプロイします。
```yml
service: timetree-to-gcalender
frameworkVersion: '3'

provider:
  name: aws
  runtime: python3.8
  region: ap-northeast-1
  environment:
    TIMETREE_SECRETS_ID: timetree_secrets
    GCP_CALENDER_SERVICE_ROLE_SECRETS_ID: gcp-calender-service-role-json
  iam:
    role:
      statements:
        - Effect: "Allow"
          Action:
            - "secretsmanager:GetSecretValue"
          Resource: "arn:aws:secretsmanager:*:${aws:accountId}:secret:gcp-calender-service-role-json-*"
        - Effect: "Allow"
          Action:
            - "secretsmanager:GetSecretValue"
          Resource: "arn:aws:secretsmanager:*:${aws:accountId}:secret:timetree_secrets-*"
functions:
  SyncCalender:
    handler: handler.sync_calender
plugins:
  - serverless-python-requirements
```

# 動作確認
イベントIDを保持していないため、定期実行すると別イベントとして判断されてしまいます。
一旦今回はLambdaが正常にイベント登録できるところまで確認します。
次回の記事で更新についても紹介できればと思います。

まずはTimeTreeに予定を追加します。
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/511411/67738100-0d6c-1394-c68b-55a7ca88e726.png)

AWSのコンソール上からLambdaをテスト実行し、正常に終了することを確認します。
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/511411/de3423fa-9d73-80a0-b366-7a4401ba801c.png)

Googleカレンダー上に表示されていれば成功です。
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/511411/cae650c1-d819-fb55-7955-d3c9c6ef6586.png)

# まとめ
コード自体は大したことがないのですが、TimeTree、AWS、GoogleCalenderの設定が多く、長くなってしまいましたが無事GoogleCalenderに書き込むことができました。
次回は状態をDyanmoDBに保持し、同一イベントであれば更新できるようにしたいと思います。

ここまでのソースの完全版はGithab上にあります。
こちらも参考していただければと思います。

https://github.com/syujiokamura/timetree-to-gcalender