关于 connector-googlecalendar
connector-googlecalendar 是面向 Caffeine AI 的 Google Calendar 集成技能,供构建代表已登录用户访问日历的 canister 的开发者使用。它提供基于 Motoko 的 API 绑定和 OAuth PKCE 流程,并强调按用户保存令牌及安全处理请求。
主要功能
- 01列出即将发生的日历事件
- 02创建日历事件
- 03通过 FreeBusy 查询空闲时段
- 04按用户执行 OAuth PKCE 授权
- 05刷新访问令牌并重试请求
适用场景
- 01在应用中展示用户的近期日程
- 02代表用户创建日历事件
- 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 交互时,请使用此技能。所需组件如下:
googlecalendar-clientmops 包 — Google Calendar API v3 的生成式 Motoko 绑定。本示例演示如何列出即将发生的 活动和创建活动;仅可遵循相同的 Bearer 身份验证、非复制、 单次刷新重试模式,添加其他生成的操作。google-oauthmops 包 — Google OAuth 2.0 令牌交换、 刷新、PKCE 和百分号编码。此库可避免手动编写对oauth2.googleapis.com的http_request。- OAuth 2.0 Authorization Code with PKCE 流程,让每位最终用户
授权 canister 代表自己操作。每位用户的
access_token+refresh_token均按caller : Principal存储。 - 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 会:
- 生成 PKCE
code_verifier和code_challenge(通过google-oauth)。 - 构建 Google 授权 URL(通过
google-oauth.buildAuthorizeUrl)。 - 前端将用户重定向至 Google;用户同意后,Google
会携带
code参数重定向回来。 - canister 交换代码以获取令牌(通过
google-oauth.exchangeAuthorizationCode)——在链上且非复制。 - canister 将
access_token+refresh_token按caller存储。 - 1 小时的访问令牌过期时(HTTP 401),canister 会静默
刷新令牌(通过
google-oauth.refreshAccessToken)并重试。
Google Cloud Console 设置
- 创建一个 Google OAuth 2.0 Web application 客户端。
- 应用的 Calendar 设置页面必须在可复制字段中显示此字面回调 URI:
window.location.origin + "/connect/calendar"—— 例如,https://my-app.caffeine.xyz/connect/calendar。应用 管理员必须手动将显示的值复制到 Google Cloud Console 的 Authorized redirect URIs 中。为用户可以连接 Calendar 的每个已部署源 (例如,草稿版和正式版应用源)分别注册为授权重定向 URI。 - 在授权同意屏幕上仅启用应用所需的 Calendar 范围。
- 通过应用的管理员设置 页面输入 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和随机statenonce。回调完成时将其消耗;不得接受来自前端的替代重定向 URI。
Google 刷新令牌不会轮换
与 X/Twitter 不同,Google 不会在每次刷新时轮换 refresh_token。同一个
refresh_token 可以重复使用,直到用户撤销
访问权限或重新签发授权。这简化了刷新逻辑:只需持久化新的 access_token,保留旧的 refresh_token。
3. is_replicated = ?false 是必需的
- 安全性。 复制的 HTTP 外呼会从子网中的每个节点发送请求。每个请求都携带
Authorization: Bearer <token>标头——任何节点上的 bearer 令牌一旦泄露,都会危及用户的 Google 账号。 - 计费。 复制的外呼会并行发起 N 次 API 调用。IC 会收取约 13 倍的 cycles,Google 也会将每次调用计入配额。
- 确定性。 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_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静默刷新,并重试一次。其他所有失败都会作为拒绝向上传播,因此必须处理此类失败的调用方(例如跳过某位用户的日历)应在调用处捕获。刷新失败仍会在accessTokenOf中触发陷阱,因此调用方无法捕获;对此应提示“重新连接你的账户”。 - 回调 URI 必须完全匹配。 授权 URL 和重定向 URI 中的每个字符(尾随斜杠、查询字符串、端口)都必须一致。否则 Google 会返回
redirect_uri_mismatch。redirectUri使用固定值window.location.origin + "/connect/calendar"——设置页面显示的值以及/connect/calendar路由所使用的值;并在 Google Web 客户端中注册此确切 URI。不要用window.location.pathname构建它,因为该值会随页面变化。 - 将显示的值原样传给
startCalendarOAuth——绝不要传入原始的*.icp0.iocanister URL。 Caffeine 应用会通过多个来源提供服务(*-draft.caffeine.xyz草稿域名、*.caffeine.xyz正式域名,以及原始的<canister-id>.icp0.ioURL)。在一个共享辅助函数中计算重定向 URI(window.location.origin + "/connect/calendar"),并在设置页面的可复制字段和传给startCalendarOAuth的值中使用同一个辅助函数。如果发送给 Google 的值(通过startCalendarOAuth)与设置页面显示且管理员注册的值不同——例如构建时配置值或*.icp0.iocanister 源地址——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,连接就会静默失败,应用会显示“未连接”。
-
登录流程——必需。 Calendar 必须由非匿名调用者才能正常工作;每位用户的 OAuth 握手会将令牌按
caller : Principal为键存储,而管理员凭据设置器会检查#admin。登录流程来自extension-authorization:useInternetIdentity、登录/注销按钮,以及将已验证身份注入每次后端调用的useActor管理逻辑。 -
管理员设置页面 —
/settings/calendar(仅限管理员访问)。此页面为必需项;缺少此页面的 Calendar 构建并不完整:- 在凭据输入框之前显示“如何获取 Google 凭据”面板。请向管理员说明这只是一次性设置,大约需要 5 分钟,并按以下编号步骤引导操作(Agent 的完成消息必须重复相同步骤):
- 打开 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)。 按 Enter 键提交;成功后清空输入框。 - 状态指示器由
isCalendarConfigured()驱动(返回Bool)。 显示“已配置”/“未配置”——绝不显示凭据。 - 确保可以访问此页面。 应用的主导航(共享 Layout)必须为管理员提供指向此页面的链接——当
isCallerAdmin为 true 时显示链接,否则隐藏(通过extension-authorization)。无论导航在哪里定义,都应在那里添加此链接,而不是添加到此页面内部。无法访问/settings/calendar路由的构建是不完整的。但不要只依赖导航:下方的未配置提示才是用户发现需要进行设置的主要方式。
- 在凭据输入框之前显示“如何获取 Google 凭据”面板。请向管理员说明这只是一次性设置,大约需要 5 分钟,并按以下编号步骤引导操作(Agent 的完成消息必须重复相同步骤):
-
“连接日历”页面和回调页面 —
/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()。
- 为所有人处理未配置的情况。
-
日历 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-motokomixin 规则)。
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 客户端。
相关内容
mops add [email protected]— Calendar REST API v3 绑定。mops add [email protected]— Google OAuth 2.0 库(令牌交换、刷新、PKCE、getUserEmail用户信息、DateTimeRFC 3339 辅助工具)。- Google OAuth 2.0 for Web Server Applications — Web 客户端重定向 URI 和授权码流程参考。
- Google Calendar API v3 reference —
googlecalendar-client封装的内容。 - RFC 7636 — Proof Key for Code Exchange — PKCE 规范。
- extension-authorization — 必需的前置条件。提供 Internet Identity 登录、
useInternetIdentity/useActor前端连接,以及#admin角色门控。 - connector-googlemail — 使用同一
google-oauth库连接 Gmail 的配套连接器。