方言を話すおしゃべり猫型ロボット『ミーア』をリリースしました(こちらをクリック)

【ミーア】Google Calendar APIをGoで操作する方法:Refresh Tokenからイベント取得まで

google-calendar-refresh-token-cron
この記事は約14分で読めます。

はじめに

様々な方言を話す。おしゃべりに小型ロボット「ミーア」を開発中。

前回、こちらの記事で、Flutterアプリからカレンダー連携ボタンを押して、googleサインインを実行したのちに、カレンダーのアクセスを許可すると、サーバー側に下記のようにrefreshtokenが保存される部分までを実装した。

今回は、refresh_token を使ってGoogle Calendar APIにアクセスし、全ユーザーのイベントを定期的に取得するシステムを構築するところまでを実装したいと思う。

全体の流れ

Google APIでは、refresh_token を直接利用してAPIにアクセスすることはできない。代わりに、refresh_token を使用して一時的な access_token を発行し、このトークンを使ってAPIにリクエストを送る必要がある。

この記事では、以下の4ステップでこの処理を実装する

  1. Refresh Tokenを使ってAccess Tokenを取得
  2. Google Calendar APIにアクセスしてイベントを取得
  3. イベント取得の自動化(定期実行スケジューラの構築)
  4. 取得したイベントをログに出力

フロー図

ShellScript
[refresh_token] --> [Google Token Endpoint] --> [access_token]
[access_token] --> [Google Calendar API] --> [イベント取得]

Refresh Tokenを使ってAccess Tokenを取得

まずは refresh_token を利用してGoogleの認証サーバーから access_token を取得する関数を実装する。

mia/google/google_calendar_service.go

Go
package google

import (
	"bytes"
	"context"
	"encoding/json"
	"fmt"
	"io/ioutil"
	"net/http"
	"os"
	"time"

	"golang.org/x/oauth2"
	"google.golang.org/api/calendar/v3"
	"google.golang.org/api/option"
)

const TokenEndpoint = "https://oauth2.googleapis.com/token"

// AccessTokenResponse GoogleのトークンAPIレスポンス
type AccessTokenResponse struct {
	AccessToken string `json:"access_token"`
	ExpiresIn   int    `json:"expires_in"`
	TokenType   string `json:"token_type"`
	Scope       string `json:"scope"`
}

// GoogleCalendarService Google Calendar APIを利用するサービス
type GoogleCalendarService struct {
	httpClient  *http.Client
	accessToken string
	expiry      time.Time
}

// NewGoogleCalendarService 初期化
func NewGoogleCalendarService() *GoogleCalendarService {
	return &GoogleCalendarService{
		httpClient: &http.Client{},
	}
}

// GetAccessToken リフレッシュトークンを使ってアクセストークンを取得
func (s *GoogleCalendarService) GetAccessToken(refreshToken string) (string, error) {
	// トークンが有効であれば再利用
	if s.accessToken != "" && time.Now().Before(s.expiry) {
		return s.accessToken, nil
	}

	clientID := os.Getenv("GOOGLE_CLIENT_ID")
	clientSecret := os.Getenv("GOOGLE_CLIENT_SECRET")

	if clientID == "" || clientSecret == "" {
		return "", fmt.Errorf("環境変数 GOOGLE_CLIENT_ID または GOOGLE_CLIENT_SECRET が設定されていません")
	}

	payload := map[string]string{
		"client_id":     clientID,
		"client_secret": clientSecret,
		"refresh_token": refreshToken,
		"grant_type":    "refresh_token",
	}
	body, _ := json.Marshal(payload)

	req, err := http.NewRequest("POST", TokenEndpoint, bytes.NewBuffer(body))
	if err != nil {
		return "", fmt.Errorf("リクエスト作成エラー: %v", err)
	}
	req.Header.Set("Content-Type", "application/json")

	resp, err := s.httpClient.Do(req)
	if err != nil {
		return "", fmt.Errorf("HTTPリクエストエラー: %v", err)
	}
	defer resp.Body.Close()

	if resp.StatusCode != http.StatusOK {
		responseBody, _ := ioutil.ReadAll(resp.Body)
		return "", fmt.Errorf("アクセストークン取得失敗: ステータスコード %d, レスポンス %s", resp.StatusCode, string(responseBody))
	}

	var tokenResp AccessTokenResponse
	if err := json.NewDecoder(resp.Body).Decode(&tokenResp); err != nil {
		return "", fmt.Errorf("レスポンスデコードエラー: %v", err)
	}

	s.accessToken = tokenResp.AccessToken
	s.expiry = time.Now().Add(time.Duration(tokenResp.ExpiresIn) * time.Second)
	return s.accessToken, nil
}

// GetEvents 指定された時間範囲のイベントを取得
func (s *GoogleCalendarService) GetEvents(ctx context.Context, accessToken string, timeMin, timeMax time.Time) ([]*calendar.Event, error) {
	srv, err := calendar.NewService(ctx, option.WithTokenSource(oauth2.StaticTokenSource(&oauth2.Token{
		AccessToken: accessToken,
	})))
	if err != nil {
		return nil, fmt.Errorf("Google Calendar APIクライアント作成エラー: %v", err)
	}

	events, err := srv.Events.List("primary").
		ShowDeleted(false).
		SingleEvents(true).
		TimeMin(timeMin.Format(time.RFC3339)).
		TimeMax(timeMax.Format(time.RFC3339)).
		OrderBy("startTime").
		Do()
	if err != nil {
		return nil, fmt.Errorf("Google Calendar APIイベント取得エラー: %v", err)
	}

	return events.Items, nil
}

コードの中身を解説

環境変数からclient_idclient_secretを取得GoogleのOAuth 2.0認証では、client_idclient_secret が必須なので、環境変数から読み込んでいる。

Go
clientID := os.Getenv("GOOGLE_CLIENT_ID")
clientSecret := os.Getenv("GOOGLE_CLIENT_SECRET")

if clientID == "" || clientSecret == "" {
    return "", fmt.Errorf("環境変数 GOOGLE_CLIENT_ID または GOOGLE_CLIENT_SECRET が設定されていません")
}

リフレッシュトークンを含んだリクエストペイロードを作成し、Googleのトークンエンドポイントに対してHTTPリクエストを送信。レスポンスをAccessTokenResponse 構造体にデコードし、アクセストークンを取得。アクセストークンをもとに、Google Calendar APIからイベントを取得するという流れ。

イベント取得の自動化(スケジューラの構築)

cron ライブラリを使って、5分おきにイベント取得を実行する。対象ユーザーを動的に取得するために、リフレッシュトークンを持つ全ユーザーをデータベースから取得。

Go
// GoogleCalendarTokenServiceに全ユーザー取得メソッドを追加
func (s *GoogleCalendarTokenService) GetAllTokens(ctx context.Context) ([]mia.GoogleCalendarToken, error) {
	var tokens []mia.GoogleCalendarToken
	query := `
		SELECT user_id, refresh_token, created_at, updated_at
		FROM google_calendar_tokens
	`
	err := s.db.SelectContext(ctx, &tokens, query)
	if err != nil {
		return nil, fmt.Errorf("failed to fetch all tokens: %w", err)
	}
	return tokens, nil
}

main.go:スケジューラの実装

以下は、main.go に追加したGoogle Calendar用のスケジューラタスク

5分以内に開始するGoogleカレンダーの予定を取得し、ログに出力する。

Go
func main(){
	// Google Calendar Token Service の初期化
	googleTokenService := rdb.NewGoogleCalendarTokenService(db)
	googleTokenValidator, err := google.NewGoogleCalendarTokenValidator()
	if err != nil {
		slog.Error("Failed to initialize Google Calendar Token Validator", "error", err)
		os.Exit(1)
	}
	// Google Calendar Serviceの初期化
	googleCalendarService := google.NewGoogleCalendarService()
	
	
	// 5分ごとにGoogle Calendarのイベントを取得するタスク
	c.AddFunc("0 */5 * * * *", func() {
		ctx := context.Background()
		fetchGoogleCalendarEvents(ctx, googleTokenService, googleCalendarService)
	})
}


// google calendar eventsを取得
func fetchGoogleCalendarEvents(
	ctx context.Context,
	googleTokenService *rdb.GoogleCalendarTokenService,
	googleCalendarService *google.GoogleCalendarService,
) {
	slog.Info("Starting Google Calendar events fetch task")

	// データベースから対象ユーザーを取得
	userTokens, err := googleTokenService.GetAllTokens(ctx)
	if err != nil {
		slog.Error("Failed to fetch user tokens from database", "error", err)
		return
	}

	for _, token := range userTokens {
		// リフレッシュトークンを使ってアクセストークンを取得
		accessToken, err := googleCalendarService.GetAccessToken(token.RefreshToken)
		if err != nil {
			slog.Error("Failed to fetch Google Calendar access token", "userID", token.UserID, "error", err)
			continue
		}

		// Google Calendar APIからイベントを取得
		now := time.Now().UTC()
		fiveMinutesLater := now.Add(5 * time.Minute)
		events, err := googleCalendarService.GetEvents(ctx, accessToken, now, fiveMinutesLater)
		if err != nil {
			slog.Error("Failed to fetch Google Calendar events", "userID", token.UserID, "error", err)
			continue
		}

		// イベントをログに出力
		for _, event := range events {
			slog.Info("Fetched Google Calendar event",
				"userID", token.UserID,
				"event", event.Summary,
				"startTime", event.Start.DateTime,
			)
		}
	}
}

動作確認

以上のコードにより、5分ごとに以下のようなログが出力された。

ShellScript
clocky_api_local    | {"time":"2024-11-26T04:00:02.038511052Z","level":"INFO","msg":"Fetched Google Calendar event","userID":4,"event":"テスト:デザイン定例","startTime":"2024-11-26T13:00:00+09:00"}

テーブル内のrefresh_tokenを使ってGoogle Calendar APIからイベントを取得する部分は実装完了。次のステップとして、取得したイベントのテキスト情報をもとに音声合成をして、デバイスシャドウ経由でミーア本体(ESP32)に送信する機能を追加する。

タイトルとURLをコピーしました