7
4

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

More than 1 year has passed since last update.

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

Last updated at Posted at 2021-07-11

個人用のGoogleカレンダーに予定を登録→平日なら内容に応じて前後30分増やして会社のカレンダーにブロック予定を作成、などを行います。1
Google Calendar APIのGo client libraryとApp Engineを主に用いて構築します。
内容をまとめたリポジトリはこちら:

この記事について

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

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

事前の設定

Google Calendar APIの有効化

GCPでAPIを有効化します。コンソールの検索バーで "Google Calendar API" を検索して遷移します。「有効化」を押下すれば完了です。

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

APIの有効化後は
スクリーンショット 2021-07-10 9.09.28.png
となりますが、無視してサービスアカウントを作成します。2

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

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

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

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

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

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

実装

Webhook URLをchannelとして登録

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

ただし、実際のコール前にWebhook用ドメインの認証とWebhookのデプロイ3が必要です。

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)}

ソースはこちら

Webhook

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

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

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

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-Statesyncで呼び出されるのでそれをハンドリングします。

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

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を使うときに指定できないクエリパラメータが多いので、syncTokenのみでコールします。

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は保存しておく
	// ...
}
イベントリストの取得と加工

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

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)
}

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

// 作成するイベントを返す
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を保存し直す必要があります4
削除は

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以下の処理です

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

# 毎週火曜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ファイルとして配置するのが楽です。こちらを参考にします。

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

デプロイ

gcloud app deploy --version 1 -q

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

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

おわりに

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

  1. 休日含め常にブロック予定を作るだけならZapierなどでも可能です。

  2. OAuthクライアントによるアクセスは可能ですが、テストアプリだとrefresh_tokenが7日で期限切れになる、本番アプリはプライバシーポリシー等の用意が必要で面倒です。

  3. channelを登録するとWebhookに対して初回用リクエストを発行するため。

  4. チャンネル一覧を取得するAPIはありません。保存しておいたIDを読み出すか、webhookを発火させてればヘッダに含まれるIDから取得できます

7
4
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
7
4

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?