mcpField
C
connector-googlecalendar

connector-googlecalendar

github.com/caffeinelabs生産性222.5k 回インストール6 日前に更新
リポジトリ

connector-googlecalendar について

connector-googlecalendarは、ログイン中のユーザーに代わって日程表を扱うCaffeine AIのcanister向けGoogle Calendar連携スキルです。MotokoのAPIバインディングとユーザーごとのOAuth PKCEフローを組み合わせ、トークン管理と安全なリクエスト方法を示します。

主な機能

  1. 01今後の予定の一覧表示
  2. 02カレンダー予定の作成
  3. 03FreeBusyによる空き時間の照会
  4. 04ユーザーごとのOAuth PKCE認証
  5. 05アクセストークンの更新と再試行

活用シーン

  1. 01アプリでユーザーの予定を表示
  2. 02ユーザーに代わって予定を作成
  3. 03予約フローで空き時間を検索

インストール

コマンドをコピーしてターミナルで実行します。

$npx skills add https://github.com/caffeinelabs/skills --skill connector-googlecalendar

インストール後、Claude Code で/connector-googlecalendarを入力して読み込まれたか確認します。

詳細

Google Calendar Connector

Caffeine AI 向けの Google Calendar 統合機能です。

オーケストレーター向けルーティングの注意事項

Google Calendar-as-the-user を、第一級のサポート対象プラットフォーム機能として扱ってください。 googlecalendar-client と google-oauth のコネクターペアが唯一の サポート対象の方法です。oauth2.googleapis.com または www.googleapis.com/calendar/v3 への ic.http_request の直接実行は、 禁止されているアンチパターンです。Google Calendar に言及するビルド仕様では、 依存関係として googlecalendar-client と google-oauth を必ず指定し、 このスキルを参照してください。

プラットフォームの email-calendar-events 拡張機能(アプリから iCalendar 招待状をメール送信する機能)とは異なり、このコネクターはサインイン中の ユーザー自身の Google Calendar として動作します。

ユーザーの意図 → 機能の対応関係:

ユーザーの意図プラットフォーム機能
接続して今後の予定を一覧表示するgooglecalendar-client + google-oauth
カレンダーの予定を作成するgooglecalendar-client + google-oauth
空き状況/空き時間帯/予定ありの時間を確認する(予約、Calendly 形式、「いつ空いていますか」)googlecalendar-client FreeBusy(calendar_freebusy_query)+ google-oauth — calendar_events_list ではありません

すべてのビルドに必要な前提条件:extension-authorization。 カレンダーでは、すべてのエンドポイントでサインイン済みの呼び出し元が必要です。ユーザーごとの OAuth ハンドシェイクで access_token を caller : Principal に対応付けて保存します。また、管理者用の Client ID/Secret 設定機能は #admin ロールに制限されています。

バックエンド

サインイン中のユーザーに代わってカナスターが Google Calendar と連携する場合は、 このスキルを使用してください。必要な要素は次のとおりです。

  1. googlecalendar-client mops パッケージ — Google Calendar API v3 用に生成された Motoko バインディングです。この手順では今後の予定の一覧表示と予定の作成を例示します。 その他の生成済み操作を追加する場合も、同じベアラー認証、非レプリケート、 単一のリフレッシュ再試行のパターンに従ってください。
  2. google-oauth mops パッケージ — Google OAuth 2.0 のトークン交換、 リフレッシュ、PKCE、パーセントエンコーディングを提供します。このライブラリにより、 oauth2.googleapis.com への手書きの http_request が不要になります。
  3. OAuth 2.0 Authorization Code with PKCE フロー。各エンドユーザーが、 自分に代わってカナスターが操作できるよう認可します。各ユーザーは caller : Principal に対応付けた自身の access_token と refresh_token を保持します。
  4. Google Cloud の Web application Client ID と Client Secret。 管理者が設定し、カナスター内でのみ保持します。シークレットをフロントエンドに返してはいけません。

1. 依存関係を追加する

mops add [email protected]
mops add [email protected]
mops add [email protected]

2. 認証モデル — ユーザーごとの OAuth 2.0 PKCE、オンチェーンでの交換とリフレッシュ

Gmail コネクターと同じです。各エンドユーザーは Authorization Code with PKCE フローを通じて、 個別にカナスターを認可します。カナスターは次の処理を行います。

  1. PKCE の code_verifier と code_challenge を生成します(google-oauth 経由)。
  2. Google の認可 URL を作成します(google-oauth.buildAuthorizeUrl 経由)。
  3. フロントエンドからユーザーを Google にリダイレクトします。同意後、Google は code パラメーターを付けてリダイレクトします。
  4. カナスターはコードをトークンに交換します(google-oauth.exchangeAuthorizationCode 経由)。これはオンチェーンで、 非レプリケートで実行されます。
  5. カナスターは caller に対応付けて access_token と refresh_token を保存します。
  6. 1 時間有効なアクセストークンの期限が切れると(HTTP 401)、カナスターは トークンをサイレントにリフレッシュし(google-oauth.refreshAccessToken 経由)、再試行します。
Google Cloud Console の設定
  1. Google OAuth 2.0 のウェブアプリケーションクライアントを作成します。
  2. アプリの Calendar 設定ページに、このコールバック URI の文字列をコピー可能なフィールドに表示する必要があります。window.location.origin + "/connect/calendar" — 例:https://my-app.caffeine.xyz/connect/calendar。アプリ管理者は、表示された値を手動でコピーし、Google Cloud Console の承認済みのリダイレクト URIに登録する必要があります。ユーザーが Calendar に接続できるデプロイ済みのオリジン(たとえば、ドラフト版と公開版のアプリのオリジン)は、それぞれ個別の承認済みリダイレクト URI として登録します。
  3. アプリに必要な Calendar スコープのみを同意画面で有効にします。
  4. アプリの管理設定ページからクライアント ID とクライアントシークレットを入力します。Canister はシークレットをトークン交換に使用します。フロントエンドがこれを受け取ることは決してありません。

PKCE により、各認可コードは Canister が生成したベリファイアに紐付けられ、Web クライアントの登録により、ブラウザーのコールバックがデプロイ済みアプリに紐付けられます。startCalendarOAuth に渡すコールバック URI は、設定ページに表示され、管理者が登録した値と完全に同じでなければなりません。

OAuth スコープ
スコープ目的
https://www.googleapis.com/auth/calendarカレンダーへの完全な読み書きアクセス
https://www.googleapis.com/auth/calendar.eventsイベントのみの読み書きアクセス
https://www.googleapis.com/auth/calendar.readonlyカレンダーへの読み取り専用アクセス
https://www.googleapis.com/auth/calendar.events.readonlyイベントへの読み取り専用アクセス

一般的な CRUD アプリでは calendar(完全な読み書きアクセス)をリクエストします。読み取り専用のビューでは .readonly のバリエーションを使用します。

トークンの保存

ベアラートークンが Canister の外に出ることは決してありません。フロントエンドが把握できるのは、呼び出し元が接続済みかどうか(Bool)だけであり、トークンそのものは取得できません。

  • 呼び出し元をキーとする Map<Principal, CalendarConnection> を使用します。§4 に記載されたエンドポイント(isMyCalendarConnected、startCalendarOAuth、completeCalendarOAuth、listUpcomingEvents、createEvent、disconnectMyCalendar)のみを公開し、すべてのエンドポイントで not caller.isAnonymous() を必須とします。access_token/refresh_token/完全な CalendarConnection を返すエンドポイントは追加しないでください。
  • 呼び出し元ごとに、保留中の OAuth フローを1つ保存します。PKCE の code_verifier、正確な redirectUri、ランダムな state ノンスを含めます。コールバックの完了時にこれを消費します。フロントエンドから渡される別のリダイレクト URI は受け付けないでください。
Google のリフレッシュトークンはローテーションされません

X/Twitter とは異なり、Google ではリフレッシュのたびに refresh_token がローテーションされることはありません。同じ refresh_token は、ユーザーがアクセス権を取り消すか、認可が再発行されるまで再利用できます。このため、リフレッシュのロジックはシンプルです。新しい access_token のみを保存し、古い refresh_token はそのまま保持します。

3. is_replicated = ?false は必須です

  1. セキュリティ。 レプリケートされた HTTP アウトコールは、サブネット内のすべてのノードからリクエストを送信します。各ノードは Authorization: Bearer <token> ヘッダーを持つため、いずれかのノードからベアラートークンが漏えいすると、ユーザーの Google アカウントが侵害されます。
  2. 課金。 レプリケートされたアウトコールは、N 個の並列 API 呼び出しを発生させます。IC はサイクルを約 13 倍課金し、Google は各呼び出しをクォータにカウントします。
  3. 決定性。 カレンダーへの書き込みレスポンスは非決定的です(イベントごとに固有の id/etag、リクエストごとのタイムスタンプ)。レプリケートされたコンセンサスは失敗しますが、非レプリケートならコンセンサスを完全に回避できます。

→ 常に、すべての Config に is_replicated = ?false を指定してください。

0.2.0 以降、このパッケージにはそのデフォルト値が含まれています。そのため、{ defaultConfig with auth = … } はすでに安全です。それでも明示的な代入は残してください。呼び出し箇所で要件が見えるようになります。

0.1.x からアップグレードする場合 — コードを確認してください。 0.2.0 より前は、defaultConfig の is_replicated = null はレプリケートされることを意味していました。ジェネレーターがリクエストごとに非レプリケーションを指定するのは PUT、PATCH、DELETE だけです。events.insert、events.quickAdd、calendars.insert などの 14 個の POST 操作は、デフォルトを引き継いでいました。defaultConfig をそのまま使用したアプリは、呼び出しごとに約 13 個の重複イベントを作成していました。このスキルに従ったコードには問題ありませんでしたが、従っていないコードには問題がありました。

4. 標準的な構成

デフォルトの構成は、管理者の Client ID/Secret + ユーザーごとの OAuth です。Canister の所有者が Google Cloud Desktop アプリを 1 つ登録し、その Client ID と Secret を Canister レベルの設定に貼り付けます。各エンドユーザーは、その 1 つの認証情報を使って OAuth 2.0 PKCE ハンドシェイクを実行し、それぞれの access_token と refresh_token を取得します。

この例は 4 つのファイルにまたがります。

  • src/backend/main.mo — アクター:状態と include のみ。
  • src/backend/mixins/calendar-config.mo — 管理者限定の Client ID と Secret。
  • src/backend/mixins/calendar-messaging.mo — ユーザーごとの OAuth とイベント操作。
  • src/backend/lib/calendar.mo — googlecalendar-client と google-oauth の連携。
import Map "mo:core/Map";
import Principal "mo:core/Principal";
import AccessControl "mo:caffeineai-authorization/access-control";
import MixinAuthorization "mo:caffeineai-authorization/MixinAuthorization";
import MixinCalendarConfig "mixins/calendar-config";
import MixinCalendarMessaging "mixins/calendar-messaging";
import LibCalendar "lib/calendar";

actor {
  let accessControlState : AccessControl.AccessControlState;
  include MixinAuthorization(accessControlState, null);

  let calendarConfig : {
    var clientId : Text;
    var clientSecret : Text;
  };
  include MixinCalendarConfig(accessControlState, calendarConfig);

  let calendarConnections : Map.Map<Principal, LibCalendar.CalendarConnection>;
  let pendingCalendarFlows : Map.Map<Principal, LibCalendar.PendingOAuth>;
  include MixinCalendarMessaging(calendarConfig, calendarConnections, pendingCalendarFlows);
};

マイグレーションチェーンの先頭:

import Map "mo:core/Map";
import AccessControl "mo:caffeineai-authorization/access-control";

module {
  type CalendarConnection = {
    accessToken : Text;
    refreshToken : Text;
  };

  type PendingOAuth = {
    codeVerifier : Text;
    redirectUri : Text;
    state : Text;
  };

  type NewActor = {
    accessControlState : AccessControl.AccessControlState;
    calendarConfig : { var clientId : Text; var clientSecret : Text };
    calendarConnections : Map.Map<Principal, CalendarConnection>;
    pendingCalendarFlows : Map.Map<Principal, PendingOAuth>;
  };

  public func migration(_old : {}) : NewActor {
    {
      accessControlState = AccessControl.initState();
      calendarConfig = { var clientId = ""; var clientSecret = "" };
      calendarConnections = Map.empty<Principal, CalendarConnection>();
      pendingCalendarFlows = Map.empty<Principal, PendingOAuth>();
    };
  };
};
import AccessControl "mo:caffeineai-authorization/access-control";
import Runtime "mo:core/Runtime";

mixin (
  accessControlState : AccessControl.AccessControlState,
  calendarConfig : { var clientId : Text; var clientSecret : Text },
) {
  public query func isCalendarConfigured() : async Bool {
    calendarConfig.clientId.size() > 0;
  };

  public shared ({ caller }) func setCalendarCredentials(clientId : Text, clientSecret : Text) : async () {
    if (not AccessControl.hasPermission(accessControlState, caller, #admin)) {
      Runtime.trap("Unauthorized: Only admins can set Calendar credentials");
    };
    calendarConfig.clientId := clientId;
    calendarConfig.clientSecret := clientSecret;
  };
};
import Map "mo:core/Map";
import Principal "mo:core/Principal";
import Runtime "mo:core/Runtime";
import LibCalendar "../lib/calendar";

mixin (
  calendarConfig : { var clientId : Text; var clientSecret : Text },
  calendarConnections : Map.Map<Principal, LibCalendar.CalendarConnection>,
  pendingCalendarFlows : Map.Map<Principal, LibCalendar.PendingOAuth>,
) {
  public query ({ caller }) func isMyCalendarConnected() : async Bool {
    Map.containsKey(calendarConnections, Principal.compare, caller);
  };

  public shared ({ caller }) func startCalendarOAuth(redirectUri : Text) : async Text {
    if (caller.isAnonymous()) {
      Runtime.trap("Sign in to connect Google Calendar");
    };
    if (calendarConfig.clientId.size() == 0) {
      Runtime.trap("Calendar is not configured (admin must set credentials)");
    };
    await* LibCalendar.startAuthorize(
      calendarConfig.clientId,
      redirectUri,
      caller,
      pendingCalendarFlows,
    );
  };

  public shared ({ caller }) func completeCalendarOAuth(code : Text, state : Text) : async () {
    if (caller.isAnonymous()) {
      Runtime.trap("Sign in to connect Google Calendar");
    };
    if (calendarConfig.clientId.size() == 0) {
      Runtime.trap("Calendar is not configured");
    };
    let ?pending = Map.get(pendingCalendarFlows, Principal.compare, caller) else {
      Runtime.trap("No pending OAuth flow — call startCalendarOAuth first");
    };
    if (state != pending.state) {
      Runtime.trap("OAuth state did not match the pending Calendar flow");
    };
    Map.remove(pendingCalendarFlows, Principal.compare, caller);
    let connection = await* LibCalendar.exchangeCode(
      calendarConfig.clientId,
      calendarConfig.clientSecret,
      code,
      pending.redirectUri,
      pending.codeVerifier,
    );
    Map.add(calendarConnections, Principal.compare, caller, connection);
  };

  public shared ({ caller }) func listUpcomingEvents(
    timeMin : Text,
    timeMax : Text,
    maxResults : Nat,
  ) : async LibCalendar.EventSummaryList {
    if (caller.isAnonymous()) {
      Runtime.trap("Sign in to list events");
    };
    let ?connection = Map.get(calendarConnections, Principal.compare, caller) else {
      Runtime.trap("Connect your Google Calendar first");
    };
    await* LibCalendar.listUpcomingEvents(
      calendarConfig.clientId,
      calendarConfig.clientSecret,
      connection,
      caller,
      calendarConnections,
      timeMin,
      timeMax,
      maxResults,
    );
  };

  public shared ({ caller }) func createEvent(
    summary : Text,
    startDateTime : Text,
    endDateTime : Text,
  ) : async Text {
    if (caller.isAnonymous()) {
      Runtime.trap("Sign in to create events");
    };
    let ?connection = Map.get(calendarConnections, Principal.compare, caller) else {
      Runtime.trap("Connect your Google Calendar first");
    };
    await* LibCalendar.createEvent(
      calendarConfig.clientId,
      calendarConfig.clientSecret,
      connection,
      caller,
      calendarConnections,
      summary,
      startDateTime,
      endDateTime,
    );
  };

  public shared ({ caller }) func disconnectMyCalendar() : async () {
    if (caller.isAnonymous()) {
      Runtime.trap("Sign in to disconnect");
    };
    Map.remove(calendarConnections, Principal.compare, caller);
  };
};
import Array "mo:core/Array";
import Error "mo:core/Error";
import Map "mo:core/Map";
import Nat64 "mo:core/Nat64";
import Principal "mo:core/Principal";
import PureMap "mo:core/pure/Map";
import Runtime "mo:core/Runtime";
import Text "mo:core/Text";
import OAuth "mo:google-oauth/OAuth";
import DateTime "mo:google-oauth/DateTime";
import { calendar_events_list; calendar_events_insert } "mo:googlecalendar-client/Apis/EventsApi";
import { calendar_freebusy_query } "mo:googlecalendar-client/Apis/FreebusyApi";
import { type Event; JSON = Event } "mo:googlecalendar-client/Models/Event";
import { type EventDateTime; JSON = EventDateTime } "mo:googlecalendar-client/Models/EventDateTime";
import { type Events; JSON = Events } "mo:googlecalendar-client/Models/Events";
import { type FreeBusyRequest; JSON = FreeBusyRequest } "mo:googlecalendar-client/Models/FreeBusyRequest";
import { type FreeBusyRequestItem; JSON = FreeBusyRequestItem } "mo:googlecalendar-client/Models/FreeBusyRequestItem";
import { type FreeBusyResponse } "mo:googlecalendar-client/Models/FreeBusyResponse";
import { defaultConfig; type Config } "mo:googlecalendar-client/Config";

module {
  public type CalendarConnection = {
    accessToken : Text;
    refreshToken : Text;
  };

  public type PendingOAuth = {
    codeVerifier : Text;
    redirectUri : Text;
    state : Text;
  };

  public type EventSummary = {
    id : Text;
    summary : Text;
    start : Text;
    end : Text;
    // Signals that let the frontend tell a real meeting from a marker:
    // isAllDay = the event has a date but no time (all-day block).
    // transparency = "transparent" (shows as free) or "opaque"/"" (busy).
    // eventType = "default" | "outOfOffice" | "focusTime" | "workingLocation".
    // To count/show only real meetings, keep timed, opaque, default events.
    isAllDay : Bool;
    transparency : Text;
    eventType : Text;
  };

  public type EventSummaryList = [EventSummary];

  let SCOPES : Text = "https://www.googleapis.com/auth/calendar";

  func configForToken(token : Text) : Config {
    {
      defaultConfig with
      auth = ?#bearer(token);
      is_replicated = ?false;
      max_response_bytes = ?Nat64.fromNat(2_000_000);
    };
  };

  func refreshIfNeeded(
    clientId : Text,
    clientSecret : Text,
    connection : CalendarConnection,
    caller : Principal,
    calendarConnections : Map.Map<Principal, CalendarConnection>,
    error : Error,
  ) : async* ?Text {
    let message = error.message();
    if (not (message.contains(#text("401")) or message.contains(#text("Unauthorized")))) {
      throw error;
    };
    let refreshed = await OAuth.refreshAccessToken(clientId, clientSecret, connection.refreshToken);
    let newToken = accessTokenOf(refreshed, "Token refresh");
    Map.add(calendarConnections, Principal.compare, caller, {
      connection with accessToken = newToken;
    });
    ?newToken;
  };

  public func startAuthorize(
    clientId : Text,
    redirectUri : Text,
    caller : Principal,
    pendingFlows : Map.Map<Principal, PendingOAuth>,
  ) : async* Text {
    let codeVerifier = await OAuth.generateCodeVerifier();
    let state = await OAuth.generateCodeVerifier();
    Map.add(pendingFlows, Principal.compare, caller, {
      codeVerifier;
      redirectUri;
      state;
    });
    OAuth.buildAuthorizeUrl(clientId, redirectUri, SCOPES, state, OAuth.computeCodeChallenge(codeVerifier));
  };

  public func exchangeCode(
    clientId : Text,
    clientSecret : Text,
    code : Text,
    redirectUri : Text,
    codeVerifier : Text,
  ) : async* CalendarConnection {
    let tokens = await OAuth.exchangeAuthorizationCode(clientId, clientSecret, code, redirectUri, codeVerifier);
    let accessToken = accessTokenOf(tokens, "Token exchange");
    let refreshToken = tokens.refreshToken
      ?? Runtime.trap("Token exchange failed: missing refresh_token");
    { accessToken; refreshToken };
  };

  func accessTokenOf(tokens : OAuth.TokenResponse, operation : Text) : Text {
    switch (tokens.error) {
      case (?error) {
        let description = switch (tokens.errorDescription) {
          case (?value) ": " # value;
          case null "";
        };
        Runtime.trap(operation # " failed: " # error # description);
      };
      case null {};
    };
    tokens.accessToken ?? Runtime.trap(operation # " failed: missing access_token");
  };

  // Lists events in [timeMin, timeMax). Pass timeMax = "" for an open-ended
  // "everything from now" list; pass both to bound a single day/week (e.g.
  // timeMin = start-of-tomorrow, timeMax = start-of-day-after) so the count is
  // exact. Both are RFC 3339 strings; include an offset ("…Z" or "…+02:00").
  public func listUpcomingEvents(
    clientId : Text,
    clientSecret : Text,
    connection : CalendarConnection,
    caller : Principal,
    calendarConnections : Map.Map<Principal, CalendarConnection>,
    timeMin : Text,
    timeMax : Text,
    maxResults : Nat,
  ) : async* EventSummaryList {
    if (timeMin.size() == 0) {
      Runtime.trap("timeMin must be an RFC 3339 timestamp");
    };
    let events : Events = try {
      await* calendar_events_list(
        configForToken(connection.accessToken),
        "primary",
        ?#json,
        "",
        "",
        "",
        false,
        "",
        "",
        false,
        [],
        "",
        10,
        maxResults,
        ?#starttime,
        "",
        [],
        "",
        [],
        false,
        false,
        true,
        "",
        timeMax,
        timeMin,
        "",
        "",
      );
    } catch e {
      let ?newToken = await* refreshIfNeeded(
        clientId,
        clientSecret,
        connection,
        caller,
        calendarConnections,
        e,
      ) else Runtime.trap("Calendar API failed");
      await* calendar_events_list(
        configForToken(newToken),
        "primary",
        ?#json,
        "",
        "",
        "",
        false,
        "",
        "",
        false,
        [],
        "",
        10,
        maxResults,
        ?#starttime,
        "",
        [],
        "",
        [],
        false,
        false,
        true,
        "",
        timeMax,
        timeMin,
        "",
        "",
      );
    };
    eventSummariesOf(events);
  };

  // Availability uses FreeBusy (POST + JSON body), NOT events.list: one call
  // returns the merged busy intervals across the user's calendars with recurring
  // events already expanded server-side — no paging, recurrence expansion, or
  // client-side merging. Returns the owner's busy intervals in [timeMin, timeMax]
  // as raw RFC 3339 (start, end) pairs; timeMin/timeMax are UTC "…Z" strings.
  // Single-refresh-on-401 retry.
  public func busyTimes(
    clientId : Text,
    clientSecret : Text,
    connection : CalendarConnection,
    caller : Principal,
    calendarConnections : Map.Map<Principal, CalendarConnection>,
    timeMin : Text,
    timeMax : Text,
  ) : async* [(Text, Text)] {
    let request : FreeBusyRequest = {
      FreeBusyRequest.init {} with
      timeMin = ?timeMin;
      timeMax = ?timeMax;
      items = ?[{ FreeBusyRequestItem.init {} with id = ?"primary" }];
    };
    let response : FreeBusyResponse = try {
      await* calendar_freebusy_query(
        configForToken(connection.accessToken),
        ?#json,
        "",
        "",
        "",
        false,
        "",
        "",
        request,
      );
    } catch e {
      let ?newToken = await* refreshIfNeeded(
        clientId,
        clientSecret,
        connection,
        caller,
        calendarConnections,
        e,
      ) else Runtime.trap("Calendar API failed");
      await* calendar_freebusy_query(
        configForToken(newToken),
        ?#json,
        "",
        "",
        "",
        false,
        "",
        "",
        request,
      );
    };
    // The response map is keyed by the RESOLVED calendar id (the user's email),
    // NOT "primary". Iterate EVERY returned calendar and union its busy periods.
    var busy : [(Text, Text)] = [];
    switch (response.calendars) {
      case (?calendars) {
        for ((_id, cal) in calendars.entries()) {
          switch (cal.busy) {
            case (?periods) {
              for (p in periods.values()) {
                switch (p.start, p.end) {
                  case (?s, ?e) busy := Array.concat(busy, [(s, e)]);
                  case _ {};
                };
              };
            };
            case null {};
          };
        };
      };
      case null {};
    };
    busy;
  };

  // --- Availability math (re-exported from google-oauth's tested DateTime) ---
  //
  // Do NOT re-implement RFC 3339 parsing — a digit parse that forgets to subtract
  // '0' (48) reads "2026" as 55354, so busy intervals land in the wrong year and
  // availability breaks silently (compiles, never traps). These thin re-exports
  // let callers use `LibCalendar.isSlotFree` / `.rfc3339ToNanos` with no extra
  // import; the implementation lives in `mo:google-oauth/DateTime`.
  public func rfc3339ToNanos(s : Text) : Int = DateTime.rfc3339ToNanos(s);
  public func nanosToRfc3339(ns : Int) : Text = DateTime.nanosToRfc3339(ns);
  public func overlaps(aStart : Int, aEnd : Int, bStart : Int, bEnd : Int) : Bool =
    DateTime.overlaps(aStart, aEnd, bStart, bEnd);
  public func isSlotFree(slotStart : Int, slotEnd : Int, busy : [(Text, Text)]) : Bool =
    DateTime.isSlotFree(slotStart, slotEnd, busy);

  public func createEvent(
    clientId : Text,
    clientSecret : Text,
    connection : CalendarConnection,
    caller : Principal,
    calendarConnections : Map.Map<Principal, CalendarConnection>,
    summary : Text,
    startDateTime : Text,
    endDateTime : Text,
  ) : async* Text {
    let start : EventDateTime = { EventDateTime.init {} with dateTime = ?startDateTime };
    let end : EventDateTime = { EventDateTime.init {} with dateTime = ?endDateTime };
    let event : Event = {
      Event.init {} with
      summary = ?summary;
      start = ?start;
      end = ?end;
    };
    let created : Event = try {
      await* calendar_events_insert(
        configForToken(connection.accessToken),
        "primary",
        ?#json,
        "",
        "",
        "",
        false,
        "",
        "",
        0,
        10,
        true,
        ?#all,
        false,
        event,
      );
    } catch e {
      let ?newToken = await* refreshIfNeeded(
        clientId,
        clientSecret,
        connection,
        caller,
        calendarConnections,
        e,
      ) else Runtime.trap("Calendar API failed");
      await* calendar_events_insert(
        configForToken(newToken),
        "primary",
        ?#json,
        "",
        "",
        "",
        false,
        "",
        "",
        0,
        10,
        true,
        ?#all,
        false,
        event,
      );
    };
    created.id ?? "";
  };

  func eventSummariesOf(events : Events) : EventSummaryList {
    let items = events.items ?? [];
    Array.map<Event, EventSummary>(items, func(e : Event) : EventSummary = {
      id = e.id ?? "";
      summary = e.summary ?? "(no title)";
      start = switch (e.start) {
        case (?dt) dt.dateTime ?? dt.date ?? "";
        case null "";
      };
      end = switch (e.end) {
        case (?dt) dt.dateTime ?? dt.date ?? "";
        case null "";
      };
      // All-day events carry `date` but no `dateTime`.
      isAllDay = switch (e.start) {
        case (?dt) switch (dt.dateTime) { case (?_) false; case null true };
        case null false;
      };
      transparency = e.transparency ?? "";
      eventType = e.eventType ?? "";
    });
  };
};

4b. 空き時間/予定ありの時間 — events.list ではなく FreeBusy を使用する

「この人はいつ空いているか/予定が入っているか」を調べる機能、予約機能、または Calendly 形式の機能では、空き状況を必ず calendar_events_list ではなく FreeBusy(calendar_freebusy_query)から取得してください。FreeBusy はこの用途に特化しています。単一の POST でユーザーのカレンダー全体の統合済み予定あり時間帯を返し、繰り返し予定はサーバー側ですでに展開されます。そのため、イベントのページネーション、繰り返し予定の展開、重複する時間帯の結合は不要です。また、不在時や終日の時間帯も含まれます。calendar_events_list はアプリ独自のイベント一覧の表示に、_insert/_delete はイベントの CRUD に使用してください。

上記の lib/calendar.mo ブロックにある busyTimes ヘルパーが、参照用の実装です。[timeMin, timeMax] の範囲で items = [{ id = "primary" }] を指定した FreeBusyRequest を作成し、401 の場合に一度だけ更新を試みます。そして、重要な点として、レスポンスに含まれるすべてのカレンダーを反復処理し(マップのキーは "primary" ではなく解決済みのカレンダー ID です)、それらの予定あり期間を結合します。

候補の時間枠と各 (start, end) を比較する前に、末尾のオフセットを考慮して絶対時刻にパースしてください。Google は時刻指定の期間を Z または数値オフセット(2026-07-21T14:00:00+02:00)付きで返し、終日の時間帯は YYYY-MM-DD の日付のみで返します。秒で切り詰めてオフセットを無視すると、予定あり時間帯がオフセット分ずれて(たとえばチューリッヒの夏時間では 2 時間)、非表示にすべき時間枠に予定あり時間帯が重ならなくなります。オフセットを考慮し、終日の日付も処理する LibCalendar.rfc3339ToNanos(テスト済みの mo:google-oauth/DateTime の再エクスポート)を使用し、その後、数値で重なりを判定してください。秒で処理を止めるパーサーを自作してはいけません。

最初から最後まで、空き状況のフローはナノ秒単位の時刻で処理し、テキストに触れるのは各処理の境界だけにします。LibCalendar.rfc3339ToNanos で時間範囲の基準を定め、単純な整数演算で候補の時間枠を生成し、LibCalendar.isSlotFree で絞り込んだ後、残った時間枠を LibCalendar.nanosToRfc3339 で表示用および createEvent にそのまま渡せる形式に変換します(createEvent の startDateTime/endDateTime は RFC 3339 形式のテキストです)。以下の時間枠は UTC の固定範囲です。実際の勤務時間やタイムゾーンのポリシーはアプリによって異なりますが、パース → 整数演算 → 形式変換という流れは同じです。

func availableSlots(
  clientId : Text, clientSecret : Text, connection : LibCalendar.CalendarConnection,
  caller : Principal, calendarConnections : Map.Map<Principal, LibCalendar.CalendarConnection>,
  windowStart : Text,   // e.g. "2026-07-21T09:00:00Z"
  slotCount : Nat,      // number of consecutive slots to consider
  slotMinutes : Nat,    // slot length, e.g. 30
) : async* [(Text, Text)] {
  let slotNs = slotMinutes * 60 * 1_000_000_000;
  let start0 = LibCalendar.rfc3339ToNanos(windowStart);
  // Candidate grid of [s, s+slot) instants.
  let candidates = Array.tabulate<(Int, Int)>(slotCount, func(i) {
    let s = start0 + i * slotNs;
    (s, s + slotNs);
  });
  let windowEnd = start0 + slotCount * slotNs;
  let busy = await* LibCalendar.busyTimes(
    clientId, clientSecret, connection, caller, calendarConnections,
    windowStart, LibCalendar.nanosToRfc3339(windowEnd),
  );
  let free = Array.filter<(Int, Int)>(candidates, func(s) = LibCalendar.isSlotFree(s.0, s.1, busy));
  Array.map<(Int, Int), (Text, Text)>(
    free, func(s) = (LibCalendar.nanosToRfc3339(s.0), LibCalendar.nanosToRfc3339(s.1)),
  );
};

5. 利用可能な API サーフェス

0.2.0 はオプションの enum パラメーターに関する破壊的変更です

オプションの enum クエリパラメーターは、単独の T ではなく ?T になりました。これにより null は「ワイヤーに含めない」を意味します。以前は常に送信されていましたが、これはジェネレーターのオプションパラメーター正規化が修正する目的の動作です。このようなパラメーターを渡すすべての呼び出し箇所に ? が必要です。

// 0.1.x
await* calendar_events_list(cfg, "primary", #json, …, #starttime, …);
// 0.2.0
await* calendar_events_list(cfg, "primary", ?#json, …, ?#starttime, …);

影響を受けるパラメーターには、すべての操作の alt、events.list の orderBy、events.insert/events.update/events.delete の sendUpdates が含まれます。enum ではないパラメーターに変更はありません。空の Text と 0 は引き続き「省略」を意味します。以下のサンプルでは新しい形式を使用しています。

0.2.0 以降の 2 つの呼び出しスタイル

0.2.0 では、Calendar のドット区切りの operationId ごとに操作をまとめたネストされたクラスのファサード src/Client.mo が追加されました。googledrive にはすでに同様のものがありました。

import { Client } "mo:googlecalendar-client/Client";

let cal = Client({ defaultConfig with auth = ?#bearer accessToken });

// events.insert takes 14 parameters; `alt` and `sendUpdates` are the optional enums.
let created = await cal.events.insert(
  calendarId, ?#json, "", "", "", false, "", "",
  0, 1, false, null, false, event);

// NB `query_`, not `query` — `query` is a Motoko keyword, so the generator escapes it.
let busy = await cal.freebusy.query_(?#json, "", "", "", false, "", "", request);

リソースは acl、calendarList、calendars、channels、colors、events、freebusy、settings です。

どちらのスタイルを選ぶか決める際に、知っておくべき点が 2 つあります。

  • ファサードは構築時に Config を一度だけバインドするため、複数の Calendar 呼び出しを行う shared メソッドで、呼び出しのたびに Config を渡す必要はありません。
  • ファサードメソッドは async ですが、フラットな Apis/*.mo のフリー関数は async* です。async* ヘルパー内では、ファサードによって await の境界がもう 1 つ加わるため、§4 のミックスイン構成ではフラットな形式を使用します。

ファサードは追加機能であり、フラットなエントリーポイントは削除されていません。ただし、0.1.x のコードが変更なしでコンパイルできるという意味ではありません。上記のオプション enum のシグネチャ変更は、フラットな関数にも適用されます。どちらのスタイルを使用する場合でも、アップグレード時には該当する呼び出し箇所に ? を追加してください。

google-oauth(OAuth 2.0 の仕組み)
関数目的
OAuth.urlEncode(text)フォーム本文用の RFC 3986 パーセントエンコーディング
OAuth.parseTokenResponse(text)Google のトークンエンドポイントの JSON を解析
OAuth.exchangeAuthorizationCode(...)認証コードをトークンと交換
OAuth.refreshAccessToken(...)期限切れのアクセストークンを更新
OAuth.generateCodeVerifier()PKCE の code_verifier を生成(オンチェーンの乱数)
OAuth.computeCodeChallenge(verifier)PKCE の code_challenge を計算(S256)
OAuth.buildAuthorizeUrl(...)Google OAuth の認可 URL を構築
OAuth.getUserEmail(accessToken)OIDC の userinfo を介して連携済みメールアドレスを取得(openid email のみ必要)
空き状況の計算(LibCalendar が mo:google-oauth/DateTime から再エクスポート)

lib/calendar.mo はテスト済みのこれらのヘルパーを再エクスポートするため、追加のインポートなしで LibCalendar.* として呼び出せます。時刻は Unix エポックからの絶対ナノ秒で、 Time.now() と一致します。

関数目的
LibCalendar.rfc3339ToNanos(text)オフセット対応の RFC 3339 をナノ秒に変換(Z/±HH:MM、日付のみの形式にも対応)
LibCalendar.nanosToRfc3339(ns)ナノ秒を UTC の RFC 3339 テキスト(…Z)に変換し、createEvent ですぐに使用可能
LibCalendar.overlaps(aStart, aEnd, bStart, bEnd)半開区間の重複を判定
LibCalendar.isSlotFree(slotStart, slotEnd, busy)スロットがすべての RFC 3339 形式の予定ありペア (start, end) と重複しないか判定
googlecalendar-client(Calendar REST API v3)

上記の標準 actor は、意図的に今後のイベントの一覧表示とイベント作成のみを実装しています。空き状況/予定あり時刻には、§4b の FreeBusy ヘルパーを使用してください。 ほかの生成済み操作では、ベアラー認証と is_replicated = ?false を維持し、refreshIfNeeded と同じ単一の更新再試行パターンを適用してください。

生成済みパッケージでは、次の操作も利用できます。

関数モジュール目的
calendar_events_listEventsApiカレンダーのイベントを一覧表示
calendar_events_getEventsApiID でイベントを取得
calendar_events_insertEventsApiイベントを作成
calendar_events_updateEventsApiイベントを更新(PUT)
calendar_events_patchEventsApiイベントにパッチを適用(PATCH)
calendar_events_deleteEventsApiイベントを削除
calendar_events_moveEventsApiイベントを別のカレンダーに移動
calendar_events_quickAddEventsApiテキストからイベントを作成("Lunch at noon")
calendar_events_instancesEventsApi繰り返しイベントの各回を一覧表示
calendar_freebusy_queryFreebusyApi複数のカレンダーの空き状況/予定ありを確認
calendar_calendarList_listCalendarListApiユーザーのカレンダーを一覧表示
calendar_calendarList_getCalendarListApiカレンダーリストの項目を取得
calendar_calendars_getCalendarsApiカレンダーのメタデータを取得
calendar_calendars_insertCalendarsApi追加のカレンダーを作成

6. サイクル数とレスポンスサイズ

google-oauth ライブラリは mo:ic/Call の Call.httpRequest を使用します。これは ic0.cost_http_request システム API を介して必要なサイクル数を自動計算し、付与します。トークン交換や更新呼び出しで、サイクル数を手動で見積もる必要はありません。

googlecalendar-client の呼び出しでは、cycles を未設定(null)のままにしてください。各呼び出しでは、そのリクエストに IC が課金する料金が付与されます。アウトコールが無料のサブネットでは、料金はゼロです。?n を指定すると、正確に n サイクルが付与されます。n が料金を下回る場合、IC は呼び出しを拒否し、キャニスターが n サイクル未満しか保持していないサブネットでは呼び出しがトラップします。大きなペイロードを含む可能性があるイベント一覧の読み取りでは、max_response_bytes = ?2_000_000 を設定してください。

7. 注意すべき点

  • is_replicated = ?false — §3 を参照してください。必須です。
  • Google のリフレッシュトークンはローテーションされません。 X/Twitter とは異なり、Google はリフレッシュのたびに新しい refresh_token を発行しません。元の refresh_token を保持し、新しい access_token のみを保存してください。
  • アクセストークンの有効期限は 1 時間です。 refreshIfNeeded ヘルパーは HTTP 401 を検出すると、google-oauth.refreshAccessToken を使って通知なくリフレッシュし、1 回だけ再試行します。それ以外の失敗はすべて reject として伝播するため、その失敗からの復帰が必要な呼び出し元(たとえば、あるユーザーのカレンダーをスキップする場合)は、その箇所で捕捉してください。リフレッシュに失敗すると accessTokenOf 内で trap が発生するため、呼び出し元で捕捉することはできません。この場合は「アカウントを再接続してください」と表示してください。
  • コールバック URI は完全一致が必要です。 末尾のスラッシュ、クエリ文字列、ポートなど、すべての文字が認可 URL とリダイレクト先で一致している必要があります。一致しない場合、Google は redirect_uri_mismatch を返します。redirectUri には固定値の window.location.origin + "/connect/calendar" を使用してください。これは設定ページに表示される値であり、/connect/calendar ルートが所有する値でもあります。また、Google Web クライアントにはこの URI をそのまま登録してください。ページによって変わる window.location.pathname から URI を組み立てないでください。
  • 設定ページに表示された値を変更せずに startCalendarOAuth に渡してください。生の *.icp0.io キャニスター URL は決して渡さないでください。 Caffeine アプリは複数のオリジン(*-draft.caffeine.xyz のドラフト、*.caffeine.xyz のライブドメイン、生の <canister-id>.icp0.io URL)で配信されます。リダイレクト URI は共有ヘルパー 1 つ(window.location.origin + "/connect/calendar")で計算し、設定ページのコピー可能なフィールドと startCalendarOAuth に渡す値の両方で同じヘルパーを使ってください。Google に送信される値(startCalendarOAuth 経由)が設定ページに表示された値や管理者が登録した値と異なると、たとえばビルド時の設定値や *.icp0.io のキャニスターオリジンを使うと、Google は redirect_uri_mismatch を返します。
  • タイムスタンプは RFC 3339 形式です。 Calendar では RFC 3339 文字列(2026-07-10T15:00:00-07:00)を使用します。終日イベントでは dateTime ではなく EventDateTime.date(YYYY-MM-DD)を設定してください。
  • createEvent の時刻にはタイムゾーンが必要です。 createEvent に渡す dateTime には UTC オフセット(…Z または …+02:00)を含める必要があります。そうでなければ、EventDateTime.timeZone("Europe/Zurich" などの IANA 名)も設定してください。2026-07-10T15:00:00 のようにどちらも指定しない値は、Google に拒否されます。イベントを意図した現地時刻に設定するには、オフセット付き文字列を送信することを推奨します。
  • calendarId = "primary" は、認証済みユーザーのデフォルトカレンダーを指します。名前付きカレンダーや共有カレンダーには、カレンダー ID(メールアドレスのような形式)を使用します。
  • 空き状況の確認には events.list ではなく FreeBusy を使ってください。 「空いているか/予定が入っているか」を確認するには、calendar_freebusy_query(§4b)を使用してください。1 回の POST で、繰り返し予定をサーバー側で展開した統合済みの予定あり時間帯を取得できます。events.list から空き状況を再構築するには、ページング、繰り返し予定の展開、重複区間の手動統合が必要です。これは間違いやすく、「予約リンクでは空いているのに、実際は予定が入っている」という問題の典型的な原因です。
  • maxAttendees と maxResults は 1 以上にする必要があります。 Google は maxAttendees=0/maxResults=0 を HTTP 400 で拒否します(ドキュメントに記載された最小値は 1 です)。listUpcomingEvents と createEvent のレシピでは maxAttendees = 10 を渡しています。イベント関連のエンドポイントでこれらに 0 を渡さないでください。
  • FreeBusy のレスポンスのキーは、クエリした文字列ではなく、解決後のカレンダー ID です。 "primary" に対して calendar_freebusy_query を呼び出すと、Google はその値を解決し、calendars マップのキーとして文字列 "primary" ではなく、実際のカレンダー ID(ユーザーのメールアドレス)を返します。レスポンス内で "primary" を検索しないでください。何も見つからず、すべての時間帯が空いていると判定されます(空き状況のバグとしてよくある原因です)。代わりに、レスポンスに含まれるすべてのカレンダーを反復処理し、それぞれの busy 時間帯を統合してから、候補の時間帯から差し引いてください。各区間の start/end は、末尾の Z または数値オフセット(+02:00)を許容する RFC 3339 として解析し、生の文字列ではなく時刻の実値を比較してください。
  • RFC 3339 の解析には LibCalendar.rfc3339ToNanos(mo:google-oauth/DateTime から再エクスポート)を使い、独自に実装しないでください。 数字ごとに '0'(48)を引く処理を忘れた手書きのパーサーは、"2026" を 55354 として読み取るため、すべての予定あり時間帯が誤った年に割り当てられ、重複チェックが一致せず、空き状況の判定が気付かれないまま誤ります。コードはコンパイルでき、trap も発生しないため、ユーザーが二重予約されるまでバグが見えません。テスト済みのヘルパーを使用してください。
  • HTTP 429 のレート制限。 エラーを呼び出し元に伝えてください。再試行によってイベントが重複作成される可能性があるため、キャニスター内で書き込みを通知なく再試行しないでください。
  • アクセストークンを公開しないでください。 calendarConnections は API 呼び出し内の Map.get(calendarConnections, ..., caller) からのみ読み取ります。getMyCalendarConnection、getMyAccessToken、イテレーターは追加しないでください。ベアラートークンが漏洩すると、そのユーザーのアカウントが侵害されます。
  • Calendar API v3 のすべての呼び出しで alt = ?#json を使用してください(0.2.0 より前は #json のみ)。省略可能な文字列パラメーターには ""、prettyPrint には false を指定してください。
  • クエリパラメーターは位置引数であり、0.2.0 以降、省略可能な enum 型の引数は ?T です。 Text には ""、Bool には false、Nat には実際の数値を渡してください(ドキュメントに記載された各最小値を守ってください。maxAttendees/maxResults は 1 以上である必要があります)。省略可能な enum には ?#json/?#starttime/?#all を渡してください。省略する場合は null を渡します。これが ? を使う理由です。enum 以外のパラメーターには null を指定できません。モデル値(Event、EventDateTime、FreeBusyRequest)は、これまでどおり直接渡してください。
  • Gmail と Calendar を組み合わせたアプリでは、スコープの和集合をリクエストし、アドレスは OAuth.getUserEmail で取得してください。 openid email と .../calendar と .../gmail.send のスコープを組み合わせることで、空き状況の確認、メールの送信、接続されたアドレスの取得(OIDC userinfo 経由)ができます。アプリで実際にメールを読み取る場合を除き、gmail.readonly は必要ありません。レシピを組み合わせる際にスコープを削除しないでください。「Gmail と Calendar を組み合わせたアプリ」を参照してください。
  • Event/EventDateTime は init {} で作成してから、必要なフィールドをレコード更新してください。 すべてのフィールドは省略可能(?T)なので、残りは null のままにしてください。
  • 生成されたクライアントでは、PATCH/PUT/DELETE は非レプリケートに固定されています(googlecalendar-client がこれらのメソッドに is_replicated = ?false を自動設定します)。GET/POST では、Config 内で明示的に設定してください。

フロントエンド

このスキルを使ったビルドでは、以下の4項目をすべて実装する必要があります。(アプリが Gmail コネクターも使用する場合は、以下の「Gmail + Calendar を組み合わせたアプリ」に従ってください。そこでは /settings/calendar と /connect/calendar が、共通の /settings/google と /connect/google に置き換わります。以下の要件は引き続き適用されます。変更されるのは2つのパスだけです。)これらは提案ではなく、受け入れ基準です。ビルド完了前に各項目を確認してください。次の3つはビルドで見落とされがちな要件であり、1つでも欠けると、コネクターは単に未完成なのではなく、動作しません。

  • 認証情報ページが存在し、アクセス可能であること。 アプリには Client ID/Secret の入力欄(項目2)を備えた /settings/calendar ページが必須です。また、ログイン済みの管理者が、ナビゲーションリンクまたは接続ページの未設定時の案内から、このページにアクセスできなければなりません。認証情報を入力するページがないのに「Connect Google Calendar」ボタンだけがあるのは、最もよくある失敗です。この場合、コネクターは使用できません。
  • 管理者設定ページに、コピー可能なリダイレクト URI がそのまま表示されること。 <your-domain> のプレースホルダーや、管理者が組み立てる「アプリ URL + /connect/calendar」というテキストではなく、読み取り専用フィールドに window.location.origin + "/connect/calendar" の実際の文字列が表示され、管理者がコピーできる必要があります。具体的には、https://my-app.caffeine.xyz で配信されるアプリでは、フィールドに https://my-app.caffeine.xyz/connect/calendar だけが正確に表示されなければなりません。これがないと、管理者は Google に URI を登録できず、すべての接続が失敗します。
  • /connect/calendar が、Google のコールバックを処理する実際のルートであること。 ボタンだけのページではいけません。キャッチオールやホームへのリダイレクトに流れてしまう場合、または認証済みのアクターの準備が整う前に completeCalendarOAuth を呼び出す場合、接続は何も通知されずに失敗し、アプリには「未接続」と表示されます。
  1. ログインフロー — 必須です。 匿名でない呼び出し元がいなければ Calendar は動作しません。ユーザーごとの OAuth ハンドシェイクでは、トークンが caller : Principal をキーにして保存され、管理者の認証情報設定は #admin によって制御されます。ログインフローには extension-authorization を使用します。useInternetIdentity、ログイン/ログアウトボタン、すべてのバックエンド呼び出しに認証済みの ID を注入する useActor の配線が含まれます。

  2. 管理者設定ページ — /settings/calendar(管理者のみアクセス可能)。このページは必須です。このページがない場合、Calendar のビルドは未完成です:

    • 認証情報の入力欄の前に、「Google の認証情報を取得する方法」パネルを表示します。セットアップは一度限りで約5分で完了することを管理者に伝え、以下の番号付き手順を案内します(エージェントの完了メッセージにも同じ手順を記載する必要があります):
      1. Google Cloud Consoleを開き、任意の Google アカウントでサインインします。
      2. プロジェクトを作成するか、既存のプロジェクトを選択します。
      3. Google Calendar API を有効にします(APIs & Services → Library →「Google Calendar API」を検索 → Enable)。
      4. OAuth consent screen を設定します(APIs & Services → OAuth consent screen → External。アプリ名、サポートメール、開発者メールを設定します。Google のデフォルトのスコープで問題ありません)。
      5. タイプが Web application の OAuth client ID を作成します(APIs & Services → Credentials → Create Credentials → OAuth client ID)。
      6. Authorized redirect URIs に、このページのコピー可能なフィールドに表示される正確な値を追加します。
      7. 作成された Client ID と Client Secret を以下の入力欄にコピーし、保存します。 Google Cloud Console を開く便利なリンクも追加します。
    • 共通ヘルパーを使って、実際の URI を読み取り専用のコピー可能なフィールドに表示します: const calendarRedirectUri = () => window.location.origin + "/connect/calendar"; たとえば、アプリを https://my-app.caffeine.xyz で開いている場合、表示される値は https://my-app.caffeine.xyz/connect/calendar です。<app-domain> だけを表示したり、URI を管理者に推測させたりしてはいけません。
    • setCalendarCredentials(clientId, clientSecret) にバインドされたパスワード入力欄を2つ設けます。Enter キーで送信し、成功したら入力欄をクリアします。
    • isCalendarConfigured()(Bool を返します)に基づくステータスインジケーターを表示します。「Configured」/「Not configured」と表示し、認証情報は決して表示しません。
    • このページに移動できるようにします。 アプリのメインナビゲーション(共通の Layout)から、管理者がこのページに移動できるようにする必要があります。isCallerAdmin が true の場合にリンクを表示し、それ以外の場合は表示しません(extension-authorization を使用)。ナビゲーションを定義している場所にリンクを追加し、このページ内には追加しないでください。アクセス手段のない /settings/calendar ルートはビルド不備です。セットアップが必要であることをユーザーが知る主な手段は、以下の未設定時のプロンプトであるため、ナビゲーションだけに頼らないでください。
  3. 「カレンダーを接続」ページとコールバックページ — /connect/calendar(サインイン済みのすべてのユーザーが利用可能)。この専用ページでは、同意後に Google からリダイレクトされたときの処理が必要です。接続ボタンだけを置いたページではありません。

    • 未設定の場合は、すべてのユーザーに対応します。 isCalendarConfigured() は公開クエリです(サインイン済みのユーザーなら誰でも呼び出せます)。false が返されたときに、機能しない接続ボタンを表示しないでください。管理者には、認証情報を入力するための /settings/calendar へのリンクを表示します。管理者以外のユーザーには、行き詰まらないよう説明を表示してください。たとえば、「Google カレンダーはまだ設定されていません。アプリの管理者が Settings で Google の認証情報を追加する必要があります。」と表示します。「Google カレンダーを接続」ボタンは、設定が完了してから有効にしてください。
    • startCalendarOAuth(calendarRedirectUri()) に紐付けた「Google カレンダーを接続」ボタンを表示します。カニスターから返された URL にブラウザーをリダイレクトしてください。任意の現在のパス名からコールバック先を導出しないでください。固定の /connect/calendar ルートと設定ページの URI は、同一である必要があります。
    • /connect/calendar を実際のアプリケーションルートとして登録してください。Google からのコールバックを処理し、処理前にキャッチオールのリダイレクト、レイアウトのデフォルト、ホームページへ遷移しないようにしてください。
    • 戻りの処理では、URLSearchParams から error、code、state を読み取ります。error がある場合は、接続失敗または拒否の状態を表示し、カニスターを呼び出さないでください。code と state の両方がある場合に限り、他のページへ移動したり URL をクリアしたりする前に、completeCalendarOAuth(code, state) を呼び出して完了を待ちます。処理中は「Google カレンダーに接続中です…」の状態を表示してください。最初にルートを置き換えたり、ホームページへリダイレクトしたり、クエリパラメーターを破棄したりしないでください。そうすると一度限りのコードが失われ、ユーザーは未接続のままになります。
    • 一度限りのコールバック呼び出しの前に、アクターの準備が整うのを待ちます。 ページでは、useInternetIdentity().isAuthenticated が認証済みとなり、useActor(createActor) が null ではなく取得中でもないアクターを返すまで待ってから、completeCalendarOAuth を呼び出す必要があります。それまでは startedRef などの一度限りの実行を制御するガードを設定しないでください。初回レンダリング時はアクターが利用できないことが多く、その状態で「Actor not ready」エラーが発生すると、認証コードが URL に残っていて再試行できるにもかかわらず、唯一の再試行が消費されてしまいます。
    • どちらかの終了経路の後に、history.replaceState を呼び出して OAuth のクエリパラメーターを削除してください。これにより、ページを更新して一度限りの認証コードが再利用されるのを防ぎます。
    • isMyCalendarConnected()(Bool を返します)に基づいてステータスを表示します。
    • オプションで、disconnectMyCalendar() に紐付けた「カレンダーの接続を解除」ボタンを表示します。
  4. カレンダー UI — メインページに今後のイベントを表示します。isCalendarConfigured() が false で、呼び出し元が管理者の場合は、認証情報ページを見つけやすくし、単にアクセス可能なだけにならないよう、/settings/calendar への「Google カレンダーを設定」リンクを表示します。現在の時刻を RFC 3339 の timeMin 値として渡し、終了時刻を指定しない timeMax には "" を渡してください。 listUpcomingEvents(new Date().toISOString(), "", 10)。1 日分に範囲を限定する場合(「明日の会議」など)は、両方の値を渡してください。ローカル時刻でのその日の開始時刻と翌日の開始時刻を、それぞれオフセット付きの RFC 3339 形式で渡し、isAllDay が false、transparency が "transparent" ではなく、eventType が "default" のエントリーだけを数えます(これにより、終日、空き時間、外出中、勤務場所のマーカーを除外できます)。これは singleEvents = true と orderBy = startTime を使用する場合に必須です。また、「イベントを作成」フォームも含めてください。datetime-local の値にはオフセットがないため、アクターを呼び出す前に、ブラウザーのローカル時刻をそれぞれ RFC 3339 形式の時刻に変換してください。 createEvent(summary, new Date(startInput).toISOString(), new Date(endInput).toISOString())。 isMyCalendarConnected() が false の場合は、/connect/calendar への「Google カレンダーを接続」リンクをインラインで表示します。

推奨されるルート構成:

/                   →  Main UI (upcoming events + create form)
/settings/calendar  →  Admin credential config (admin-only)
/connect/calendar   →  Per-user OAuth handshake (any signed-in user)
# If the app ALSO uses Gmail: drop the two routes above and use a single
# /settings/google + /connect/google — see "Combined Gmail + Calendar apps".

Gmail + Calendar アプリの統合

アプリで両方のコネクターを使用する場合は、Google 接続を 2 つではなく1 つだけ共有して構築します(認証コードは一度しか使用できないため、フローが 2 つあると同意画面が 2 回表示されます)。 フロントエンド:

  • 管理ページ /settings/google を 1 つだけ用意 — Client ID / Client Secret のフォーム、isGoogleConfigured のステータス、window.location.origin + "/connect/google" をそのまま表示するコピー可能なリダイレクト URI フィールドを 1 つずつ用意します。
  • 接続ルート /connect/google を 1 つだけ用意 — 上記の Frontend セクションで必要とされる実際のコールバックルートと同じものです。「Connect Google」を表示し、リダイレクトを受け取り、アクターの準備が整うまで待ってから、完了処理を一度だけ呼び出します。2 つ目のコールバックルートは作成しません。
  • 次のルートは作成しないでください:/settings/gmail、/connect/gmail、/settings/calendar、/connect/calendar。上記の Frontend の要件はすべて適用されます。変更されるのはこれらのパスだけです。

バックエンド — 共有フローを1 回だけ記述します(コネクターごとの OAuth フロー 2 つを置き換えます)。コネクターごとの startAuthorize / exchangeCode / refresh 関数と同じ構成ですが、次の点が異なります。

  • 1 つの #admin ゲート付き設定セッターで、単一の Client ID / Secret を保存します。
  • SCOPES = 以下のスコープの和集合 — 1 回の同意で両方の API を対象にします。
  • completeGoogleOAuth(code, state) は OAuth.getUserEmail(OIDC userinfo — 必要なのは openid email のみで、gmail.readonly は不要)で接続されたメールアドレスを取得し、単一の Map<Principal, GoogleConnection> に 1 つの接続 { accessToken; refreshToken; emailAddress } を保存します。
  • Gmail の送信と Calendar の呼び出しでは、それぞれ独自のクライアント Config をこの 1 つの accessToken から構築し、各々の 401 時の単一リトライを維持します。
  • 接続とクライアント設定は 1 つの共有状態値にまとめ、Gmail と Calendar の両方のミックスインにパラメーターとして渡します。これにより、両方が同じ接続を読み書きします(writing-motoko のミックスイン規則を参照)。
let SCOPES : Text =
  "openid email "                                    // learn the address via userinfo
  # "https://www.googleapis.com/auth/gmail.send "
  # "https://www.googleapis.com/auth/calendar";
// Add "https://www.googleapis.com/auth/gmail.readonly " ONLY if the app reads mail.

両方のサービスで1 つの接続を共有するように接続します。設定、接続マップ、保留中フローのマップはそれぞれ 1 回だけ宣言し、すべてのミックスインに同じバインディングを渡します。Gmail と Calendar のメッセージング用ミックスインでは、独自の設定や接続を宣言しません。共有の googleConfig と googleConnections を受け取ります(401 時のリトライで更新が必要なため、設定も必要です)。

actor {
  let accessControlState : AccessControl.AccessControlState;
  include MixinAuthorization(accessControlState, null);

  // ONE shared credential + connection state for both services.
  let googleConfig : { var clientId : Text; var clientSecret : Text };
  let googleConnections : Map.Map<Principal, Google.Connection>;
  let pendingGoogleFlows : Map.Map<Principal, Google.PendingOAuth>;

  include MixinGoogleConfig(accessControlState, googleConfig);                    // setGoogleCredentials / isGoogleConfigured (#admin-gated setter)
  include MixinGoogleOAuth(googleConfig, googleConnections, pendingGoogleFlows);  // startGoogleOAuth / completeGoogleOAuth, SCOPES = union above
  include MixinGmailMessaging(googleConfig, googleConnections);                  // sendEmail — refresh-on-401 needs config; reads the shared connection
  include MixinCalendarMessaging(googleConfig, googleConnections);               // calendar calls — same shared config + connection
};

このバリアントのマイグレーションチェーンのヘッドは、コネクターごとのものに置き換わります。共有接続に emailAddress が含まれる点に注意してください。

import Map "mo:core/Map";
import AccessControl "mo:caffeineai-authorization/access-control";

module {
  type Connection = {
    accessToken : Text;
    refreshToken : Text;
    emailAddress : Text;
  };

  type PendingOAuth = {
    codeVerifier : Text;
    redirectUri : Text;
    state : Text;
  };

  type NewActor = {
    accessControlState : AccessControl.AccessControlState;
    googleConfig : { var clientId : Text; var clientSecret : Text };
    googleConnections : Map.Map<Principal, Connection>;
    pendingGoogleFlows : Map.Map<Principal, PendingOAuth>;
  };

  public func migration(_old : {}) : NewActor {
    {
      accessControlState = AccessControl.initState();
      googleConfig = { var clientId = ""; var clientSecret = "" };
      googleConnections = Map.empty<Principal, Connection>();
      pendingGoogleFlows = Map.empty<Principal, PendingOAuth>();
    };
  };
};

Gmail と Calendar に別々の設定/接続状態や別々の OAuth フローを用意しないでください。認証コードは一度しか使用できず、状態を分けると同期がずれます(writing-motoko のミックスイン規則を参照)。

1 つの OAuth クライアントで両方の API を有効にし、単一の .../connect/google リダイレクト URI のみを登録します。ユーザーが異なる 2 つの Google アカウントへの接続を明示的に求めた場合に限り、2 つの別々のパネルに分けます。

すべてのバリアントに共通

  • Calendar 関連のすべてのルートでサインインが必要です。/settings/... と接続ルート(/connect/calendar、または統合アプリでは /connect/google)を、extension-authorization の認証ガード(useInternetIdentity + !isAuthenticated の場合のリダイレクト)に接続します。
  • フロントエンドでトークンを永続化することはありません。 localStorage、IndexedDB、Cookie は使用しません。すべてをキャニスターが仲介します。ブラウザーから見えるのは Bool ステータスフラグと OAuth リダイレクト URL だけです。
  • OAuth の state パラメーターはキャニスターが生成して検証します。 キャニスターはランダムなノンスを、保留中の verifier およびコールバック URI とともに保存します。フロントエンドは完了処理の呼び出しに code と state の両方を渡す必要があります(completeCalendarOAuth、または統合アプリでは completeGoogleOAuth)。どちらの値もフロントエンド側で作成または変更することはありません。
  • カレンダー UI はシンプルです。 今後のイベントの一覧と、概要および開始/終了日時の入力欄を備えたイベント作成フォームだけです。クライアント側の Google SDK、トークン処理、JSON シリアライズは使用しません。キャニスターが Calendar クライアントです。

関連項目

コメント