家庭のカレンダーにTimeTreeを利用していて、予定を共有するのに重宝しているのですが、ロック画面上に予定を表示してくれるGalaxyのAlways On Displayには連携できず、不便だったため連携処理をAWS Lambda上で連携させてみました。
TimeTreeに他のカレンダーを表示することは可能ですが、逆のパターンはCalDAVには対応しておらず、1件づつコピーする方法しかありません。
他のカレンダー(Google カレンダーなど)をTimeTree上で利用する
Galaxyに関わらず、TimeTreeを他のカレンダーに連携したい方にも役にたつかもしれないです。
普段Serverless framework + AWS Lambdaを利用しているので、今回もAWS Lambda上で連携してみたいと思います。
記事を書いてみてかなりの長文になってしまったので、実際のソースものせますので合わせて見ていただければと思います。
Always On Displayとは
スリープ状態でもスマホ上に時計や予定を表示することが可能で、電源が入っていれば常に表示されます。
自分はGalaxy S21を使用しており、めちゃめちゃ便利な機能だと思っています。
時計のスタイルを変更することでカレンダーが表示できるのですが、スタイルのなかで一部のスタイルのみ対応しているので、分かりづらいですが設定することで表示できます。
カレンダーはGalaxyの標準のカレンダーが表示されるようで、CalDAVに対応するカレンダーであれば同期することができます。
つまりGoogleアカウントを標準カレンダーに登録することでGoogleアカウント上の予定がAlways On Display上に表示されます。
TimeTree API
上で説明したとおり、CalDAVには対応しておりませんが、TimeTreeではAPIが公開されております。
このなかのイベント一覧を取得し、GoogleCalenderに連携すれば良さそうです。
アプリケーションの選択
TimeTreeには3種類のアプリケーションが提供されています。
公式のドキュメントによると用途は以下のようになっています。
- Calendar App
予定やコメントにアプリケーションとして表示したい - OAuth App
アプリケーションが複数のカレンダーにアクセスする - Personal access token
APIをとりあえず試したい、認証をシンプルにしたい
当初webhookが利用できると記載があったので、Calender Appを利用しようとしたのですが、
あくまでカレンダーがユーザに連携された際などでしか発火されませんでした。
汎用的に連携させるためには画面を作成して、OAuth認証をさせるべきなのですが、
個人利用なので、Personal access tokenを発行しました。
Personal access token発行方法
Personal access token発行ページ に遷移し、アクセストークンを発行します。
トークンの作成からトークン名と読み取りにチェックをつけ作成します。
発行されたトークンは一度しか表示されないのであらかじめコピーしておきます。
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が固定値で良ければ必要ありません。
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からイベントがリストで受け取れます。
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にログインし、ダッシュボードの検索窓から「Google Calendar API」を検索し、APIの有効化を押下します。
サービスアカウントの発行
API経由でアクセスするためにはOauthもしくはサービスアカウントを発行する必要があります。
今回は画面による認証は行いたくないのでサービスアカウントを発行します。
Google Cloud Consoleにログインし、「APIとサービス」から「認証情報」を開きます。
サービスアカウントを作成から新規サービスアカウントを発行します。
ひとまず権限はデフォルトのまま作成します。
サービスアカウント作成後、「キー」タブから「鍵の追加」より秘密鍵の発行し、ダウンロードします。
AWS SecretsManagerにキー情報を登録
Lambdaで実行するにあたり、サービスアカウントの鍵情報をLambda上で読み込ませる必要性があります。
JSONファイルをそのまま置くこともできますが、Git上にも保存しなくてはいけません。
JSON形式で保存できるSecretsManagerが親和性が高いため、鍵情報をSecretsManagerに登録します。
SecretsManagerのサービスから「新しいシークレットを保存する」を選択します。
シークレットタイプを「その他のシークレットタイプ」とし、キー/値のペアに先ほどダウンロードしたサービスアカウントの鍵情報をコピー&ペーストします。
任意のシークレット名を入力し保存します。
これでサービスアカウント情報をLambdaから取得できるようになります。
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
に入るためです。
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にマッピングします。
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にデプロイします。
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が正常にイベント登録できるところまで確認します。
次回の記事で更新についても紹介できればと思います。
AWSのコンソール上からLambdaをテスト実行し、正常に終了することを確認します。
まとめ
コード自体は大したことがないのですが、TimeTree、AWS、GoogleCalenderの設定が多く、長くなってしまいましたが無事GoogleCalenderに書き込むことができました。
次回は状態をDyanmoDBに保持し、同一イベントであれば更新できるようにしたいと思います。
ここまでのソースの完全版はGithab上にあります。
こちらも参考していただければと思います。