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

[Mia] How to use Google Calendar API with Go: From Refresh Token to Event Acquisition

google-calendar-refresh-token-cron
This article can be read in about 16 minutes.

Introduction.

I am developing a small cat-shaped robot “Mia”, that speaks various funny phrases in dialects

In the previous article, we implemented the following part of the calendar integration process.

This time, we would like to implement a system that accesses the Google Calendar API and periodically retrieves events for all users.

Overall flow

The Google API does not allow direct use of refresh_token access to the API. Instead, you must use refresh_token to issue a temporary access_token and send a request to the API using this token.

This article implements this process in the following four steps

  1. Get Access Token using Refresh Token
  2. Access Google Calendar API to retrieve events
  3. Automation of event acquisition (construction of periodic execution scheduler)
  4. Outputs acquired events to log

flowchart

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

Get Access Token using Refresh Token

First, implement a function to obtain an access_token from Google’s authentication server using refresh_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
}

Explanation of what is in the code

Get client_id andclient_secret from environment variables: Google’s OAuth 2.0 authentication requires client_id and client_secret, so they are read from the environment variables.

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 が設定されていません")
}

Create a request payload containing a refresh token and send Han TTP request to Google’s token endpoint. Decode the response into an AccessTokenResponse structure to obtain an access token. Based on the access token, the event is retrieved from the Google Calendar API.

Automation of event acquisition (building a scheduler)

Use cron library to perform event retrieval every 5 minutes. All users with refresh tokens are retrieved from the database in order to retrieve target users dynamically.

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: Scheduler implementation

Below is the scheduler task for Google Calendar added to main.go

Retrieve Google Calendar appointments that start within 5 minutes and output them to the log.

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

operation check

The above code resulted in the following log output every 5 minutes.

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

The part of getting events from Google Calendar API using refresh_token in the table has been implemented. The next step is to add a function to synthesize speech based on the text information of the acquired events and send it to the main body of Mia (ESP32) via device shadow.

Copied title and URL