🍉

Google認証+Googleカレンダー予定登録APIを実装(Go×React)

に公開

はじめに

AIエージェントを自分の手で構築していく中で機能の一つとしてGoogleカレンダーへの操作機能は不可欠だと思いました。そのため、AIを組み込む前に、ユーザーの入力からカレンダーへ予定を登録するの基本的なAPIを作成しました。

使用技術

backend: Go (Gin)
frontend: React (Typescript)
認証: Google OAuth2
その他: Google Calendar API

  • ディレクトリ構造(参考)
      backend/
      ├── main.go
      ├── routes/
      │   └── routes.go
      ├── controllers/
      │   ├── auth_controller.go      
      │   └── calendar_schedule.go  
      ├── services/
      │   └── google_calendar.go      
      ├── models/
      │   └── chat.go            
      └── utils/
          └── oauth_config.go
    
      frontend/
      ├── App.tsx
      └── src/
         └── Chat.tsx    
    

Google Cloud側の準備

Google Calendar APIを利用するために、Google Cloud Platformでの準備が必要です。詳しくは公式ドキュメントや他の方の記事をご参照ください。詳細かつ丁寧に書かれております。

  • プロジェクト作成&Calendar APIの有効化
    • プロジェクト作成し、検索バーで「Calendar」と入力しGoogle Calendar APIを有効化します。
  • OAuth 2.0 クライアントID・シークレット取得
    • APIとサービス / 認証情報 / 認証情報を作成 からOAuthクライアントIDを作成します。jsonファイルをダウンロードしておくと、参照する際に便利です。
  • テストユーザー登録
    • APIとサービス / OAuth 同意画面 / 対象 でした方にスクロールするとテストユーザーという項目の中に「+ Add users」というボタンがあるのでそこから使用したいメールアドレスを登録します。公開ステータスが「テスト中」の時はここで登録したユーザーのみがアクセスできます。
  • リダイレクトURI設定
    • APIとサービス / OAuth 同意画面 / クライアント / 作成したクライアントID で承認済みのリダイレクトURIの欄に以下のURLを追加しておきます。
      http://localhost:8080/auth/google/callback

Goサーバー側の環境変数

Google OAuthの認証情報を安全に管理するため、.envファイルに以下の環境変数を設定します。.gitignoreにも追記しておきます。

.env
GOOGLE_CLIENT_ID=xxxxxxxxxxxxxxxx.apps.googleusercontent.com
GOOGLE_CLIENT_SECRET=yyyyyyyyyyyyyyyyyyyyyyyy
GOOGLE_REDIRECT_URL=http://localhost:8080/auth/google/callback

認証フロー

0. Ginを用いたエンドポイントの定義

routes/routes.go
r.GET("/auth/google/login", controllers.GoogleLogin)
r.GET("/auth/google/callback", controllers.GoogleCallback)
r.POST("/chat/schedule", controllers.HandleScheduleChat)
r.GET("/auth/check", controllers.CheckLogin)

1. /auth/google/login → Google OAuth認証画面へ

このエンドポイントにアクセスすると、ユーザーはGoogle OAuth認証画面にリダイレクトされます。

controllers/auth_controller.go
func GoogleLogin(c *gin.Context) {
	config := utils.GetGoogleOAuthConfig()

	url := config.AuthCodeURL("state-token", oauth2.AccessTypeOffline)
	log.Println("[DEBUG] Redirecting to Google Auth URL:", url)

	c.Redirect(http.StatusTemporaryRedirect, url)
}
utils/oauth_config.go
func GetGoogleOAuthConfig() *oauth2.Config {
	clientID := os.Getenv("GOOGLE_CLIENT_ID")
	clientSecret := os.Getenv("GOOGLE_CLIENT_SECRET")
	redirectURL := os.Getenv("GOOGLE_REDIRECT_URL")

	log.Println("[GetGoogleOAuthConfig] client_id:", clientID)

	return &oauth2.Config{
		ClientID:     clientID,
		ClientSecret: clientSecret,
		RedirectURL:  redirectURL,
		Scopes: []string{
			"https://www.googleapis.com/auth/calendar",
			"https://www.googleapis.com/auth/userinfo.email",
		},
		Endpoint: google.Endpoint,
	}
}

2. /auth/google/callback → トークンを取得し、Cookieに保存し、Reactへリダイレクト

Google認証が成功すると、このエンドポイントにリダイレクトされ、認証からアクセストークンを取得します。取得したトークンはCookeiに保存し、フロントエンドにリダイレクトします。

controllers/auth_controller.go
func GoogleCallback(c *gin.Context) {
	code := c.Query("code")
	if code == "" {
		c.JSON(http.StatusBadRequest, gin.H{"error": "Code not found"})
		return
	}

	config := utils.GetGoogleOAuthConfig()

	token, err := config.Exchange(context.Background(), code)
	if err != nil {
		c.JSON(http.StatusInternalServerError, gin.H{"error": "Token exchange failed"})
		return
	}

	// Cookieに保存
	c.SetCookie("access_token", token.AccessToken, 3600, "/", "localhost", false, true)
	c.Redirect(http.StatusTemporaryRedirect, "http://localhost:3000")
}

3. チャットから予定を送信とログイン状態の確認

フロントエンドからは、ユーザーがメッセージを送信する際に/chat/scheduleエンドポイントを呼び出します。また、useEffect/auth/checkエンドポイントを呼び出し、ユーザーのログイン状態をCookieで確認します。

components/Chat.tsx
    // Cookie内のアクセストークン確認
    useEffect(() => {
        const checkLogin = async () => {
            try {
                const res = await fetch("http://localhost:8080/auth/check", {
                    method: "GET",
                    credentials: "include",
                });
                const data = await res.json();
                setLoggedIn(data.loggedIn);
            } catch (err) {
                console.error("ログイン確認エラー", err);
                setLoggedIn(false);
            }
        };
    
        checkLogin();
    }, []);

    const handleSend = async () => {
        if (!message.trim()) return;

        try {
            const res = await fetch('http://localhost:8080/chat/schedule', {
                method: 'POST',
                headers: {
                    'Content-Type': 'application/json',
                },
                credentials: 'include',
                body: JSON.stringify({ message }),
            });

            const data = await res.json();

            if (res.ok) {
                setReply(`${data.message}`);        
            } else {
                setReply(`${data.error}`);
            }
        
        } catch (err) {
            console.error('送信エラー: ', err);
            setReply('エラーが発生しました');
        }
    };
controllers/auth_controller.go
func CheckLogin(c *gin.Context) {
	_, err := c.Cookie("access_token")
	if err != nil {
		c.JSON(http.StatusUnauthorized, gin.H{"loggedIn": false})
		return
	}
	c.JSON(http.StatusOK, gin.H{"loggedIn": true})
}

4. 予定を解釈してGoogleカレンダーに予定を登録

controllers/chat_schedule.go
func HandleScheduleChat(c *gin.Context) {
	var req struct {
		Message string `json:"message"`
	}
	if err := c.ShouldBindJSON(&req); err != nil {
		log.Println("[ERROR] メッセージのバインドに失敗:", err)
		c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid message"})
		return
	}

	log.Println("[DEBUG] メッセージ内容:", req.Message)

	// Cookieからアクセストークン取得
	accessToken, err := c.Cookie("access_token")
	if err != nil || accessToken == "" {
		log.Println("[ERROR] Cookieからaccess_token取得失敗:", err)
		c.JSON(http.StatusUnauthorized, gin.H{"error": "アクセストークンが見つかりません"})
		return
	}

	token := &oauth2.Token{
		AccessToken: accessToken,
		TokenType:   "Bearer",
	}

	// 例:「6月5日の10時から11時でMTG」
	title, start, end, err := parseMessageSimple(req.Message)
	if err != nil {
		log.Println("[ERROR] メッセージ解析に失敗:", err)
		c.JSON(http.StatusBadRequest, gin.H{"error": "Could not parse message"})
		return
	}

	log.Printf("[INFO] 予定を作成します: %s %s〜%s\n", title, start.Format(time.RFC3339), end.Format(time.RFC3339))

	if err := services.CreateCalendarEvent(token, title, start, end); err != nil {
		log.Println("[ERROR] Google Calendar 予定作成に失敗:", err)
		c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
		return
	}

	c.JSON(http.StatusOK, gin.H{"message": "予定を追加しました", "title": title})
}

// クエリから予定を登録するために必要な情報を返す
func parseMessageSimple(msg string) (title string, start, end time.Time, err error) {
	log.Println("[DEBUG] メッセージ内容:", msg)

	// 正規表現で抽出(追々AIを組み込んで柔軟に対応する)
	re := regexp.MustCompile(`(?P<month>\d{1,2})月(?P<day>\d{1,2})日の(?P<startHour>\d{1,2})時から(?P<endHour>\d{1,2})時(?:まで)?で(?P<title>.+)`)
	matches := re.FindStringSubmatch(msg)

	if len(matches) < 6 {
		log.Println("[ERROR] メッセージからの抽出に失敗")
		return "", time.Time{}, time.Time{}, fmt.Errorf("format error")
	}

	now := time.Now()
	year := now.Year()

	month, _ := strconv.Atoi(matches[1])
	day, _ := strconv.Atoi(matches[2])
	startHour, _ := strconv.Atoi(matches[3])
	endHour, _ := strconv.Atoi(matches[4])
	title = matches[5]

	location := time.Local
	start = time.Date(year, time.Month(month), day, startHour, 0, 0, 0, location)
	end = time.Date(year, time.Month(month), day, endHour, 0, 0, 0, location)

	log.Printf("[DEBUG] title: %s, start: %s, end: %s\n", title, start.Format(time.RFC3339), end.Format(time.RFC3339))
	return title, start, end, nil
}

5. カレンダー登録処理

services/google_calendar.go
func CreateCalendarEvent(token *oauth2.Token, title string, start, end time.Time) error {
	ctx := context.Background()
	config := utils.GetGoogleOAuthConfig()
	client := config.Client(ctx, token)

	srv, err := calendar.NewService(ctx, option.WithHTTPClient(client))
	if err != nil {
		return err
	}

	event := &calendar.Event{
		Summary: title,
		Start:   &calendar.EventDateTime{DateTime: start.Format(time.RFC3339)},
		End:     &calendar.EventDateTime{DateTime: end.Format(time.RFC3339)},
	}

	_, err = srv.Events.Insert("primary", event).Do()
	return err
}

6. フロント側の実装

ログイン状態に応じて表示を切り替え、ログイン済みであればチャット入力欄と送信ボタンを表示します。

componetns/Chat.tsx
return (
    <div>
        <h1>Zenith</h1>

        {!loggedIn ? (
            <>
                <button onClick={handleLogin}>Googleアカウントでログイン</button>
                <button onClick={() => window.location.reload()}>ログイン後に反映</button>
            </>
        ): (
            <>
                <input 
                    type="text"
                    value={message}
                    onChange={(e) => setMessage(e.target.value)}
                    placeholder="メッセージを入力 (例:6月5日の10時から11時でMTG)"
                    style={{ padding: '0.5rem', width: '300px' }}
                />
                <button onClick={handleSend} style={{ marginLeft: '1rem' }}>
                    送信
                </button>
                <div style={{ marginTop: '1rem' }}>
                    <strong>応答:</strong> {reply}
                </div>
            </>
        )}
    </div>
);

動作確認

Go, Reactサーバ起動後、localhost:3000にアクセスすると、以下のようにGoogleアカウントでのログインボタンが表示されます。

この状態で「Googleアカウントでログイン」を押すとログインされ以下のようなチャット入力画面に遷移します。

ちなみにこの画面からログイン前の画面に戻るためには、ブラウザの検証からCookieに保存されているaccess_tokenを消去し、ページをリロードすることでログイン前の状態に戻れます。

では、入力した予定が登録できるか検証してみます。ちょうど高校の時の友人とカレーを食べる予定があるのでその予定を登録したいと思います。入力欄に「6月6日の15時から17時でカレーwith友人A」と入力してみます。

無事にGoogleカレンダーに予定が登録されました。

おわりに

本記事で、AIエージェントの機能としてGoogleカレンダーの予定登録を行うための基本的なAPIを構築しました。今後は、カレンダーの予定の読み取りや消去といった機能を追加し、現在の正規表現によるルールベースの解析をAIによるより柔軟に処理に置き換えていく予定です。また、Googleカレンダーだけでなく他の外部サービスとも連携できるよう拡張を進めていきたいと考えています。

改善点等ありましたら、ぜひコメントください。

Discussion