connector-googlecalendar について
connector-googlecalendarは、ログイン中のユーザーに代わって日程表を扱うCaffeine AIのcanister向けGoogle Calendar連携スキルです。MotokoのAPIバインディングとユーザーごとのOAuth PKCEフローを組み合わせ、トークン管理と安全なリクエスト方法を示します。
主な機能
- 01今後の予定の一覧表示
- 02カレンダー予定の作成
- 03FreeBusyによる空き時間の照会
- 04ユーザーごとのOAuth PKCE認証
- 05アクセストークンの更新と再試行
活用シーン
- 01アプリでユーザーの予定を表示
- 02ユーザーに代わって予定を作成
- 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 と連携する場合は、 このスキルを使用してください。必要な要素は次のとおりです。
googlecalendar-clientmops パッケージ — Google Calendar API v3 用に生成された Motoko バインディングです。この手順では今後の予定の一覧表示と予定の作成を例示します。 その他の生成済み操作を追加する場合も、同じベアラー認証、非レプリケート、 単一のリフレッシュ再試行のパターンに従ってください。google-oauthmops パッケージ — Google OAuth 2.0 のトークン交換、 リフレッシュ、PKCE、パーセントエンコーディングを提供します。このライブラリにより、oauth2.googleapis.comへの手書きのhttp_requestが不要になります。- OAuth 2.0 Authorization Code with PKCE フロー。各エンドユーザーが、
自分に代わってカナスターが操作できるよう認可します。各ユーザーは
caller : Principalに対応付けた自身のaccess_tokenとrefresh_tokenを保持します。 - 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 フローを通じて、 個別にカナスターを認可します。カナスターは次の処理を行います。
- PKCE の
code_verifierとcode_challengeを生成します(google-oauth経由)。 - Google の認可 URL を作成します(
google-oauth.buildAuthorizeUrl経由)。 - フロントエンドからユーザーを Google にリダイレクトします。同意後、Google は
codeパラメーターを付けてリダイレクトします。 - カナスターはコードをトークンに交換します(
google-oauth.exchangeAuthorizationCode経由)。これはオンチェーンで、 非レプリケートで実行されます。 - カナスターは
callerに対応付けてaccess_tokenとrefresh_tokenを保存します。 - 1 時間有効なアクセストークンの期限が切れると(HTTP 401)、カナスターは
トークンをサイレントにリフレッシュし(
google-oauth.refreshAccessToken経由)、再試行します。
Google Cloud Console の設定
- Google OAuth 2.0 のウェブアプリケーションクライアントを作成します。
- アプリの Calendar 設定ページに、このコールバック URI の文字列をコピー可能なフィールドに表示する必要があります。
window.location.origin + "/connect/calendar"— 例:https://my-app.caffeine.xyz/connect/calendar。アプリ管理者は、表示された値を手動でコピーし、Google Cloud Console の承認済みのリダイレクト URIに登録する必要があります。ユーザーが Calendar に接続できるデプロイ済みのオリジン(たとえば、ドラフト版と公開版のアプリのオリジン)は、それぞれ個別の承認済みリダイレクト URI として登録します。 - アプリに必要な Calendar スコープのみを同意画面で有効にします。
- アプリの管理設定ページからクライアント 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 は必須です
- セキュリティ。 レプリケートされた HTTP アウトコールは、サブネット内のすべてのノードからリクエストを送信します。各ノードは
Authorization: Bearer <token>ヘッダーを持つため、いずれかのノードからベアラートークンが漏えいすると、ユーザーの Google アカウントが侵害されます。 - 課金。 レプリケートされたアウトコールは、N 個の並列 API 呼び出しを発生させます。IC はサイクルを約 13 倍課金し、Google は各呼び出しをクォータにカウントします。
- 決定性。 カレンダーへの書き込みレスポンスは非決定的です(イベントごとに固有の
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_list | EventsApi | カレンダーのイベントを一覧表示 |
calendar_events_get | EventsApi | ID でイベントを取得 |
calendar_events_insert | EventsApi | イベントを作成 |
calendar_events_update | EventsApi | イベントを更新(PUT) |
calendar_events_patch | EventsApi | イベントにパッチを適用(PATCH) |
calendar_events_delete | EventsApi | イベントを削除 |
calendar_events_move | EventsApi | イベントを別のカレンダーに移動 |
calendar_events_quickAdd | EventsApi | テキストからイベントを作成("Lunch at noon") |
calendar_events_instances | EventsApi | 繰り返しイベントの各回を一覧表示 |
calendar_freebusy_query | FreebusyApi | 複数のカレンダーの空き状況/予定ありを確認 |
calendar_calendarList_list | CalendarListApi | ユーザーのカレンダーを一覧表示 |
calendar_calendarList_get | CalendarListApi | カレンダーリストの項目を取得 |
calendar_calendars_get | CalendarsApi | カレンダーのメタデータを取得 |
calendar_calendars_insert | CalendarsApi | 追加のカレンダーを作成 |
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.ioURL)で配信されます。リダイレクト 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を呼び出す場合、接続は何も通知されずに失敗し、アプリには「未接続」と表示されます。
-
ログインフロー — 必須です。 匿名でない呼び出し元がいなければ Calendar は動作しません。ユーザーごとの OAuth ハンドシェイクでは、トークンが
caller : Principalをキーにして保存され、管理者の認証情報設定は#adminによって制御されます。ログインフローにはextension-authorizationを使用します。useInternetIdentity、ログイン/ログアウトボタン、すべてのバックエンド呼び出しに認証済みの ID を注入するuseActorの配線が含まれます。 -
管理者設定ページ —
/settings/calendar(管理者のみアクセス可能)。このページは必須です。このページがない場合、Calendar のビルドは未完成です:- 認証情報の入力欄の前に、「Google の認証情報を取得する方法」パネルを表示します。セットアップは一度限りで約5分で完了することを管理者に伝え、以下の番号付き手順を案内します(エージェントの完了メッセージにも同じ手順を記載する必要があります):
- Google Cloud Consoleを開き、任意の Google アカウントでサインインします。
- プロジェクトを作成するか、既存のプロジェクトを選択します。
- Google Calendar API を有効にします(APIs & Services → Library →「Google Calendar API」を検索 → Enable)。
- OAuth consent screen を設定します(APIs & Services → OAuth consent screen → External。アプリ名、サポートメール、開発者メールを設定します。Google のデフォルトのスコープで問題ありません)。
- タイプが Web application の OAuth client ID を作成します(APIs & Services → Credentials → Create Credentials → OAuth client ID)。
- Authorized redirect URIs に、このページのコピー可能なフィールドに表示される正確な値を追加します。
- 作成された 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ルートはビルド不備です。セットアップが必要であることをユーザーが知る主な手段は、以下の未設定時のプロンプトであるため、ナビゲーションだけに頼らないでください。
- 認証情報の入力欄の前に、「Google の認証情報を取得する方法」パネルを表示します。セットアップは一度限りで約5分で完了することを管理者に伝え、以下の番号付き手順を案内します(エージェントの完了メッセージにも同じ手順を記載する必要があります):
-
「カレンダーを接続」ページとコールバックページ —
/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()に紐付けた「カレンダーの接続を解除」ボタンを表示します。
- 未設定の場合は、すべてのユーザーに対応します。
-
カレンダー 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 クライアントです。
関連項目
mops add [email protected]— Calendar REST API v3 のバインディングです。mops add [email protected]— Google OAuth 2.0 ライブラリです(トークン交換、更新、PKCE、getUserEmailによるユーザー情報、DateTimeRFC 3339 ヘルパー)。- Web サーバーアプリケーション向け Google OAuth 2.0 — Web クライアントのリダイレクト URI と認可コードフローのリファレンスです。
- Google Calendar API v3 リファレンス —
googlecalendar-clientがラップする API です。 - RFC 7636 — Proof Key for Code Exchange — PKCE の仕様です。
- extension-authorization — 必須の前提条件です。Internet Identity ログイン、
useInternetIdentity/useActorのフロントエンド配線、#adminロールによるアクセス制御を提供します。 - connector-googlemail — Gmail 向けに同じ
google-oauthライブラリを使用する関連コネクターです。