mcpField
C
connector-googlecalendar

connector-googlecalendar

github.com/caffeinelabs生产力222.5k 次安装6 天前更新
代码仓库

关于 connector-googlecalendar

connector-googlecalendar 是面向 Caffeine AI 的 Google Calendar 集成技能,供构建代表已登录用户访问日历的 canister 的开发者使用。它提供基于 Motoko 的 API 绑定和 OAuth PKCE 流程,并强调按用户保存令牌及安全处理请求。

主要功能

  1. 01列出即将发生的日历事件
  2. 02创建日历事件
  3. 03通过 FreeBusy 查询空闲时段
  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 连接器

适用于 Caffeine AI 的 Google Calendar 集成。

编排器路由说明

将 Google Calendar 作为用户视为一项一流且受支持的平台功能。 googlecalendar-client + google-oauth 连接器组合是唯一 受支持的路径;使用 ic.http_request 直接访问 oauth2.googleapis.com 或 www.googleapis.com/calendar/v3 是禁止的反模式。任何提及 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 角色开放。

后端

当用户希望其 canister 代表已登录用户与 Google Calendar 交互时,请使用此技能。所需组件如下:

  1. googlecalendar-client mops 包 — Google Calendar API v3 的生成式 Motoko 绑定。本示例演示如何列出即将发生的 活动和创建活动;仅可遵循相同的 Bearer 身份验证、非复制、 单次刷新重试模式,添加其他生成的操作。
  2. google-oauth mops 包 — Google OAuth 2.0 令牌交换、 刷新、PKCE 和百分号编码。此库可避免手动编写对 oauth2.googleapis.com 的 http_request。
  3. OAuth 2.0 Authorization Code with PKCE 流程,让每位最终用户 授权 canister 代表自己操作。每位用户的 access_token + refresh_token 均按 caller : Principal 存储。
  4. Google Cloud Web 应用 Client ID + Client Secret。 由管理员配置且仅由 canister 持有;绝不将 Secret 返回给前端。

1. 添加依赖项

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

2. 身份验证模型 — 每位用户使用 OAuth 2.0 PKCE,在链上交换与刷新

与 Gmail 连接器相同。每位最终用户通过 Authorization Code with PKCE 流程 独立授权 canister。canister 会:

  1. 生成 PKCE code_verifier 和 code_challenge(通过 google-oauth)。
  2. 构建 Google 授权 URL(通过 google-oauth.buildAuthorizeUrl)。
  3. 前端将用户重定向至 Google;用户同意后,Google 会携带 code 参数重定向回来。
  4. canister 交换代码以获取令牌(通过 google-oauth.exchangeAuthorizationCode)——在链上且非复制。
  5. canister 将 access_token + refresh_token 按 caller 存储。
  6. 1 小时的访问令牌过期时(HTTP 401),canister 会静默 刷新令牌(通过 google-oauth.refreshAccessToken)并重试。
Google Cloud Console 设置
  1. 创建一个 Google OAuth 2.0 Web application 客户端。
  2. 应用的 Calendar 设置页面必须在可复制字段中显示此字面回调 URI:window.location.origin + "/connect/calendar" —— 例如,https://my-app.caffeine.xyz/connect/calendar。应用 管理员必须手动将显示的值复制到 Google Cloud Console 的 Authorized redirect URIs 中。为用户可以连接 Calendar 的每个已部署源 (例如,草稿版和正式版应用源)分别注册为授权重定向 URI。
  3. 在授权同意屏幕上仅启用应用所需的 Calendar 范围。
  4. 通过应用的管理员设置 页面输入 Client ID 和 Client Secret。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 变体。

存储令牌

Bearer 令牌绝不会离开 canister。前端只会获知调用方是否已连接(一个 Bool),绝不会获知令牌本身。

  • 使用以调用方为键的 Map<Principal, CalendarConnection>。仅公开 §4 中列出的端点 —— isMyCalendarConnected、startCalendarOAuth、 completeCalendarOAuth、listUpcomingEvents、createEvent、 disconnectMyCalendar — 所有端点都必须检查 not caller.isAnonymous()。 不得添加任何会返回 access_token / refresh_token / 完整 CalendarConnection 的端点。
  • 每个调用方只存储一个待处理的 OAuth 流程:PKCE code_verifier、确切的 redirectUri 和随机 state nonce。回调完成时将其消耗;不得接受来自前端的替代重定向 URI。
Google 刷新令牌不会轮换

与 X/Twitter 不同,Google 不会在每次刷新时轮换 refresh_token。同一个 refresh_token 可以重复使用,直到用户撤销 访问权限或重新签发授权。这简化了刷新逻辑:只需持久化新的 access_token,保留旧的 refresh_token。

3. is_replicated = ?false 是必需的

  1. 安全性。 复制的 HTTP 外呼会从子网中的每个节点发送请求。每个请求都携带 Authorization: Bearer <token> 标头——任何节点上的 bearer 令牌一旦泄露,都会危及用户的 Google 账号。
  2. 计费。 复制的外呼会并行发起 N 次 API 调用。IC 会收取约 13 倍的 cycles,Google 也会将每次调用计入配额。
  3. 确定性。 Calendar 写入响应具有非确定性(唯一的事件 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 请求固定为非复制模式;14 个 POST 操作——包括 events.insert、events.quickAdd、calendars.insert——都会继承默认值。直接使用 defaultConfig 的应用每次调用都会创建大约 13 个重复事件。遵循本指南的代码没有问题;未遵循的则有问题。

4. 规范布局

默认结构:管理员 Client ID/Secret + 每位用户各自的 OAuth。Canister 所有者注册一个 Google Cloud 桌面应用,并将其 Client ID + Secret 填入 canister 级配置;每位最终用户都使用这组凭据完成 OAuth 2.0 PKCE 握手,并各自获得 access_token + refresh_token。

示例包含四个文件:

  • src/backend/main.mo — actor:仅包含状态和 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. 可用时间/忙碌时间——使用 FreeBusy,而不是 events.list

任何“此人什么时候有空/忙”、预约或类似 Calendly 的功能,都必须通过 FreeBusy(calendar_freebusy_query)读取可用时间,而不是使用 calendar_events_list。FreeBusy 专为此用途打造:一次 POST 即可返回用户各个日历中合并后的忙碌时段,并且服务器端已展开重复事件——你无需翻页读取事件、展开重复事件或自行合并重叠时段。它还会纳入不在办公室和全天时段。calendar_events_list 应保留用于显示应用自身的事件列表,_insert/_delete 则用于事件 CRUD。

上文 lib/calendar.mo 代码块中的 busyTimes helper 是参考实现:它会针对 [timeMin, timeMax],为 items = [{ id = "primary" }] 构建 FreeBusyRequest,在收到 401 时重试一次(先刷新),并且——至关重要的是——遍历响应返回的每个日历(映射以解析后的日历 ID 为键,而不是 "primary"),并合并它们的忙碌时段。

在比较每个 (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(其 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 对可选枚举参数的破坏性变更

可选枚举查询参数现在使用 ?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。非枚举参数保持不变:空 Text 和 0 仍表示“省略”。下方示例使用了新形式。

自 0.2.0 起提供两种调用方式

0.2.0 新增了 src/Client.mo,这是一个嵌套类 facade,按照 Calendar 的点分隔 operationId 对操作进行分组,和 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。

选择调用方式时,有两点需要注意:

  • facade 在构造时绑定一次 Config,因此进行多次 Calendar 调用的 shared 方法无需在每次调用时都传递它;
  • facade 方法是 async,扁平的 Apis/*.mo 自由函数则是 async*。在 async* helper 中,facade 会增加一个 await 边界,因此 §4 中的 mixin 布局仍采用扁平形式。

facade 是增补式的——不会移除任何扁平入口——但这不意味着 0.1.x 代码无需修改就能编译:上文所述的可选枚举签名变更同样适用于扁平函数。升级时,无论使用哪种方式,都需要在相应调用点加上 ?。

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 辅助函数。 对于其他生成的操作,请保留 bearer 身份验证和 is_replicated = ?false,然后采用与 refreshIfNeeded 相同的单次刷新重试模式。

生成的包还提供:

函数模块用途
calendar_events_listEventsApi列出日历中的事件
calendar_events_getEventsApi按 ID 获取事件
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 静默刷新,并重试一次。其他所有失败都会作为拒绝向上传播,因此必须处理此类失败的调用方(例如跳过某位用户的日历)应在调用处捕获。刷新失败仍会在 accessTokenOf 中触发陷阱,因此调用方无法捕获;对此应提示“重新连接你的账户”。
  • 回调 URI 必须完全匹配。 授权 URL 和重定向 URI 中的每个字符(尾随斜杠、查询字符串、端口)都必须一致。否则 Google 会返回 redirect_uri_mismatch。redirectUri 使用固定值 window.location.origin + "/connect/calendar"——设置页面显示的值以及 /connect/calendar 路由所使用的值;并在 Google Web 客户端中注册此确切 URI。不要用 window.location.pathname 构建它,因为该值会随页面变化。
  • 将显示的值原样传给 startCalendarOAuth——绝不要传入原始的 *.icp0.io canister URL。 Caffeine 应用会通过多个来源提供服务(*-draft.caffeine.xyz 草稿域名、*.caffeine.xyz 正式域名,以及原始的 <canister-id>.icp0.io URL)。在一个共享辅助函数中计算重定向 URI(window.location.origin + "/connect/calendar"),并在设置页面的可复制字段和传给 startCalendarOAuth 的值中使用同一个辅助函数。如果发送给 Google 的值(通过 startCalendarOAuth)与设置页面显示且管理员注册的值不同——例如构建时配置值或 *.icp0.io canister 源地址——Google 会返回 redirect_uri_mismatch。
  • RFC 3339 时间戳。 Calendar 使用 RFC 3339 字符串(2026-07-10T15:00:00-07:00)。对于全天事件,应设置 EventDateTime.date(YYYY-MM-DD),而不是 dateTime。
  • createEvent 的时间必须包含时区。 传给 createEvent 的 dateTime 必须带有 UTC 偏移量(…Z 或 …+02:00),或者还必须设置 EventDateTime.timeZone(例如 "Europe/Zurich" 这样的 IANA 名称)。既不包含偏移量也不设置时区的 2026-07-10T15:00:00 会被 Google 拒绝。建议发送带偏移量的字符串,以确保事件安排在预期的当地时间。
  • calendarId = "primary" 指经过身份验证的用户的默认日历。命名日历或共享日历使用其日历 ID(类似电子邮件地址)。
  • 查询可用状态应使用 FreeBusy,而非 events.list。 如需查询“我是否有空”,请使用 calendar_freebusy_query(§4b):一次 POST 即可返回合并后的忙碌时段,重复事件会在服务器端展开。通过 events.list 重建可用状态,需要分页、展开重复事件并手动合并重叠时段——很容易出错,也是“预约链接显示我有空,但我其实很忙”的典型原因。
  • maxAttendees 和 maxResults 必须 ≥ 1。 Google 会对 maxAttendees=0 / maxResults=0 返回 HTTP 400(文档规定的最小值为 1)。listUpcomingEvents 和 createEvent 示例传入 maxAttendees = 10;在任何事件端点中都不要为这些参数传入 0。
  • FreeBusy 响应以解析后的日历 ID 为键,而不是你查询时使用的字符串。 调用 calendar_freebusy_query 查询 "primary" 时,Google 会解析该值,并以真实日历 ID(用户的电子邮件地址)作为 calendars 映射的键,而不是字面值 "primary"。不要在响应中查找 "primary"——查不到任何内容,会导致所有时段都显示为空闲(这是常见的可用状态错误)。应遍历响应返回的所有日历,将它们的 busy 时段合并,再从候选时段中扣除这些时段。将每个时段的 start/end 解析为 RFC 3339,允许末尾带 Z 或数字偏移量(+02:00);比较时间点,而不是原始字符串。
  • 使用 LibCalendar.rfc3339ToNanos 解析 RFC 3339(从 mo:google-oauth/DateTime 重新导出)——不要自行重写解析逻辑。 手写解析器若忘记对每个数字减去 '0'(48),就会把 "2026" 读作 55354,导致所有忙碌时段都落在错误年份、重叠检查永远无法匹配,可用状态也会悄无声息地出错——代码仍能编译,也不会触发陷阱,因此直到用户被重复预约才会发现这个问题。请使用经过测试的辅助函数。
  • HTTP 429 速率限制。 将错误传给调用方;绝不要在 canister 内静默重试写入操作,因为重试可能创建重复事件。
  • 不要暴露访问令牌。 API 调用中只通过 Map.get(calendarConnections, ..., caller) 读取 calendarConnections。不要提供 getMyCalendarConnection、getMyAccessToken 或迭代器。泄露的 bearer 令牌会导致单个用户的账户遭到入侵。
  • 所有 Calendar API v3 调用都使用 alt = ?#json(0.2.0 之前使用不带问号的 #json)。可选字符串参数保留为 "",并将 prettyPrint 设为 false。
  • 查询参数按位置传递;从 0.2.0 起,可选的枚举参数类型为 ?T。 Text 传入 "",Bool 传入 false,Nat 传入实际数字(遵守各自文档规定的最小值:maxAttendees / maxResults 必须 ≥ 1),可选枚举传入 ?#json / ?#starttime / ?#all——也可以传入 null 来省略它们,这正是 ? 的用途。非枚举参数不能传入 null。模型值(Event、EventDateTime、FreeBusyRequest)仍像以前一样直接传入。
  • 组合 Gmail + Calendar 应用:请求权限范围的并集,并通过 OAuth.getUserEmail 获取地址。 openid email + .../calendar + .../gmail.send 的并集涵盖可用状态查询、邮件发送和已连接地址(通过 OIDC userinfo)——除非应用确实要读取邮件,否则不需要 gmail.readonly。合并示例时绝不要移除权限范围——参见“组合 Gmail + Calendar 应用”。
  • 使用 init {} 构建 Event / EventDateTime,然后通过 record-update 设置所需字段——所有字段都是可选的(?T);其余字段保持为 null。
  • 生成的客户端会强制 PATCH/PUT/DELETE 使用非复制模式(googlecalendar-client 会自动为这些方法设置 is_replicated = ?false)。对于 GET/POST,请在 Config 中显式设置。

前端

使用此技能构建的应用必须包含以下全部四项。(如果应用还使用 Gmail 连接器,请改为遵循下方的“Gmail + Calendar 组合应用”——它会将 /settings/calendar + /connect/calendar 替换为共用的 /settings/google + /connect/google。以下要求仍然适用;只有这两个路径会改变。)这些是验收标准,而非建议——构建完成前请逐项验证。以下三项是构建时常被遗漏的要求,缺少其中任何一项都会导致连接器无法使用,而不只是功能不完整:

  • 凭据页面存在且可以访问。 应用必须提供 /settings/calendar 页面,其中包含 Client ID/Secret 输入框(第 2 项),并且已登录的管理员必须能够访问该页面——通过导航链接,或连接页面上“尚未配置”的提示。常见问题是只有“连接 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,不多不少。没有该 URI,管理员就无法在 Google 中注册它,每次连接都会失败。
  • /connect/calendar 必须是真正处理 Google 回调的路由——不能只是一个按钮页面。如果它跳转到兜底路由或主页,或者在已验证的调用者准备就绪前调用 completeCalendarOAuth,连接就会静默失败,应用会显示“未连接”。
  1. 登录流程——必需。 Calendar 必须由非匿名调用者才能正常工作;每位用户的 OAuth 握手会将令牌按 caller : Principal 为键存储,而管理员凭据设置器会检查 #admin。登录流程来自 extension-authorization:useInternetIdentity、登录/注销按钮,以及将已验证身份注入每次后端调用的 useActor 管理逻辑。

  2. 管理员设置页面 — /settings/calendar(仅限管理员访问)。此页面为必需项;缺少此页面的 Calendar 构建并不完整:

    • 在凭据输入框之前显示“如何获取 Google 凭据”面板。请向管理员说明这只是一次性设置,大约需要 5 分钟,并按以下编号步骤引导操作(Agent 的完成消息必须重复相同步骤):
      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)。 按 Enter 键提交;成功后清空输入框。
    • 状态指示器由 isCalendarConfigured() 驱动(返回 Bool)。 显示“已配置”/“未配置”——绝不显示凭据。
    • 确保可以访问此页面。 应用的主导航(共享 Layout)必须为管理员提供指向此页面的链接——当 isCallerAdmin 为 true 时显示链接,否则隐藏(通过 extension-authorization)。无论导航在哪里定义,都应在那里添加此链接,而不是添加到此页面内部。无法访问 /settings/calendar 路由的构建是不完整的。但不要只依赖导航:下方的未配置提示才是用户发现需要进行设置的主要方式。
  3. “连接日历”页面和回调页面 — /connect/calendar(任何已登录用户均可访问)。此专用页面必须捕获并处理 Google 在用户授权后的重定向;它不能只是一个带有连接按钮的页面:

    • 为所有人处理未配置的情况。 isCalendarConfigured() 是一个公开查询(任何已登录用户都可以调用)。当它返回 false 时,不要显示无法使用的连接按钮。管理员应看到指向 /settings/calendar 的链接,以便输入凭据。非管理员必须看到说明,而不是无路可走的页面,例如:“Google 日历尚未设置 — 应用管理员需要在设置中添加 Google 凭据。”只有配置完成后,才启用“连接 Google 日历”按钮。
    • “连接 Google 日历”按钮绑定到 startCalendarOAuth(calendarRedirectUri())。将浏览器重定向到 canister 返回的 URL。不要从任意当前路径推导回调地址;固定的 /connect/calendar 路由必须与设置页面中的 URI 完全一致。
    • 将 /connect/calendar 注册为真实的应用路由。它必须捕获 Google 回调;在处理回调前,不得跳转到兜底重定向、布局默认页面或主页。
    • 返回时,从 URLSearchParams 读取 error、code 和 state。如果存在 error,显示连接失败/已拒绝状态,且不要调用 canister。只有当 code 和 state 都存在时,才调用并等待 completeCalendarOAuth(code, state) 完成,然后再导航到其他页面或清除 URL。在等待期间,保持显示“正在连接 Google 日历…”状态。不要先替换路由、重定向到主页或丢弃查询参数,否则会导致一次性代码丢失,用户也无法完成连接。
    • 调用一次性回调前,等待 Actor 就绪。 页面必须等待 useInternetIdentity().isAuthenticated 为真,且 useActor(createActor) 提供非空、非加载中的 Actor,然后才能调用 completeCalendarOAuth。在此之前不要设置 startedRef/单次调用保护:首次渲染时 Actor 通常尚不可用,否则“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)。要限定单日范围(例如“明天的会议”),需同时传入当天本地起始时间和次日开始时间,两者都使用带偏移量的 RFC 3339 格式;并且只统计 isAllDay 为 false、transparency 不为 "transparent" 且 eventType 为 "default" 的条目(这样可过滤全天、空闲、外出办公和工作地点标记)。在使用 singleEvents = true 和 orderBy = startTime 时,必须执行此筛选。还要包含“创建活动”表单。datetime-local 值不带偏移量,因此在调用 Actor 前,应将每个浏览器本地时间值转换为 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 连接,而不是两个(授权码只能使用一次,因此两个流程会要求用户进行两次授权)。 前端:

  • 一个管理页面 /settings/google — 一个 Client ID / Client Secret 表单、一个 isGoogleConfigured 状态,以及一个可复制的重定向 URI 字段,内容必须精确为 window.location.origin + "/connect/google"。
  • 一个连接路由 /connect/google — 与上方前端部分要求的真实回调路由相同:呈现“Connect Google”,接收重定向,等待 actor 就绪,然后只调用一次完成操作。不要设置第二个回调路由。
  • 不要构建 /settings/gmail、/connect/gmail、/settings/calendar 或 /connect/calendar。上方的其他前端要求仍然适用,只有这些路径会改变。

后端 — 只编写一次共享流程(取代两个按连接器划分的 OAuth 流程)。其结构与按连接器划分的 startAuthorize / exchangeCode / 刷新函数相同,但有以下确切差异:

  • 由 #admin 限制访问的单个配置设置器,用于存储一个 Client ID/Secret。
  • SCOPES = 以下范围的并集 — 在一次授权中访问两个 API。
  • completeGoogleOAuth(code, state) 通过 OAuth.getUserEmail 获取已连接账号的邮箱(OIDC userinfo — 只需 openid email,不需要 gmail.readonly),并在单个 Map<Principal, GoogleConnection> 中存储一个连接 { accessToken; refreshToken; emailAddress }。
  • Gmail 发送操作和 Calendar 调用各自使用该 accessToken 创建自己的客户端 Config,并各自保留遇到 401 时只重试刷新一次的逻辑。
  • 将连接和客户端配置保存在一个共享状态值中,并将其作为参数传给 Gmail 和 Calendar 两个 mixin,使两者读写同一个连接(请参阅 writing-motoko mixin 规则)。
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.

将其接入为两个服务共享的一个连接 — 只声明一次配置、连接映射和待处理流程映射,并将相同的绑定传给每个 mixin。Gmail 和 Calendar 消息 mixin 不得声明自己的配置或连接;它们接收共享的 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 mixin 规则)。

在同一个 OAuth 客户端上启用两个 API,并且只注册单个 .../connect/google 重定向 URI。仅当用户明确要求连接两个不同的 Google 账号时,才拆分为两个独立面板。

所有变体通用

  • 所有与 Calendar 相关的路由都必须登录后才能访问。 通过 extension-authorization 的身份验证守卫(useInternetIdentity + 在 !isAuthenticated 时重定向)接入 /settings/... 和连接路由(组合应用中为 /connect/calendar 或 /connect/google)。
  • 前端绝不持久化令牌。 不使用 localStorage、IndexedDB 或 Cookie — 所有操作均由 canister 处理。浏览器只会看到 Bool 状态标记和 OAuth 重定向 URL。
  • OAuth state 参数由 canister 生成并验证。 canister 会将随机 nonce 与待处理的 verifier 和回调 URI 一起存储。前端必须在完成调用中同时传入 code 和 state(组合应用中调用 completeGoogleOAuth,否则调用 completeCalendarOAuth);前端绝不创建或修改其中任何一个值。
  • Calendar 界面很简单: 显示即将发生的活动列表,以及包含摘要和开始/结束日期时间输入框的创建活动表单。不使用客户端 Google SDK,不处理令牌,也不进行 JSON 序列化 — canister 就是 Calendar 客户端。

相关内容

评论

支持 Markdown