Googleカレンダーで予定を作成したら、別アカウントのカレンダーに「ブロック」予定を作成する

個人用のGoogleカレンダーに予定を登録→平日なら内容に応じて前後30分増やして会社のカレンダーにブロック予定を作成、などを行います。[^zapier]
Google Calendar APIの[Go client library](https://pkg.go.dev/google.golang.org/api/calendar/v3)とApp Engineを主に用いて構築します。
内容をまとめたリポジトリはこちら:

https://github.com/shiraily/gcal-sync


# この記事について

記事内のコードは説明のための部分的なコードなので、エラー処理など完全なものはリポジトリを参照ください。
また、以下を前提として進めます。

- GCP projectが作成済み。以下を利用
    - App Engine
    - Cloud Scheduler
    - Firestore
- Go / Google Cloud SDK

# 事前の設定

### Google Calendar APIの有効化

GCPでAPIを有効化します。[コンソール](https://console.cloud.google.com/)の検索バーで "Google Calendar API" を検索して遷移します。「有効化」を押下すれば完了です。

### サービスアカウントの作成

APIの有効化後は
![スクリーンショット 2021-07-10 9.09.28.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/88483/908cc8dc-cb14-9f66-5b39-101347ce6c23.png)
となりますが、無視してサービスアカウントを作成します。[^oauth]


Firestore (ここではネイティブモード) をトークンなどの保存先として使うなら、それ用のサービスアカウントを作成します。

1. Cloud Console > IAMと管理 > サービス アカウントから、ロール「Firebase Admin SDK 管理者サービス エージェント」を付与して作成
1. サービスアカウント一覧に戻るので、選択し直してキー > 鍵を追加 > 新しい鍵を作成 > JSON でダウンロード（ `service_account_key.json` とします)

### サービスアカウントへカレンダーを共有する

Google Calendar上で対象カレンダーの設定と共有から共有します。サービスアカウントは　`service-account-name@project-name.iam.gserviceaccount.com` などとなっています。権限は同期元は「予定の表示（すべての予定の詳細）」、同期先は「予定の変更」です。

なお、Google Workspaceではカレンダーの共有が不可になっていることがあります。その場合、Zapierを経由することで回避します。

##### Zapierを利用する際の設定

1. 個人用のGoogleカレンダーにブロック専用のカレンダーを作成し、それを本記事での同期先とする
1. サービスアカウントに「予定の変更」権限でブロック専用カレンダーを共有する
1. Zapierで「ブロック専用のカレンダーで予定が追加されたら会社用のカレンダーへ予定を作成」というZapを作成する
    1. [こちら](https://zapier.com/shared/ce0f27fa322963e443fa6e369c61beec08bc8a0c)を利用ください。

# 実装

### Webhook URLをchannelとして登録

[Push Notifications](https://developers.google.com/calendar/api/guides/push)を参考に登録用APIをコールし、必要情報を保存します。
URLはApp Engineの場合は`https://[service]-dot-[project].an.r.appspot.com/notify` などとなります。パスは任意。ドメインはわからなければ先にApp Engineをデプロイすることで取得できます。

ただし、実際のコール前に[Webhook用ドメインの認証](#webhook用ドメインの認証)とWebhookのデプロイ[^webhook]が必要です。

channel定義

```watch.go
// Google Calendar APIが扱うtime formatを流用
const gcalTimeFormat = "2006-01-02T15:04:05-07:00"

func newChannel(webhookURL string) *calendar.Channel {
	id, _ := uuid.NewRandom() // UUID推奨
	// 有効期限。1ヶ月以上先を指定しても最大1ヶ月後
	exp, _ := time.Parse(gcalTimeFormat, "2030-01-01T00:00:00+09:00")
	ch := calendar.Channel{
		Id:         id.String(),
		Type:       "webhook",
		Expiration: exp.UnixNano() / int64(time.Millisecond), // Unix timestamp (ミリ秒)
		Address:    webhookURL,
	}
	return &ch
}
```

サービスの取得とAPI呼び出し

```watch.go
const serviceAccountKey = "service_account_key.json"

func NewCalendarService(ctx context.Context) *calendar.Service {
	svc, _ := calendar.NewService(ctx, option.WithCredentialsFile(serviceAccountKey))
	return svc
}

func main() {
	flag.Parse()
	webhookURL := flag.Args()[0]
	ctx := context.Background()

	ch := newChannel(webhookURL)
	svc := NewCalendarService(ctx)
	calendarId := flag.Args()[1]
	res, _ := svc.Events.Watch(calendarId, ch).Do()
	// res.ResourceIdを保存
}
```

Firestoreに保存するなら

```watch.go
func NewFirestoreClient(ctx context.Context, project string) *firestore.Client {
	app, _ := firebase.NewApp(ctx, &firebase.Config{ProjectID: project}, option.WithCredentialsFile(serviceAccountKey))
	cli, _ := app.Firestore(ctx)
	return cli
}

func main() {
	// ...
	project := flag.Args()[2] // プロジェクト名
	fsClient := NewFirestoreClient(ctx, project)
	_, err := fsClient.Collection("calendar").Doc("channel").Set(ctx, map[string]interface{}{
		"channelId":  ch.Id,
		"resourceId": res.ResourceId,
		"exp":        res.Expiration, // デバッグ用
	})
	if err != nil {
		log.Fatal(err)
	}
	log.Printf("channelId=%s, resourceId=%s, exp=%d", ch.Id, res.ResourceId, res.Expiration)}

```

ソースは[こちら](https://github.com/shiraily/gcal-sync/blob/main/cmd/naive/qiita/watch.go)

### Webhook

通知を受け取るWebhookの実装です。

1. Webhookがリクエストを受け取る
1. `X-Goog-Resource-State`ヘッダを見て、`sync`なら初回のList APIを呼んでsyncTokenを取得、`exists`なら保存済みsyncTokenを取得
1. syncTokenを使ってList APIを呼び出し、更新分のイベント（予定）リストを得る。返った`nextSyncToken`を保存
1. 得られた予定のリストをもとに、別アカウントへ予定を作成など好きに処理を行う

##### リクエスト受け取り

エンドポイントを作成します。

```go
func main() {
	http.HandleFunc("/notify", OnNotify)
	port := os.Getenv("PORT")
	if port == "" {
		port = "3000"
	}
	if err := http.ListenAndServe(":"+port, nil); err != nil {
		log.Fatal(err)
	}
}
```

##### 初回のList API呼び出し



通知を受け取っても中にイベントが入っているわけではないので、通知を受け取った後に「どの続きから読みだすか」を示すsyncTokenを利用してList APIを呼び出しますが、最初(Webhook登録により発火される)はsyncTokenがないので取得するためにList APIをコールします。
API登録時に`X-Goog-Resource-State`が`sync`で呼び出されるのでそれをハンドリングします。

注意として、イベント数が多い場合は最後までpagingしないとsyncTokenが返らないので、現在時刻よりも後のイベント一覧などと絞ります。

```go
func OnNotify(w http.ResponseWriter, r *http.Request) {
	w.Header().Set("Content-Type", "text/plain")
	log.Printf("channelId=%s, resourceId=%s", r.Header["X-Goog-Channel-Id"], r.Header["X-Goog-Resource-Id"])

	ctx := context.Background()
	svc := NewCalendarService(ctx)

	resourceState := r.Header["X-Goog-Resource-State"]
	if len(resourceState) > 0 && resourceState[0] == "sync" {
		// 初回syncToken取得
		currentEvents, _ := svc.Events.List(SrcCalId).ShowDeleted(false).
			SingleEvents(true).TimeMin(time.Now().Format(time.RFC3339)).Do()
		fmt.Printf("syncToken: %s\n", currentEvents.NextSyncToken) // 保存する
		return
	}	// ...
}
```

##### syncTokenを使ったList API呼び出し

[syncTokenを使うときに指定できないクエリパラメータが多い](https://developers.google.com/calendar/api/v3/reference/events/list)ので、syncTokenのみでコールします。

```go
var (
	SrcCalId  = "hoge@example.com" // 同期元
	DestCalId = "fuga@example.com" // 同期先
)

func OnNotify(w http.ResponseWriter, r *http.Request) {
	// ...
	syncToken := "" // 保存したtokenを使う
	events, _ := svc.Events.List(SrcCalId).SyncToken(syncToken).Do()
	fmt.Println(syncToken, events.NextSyncToken) // NextSyncTokenは保存しておく
	// ...
}
```

##### イベントリストの取得と加工

イベントリストを取得し、イベントごとに処理します。

```go
func OnNotify(w http.ResponseWriter, r *http.Request) {
	// ...
	for _, srcEvt := range events.Items {
		destEvt := newEvent(srcEvt)
		if destEvt == nil {
			continue
		}
		createdEvt, _ := svc.Events.Insert(DestCalId, destEvt).Do()
		fmt.Printf("created %s", createdEvt.Id)
	}

	w.WriteHeader(http.StatusOK)
}
```

例としてこんな加工やフィルタリングができます。

```go
// 作成するイベントを返す
func newEvent(srcEvt *calendar.Event) *calendar.Event {
	if srcEvt.Status != "confirmed" { // キャンセル等を除外
		return nil
	}
	if srcEvt.Start.DateTime == "" { // 終日イベントを除外
		return nil
	}

	// 休日を除外
	start, _ := time.Parse(gcalTimeFormat, srcEvt.Start.DateTime)
	end, _ := time.Parse(gcalTimeFormat, srcEvt.End.DateTime)
	if start.Weekday() == time.Saturday ||
		start.Weekday() == time.Sunday ||
		end.Weekday() == time.Saturday ||
		end.Weekday() == time.Sunday {
		return nil
	}

	// 場所が入っていれば前後30分追加
	if srcEvt.Location != "" {
		start = start.Add(time.Duration(-30) * (time.Minute))
		end = end.Add(time.Duration(30) * (time.Minute))
	}
	startDateTime := start.Format(gcalTimeFormat)
	endDateTime := end.Format(gcalTimeFormat)
	return &calendar.Event{
		Summary: "ブロック",
		Start: &calendar.EventDateTime{
			DateTime: startDateTime,
		},
		End: &calendar.EventDateTime{
			DateTime: endDateTime,
		},
	}
}
```

### 定期的なchannelの更新

channelの期限は最大1ヶ月なので、定期的にchannelを更新(削除 & 新規作成)してchannelIdを保存し直す必要があります[^channel-list]。
削除は

```stop.go
package main

import (
	"context"

	"google.golang.org/api/calendar/v3"
)

func main() {
	ctx := context.Background()
	svc := NewCalendarService(ctx)
	svc.Channels.Stop(&calendar.Channel{
		ResourceId: "resource-id",
		Id:         "channel-id",
	})
}
```

まとめると [/renew](https://github.com/shiraily/gcal-sync/blob/main/main.go)以下の処理です

App Engineに更新用APIを登録してCloud Schedulerからコールするなら

```sh
# 毎週火曜5時に実行
gcloud scheduler jobs create app-engine job-name --schedule="0 5 * * 2" --relative-url="/renew" --service=service-name --time-zone="Asia/Tokyo"
```

# 動かす

### Webhook用ドメインの認証

通知を受け取るためのWebhook URLは事前にドメイン認証されている必要があります。
htmlファイルをstaticファイルとして配置するのが楽です。[こちら](https://qiita.com/wezardnet/items/996b3cef41e33e6a895f#1-%E4%BA%8B%E5%89%8D%E6%BA%96%E5%82%99)を参考にします。

```app.yaml
runtime: go115

service: service-name

handlers:
- url: /(.*\.html)$
  static_files: static/\1
  upload: static/.*\.html$
- url: /.*
  script: auto
```

```static/googleXXX.html
google-site-verification: googleXXX.html
```

### デプロイ

```sh
gcloud app deploy --version 1 -q

# channel登録
go run cmd/watch/watch.go # リポジトリでの例
```

これで同期元のカレンダーに予定作成すると、同期先に加工後の予定が追加されるはずです。

# おわりに

実現したいことに対してだいぶ煩雑ですが、Zapierなどで条件分岐を駆使して同じようなことをするにはたいてい有料だしめちゃくちゃ大変ですし、仕方ありません。

[^oauth]: OAuthクライアントによるアクセスは可能ですが、テストアプリだとrefresh_tokenが7日で期限切れになる、本番アプリはプライバシーポリシー等の用意が必要で面倒です。
[^scope]: `https://www.googleapis.com/auth/calendar.events` だと、自分がアクセス可能な他の人のカレンダーが含まれてしまうので脆弱性を埋め込んでしまった場合に被害が大きくなります。気にせずscopeを広くすれば、AからBへGoogle Calendarをすでに共有している場合はBのみ追加で良くなるはずです。
[^webhook]: channelを登録するとWebhookに対して初回用リクエストを発行するため。
[^zapier]: 休日含め常にブロック予定を作るだけならZapierなどでも可能です。
[^channel-list]: [チャンネル一覧を取得するAPIはありません](https://qiita.com/shin1ogawa/items/6db429a3d27e239653d7#fn4)。保存しておいたIDを読み出すか、webhookを発火させてればヘッダに含まれるIDから取得できます