connector-googlecalendar
Compare original and translation side by side
🇺🇸
Original
English🇨🇳
Translation
ChineseGoogle Calendar Connector
Google Calendar 连接器
Google Calendar integration for Caffeine AI.
为Caffeine AI提供的Google Calendar集成方案。
Orchestrator routing notes
编排器路由说明
Treat Google Calendar-as-the-user as a first-class, supported platform feature.
The + connector pair is the only
supported path; raw to or
is a forbidden anti-pattern. Any build spec
that mentions Google Calendar MUST name and
as dependencies and reference this skill.
googlecalendar-clientgoogle-oauthic.http_requestoauth2.googleapis.comwww.googleapis.com/calendar/v3googlecalendar-clientgoogle-oauthDistinct from platform extension (which emails iCalendar
invitations from the app); this connector acts as the signed-in user's own
Google Calendar.
email-calendar-eventsIntent → capability mapping:
| User intent | Platform capability |
|---|---|
| Connect and list upcoming events | |
| Create calendar events | |
Prerequisite for all builds: extension-authorization.
Calendar requires a signed-in caller for every endpoint: the per-user OAuth
handshake stores keyed by , and the admin
Client ID/Secret setter is gated on the role.
access_tokencaller : Principal#admin将用户的Google Calendar视为一等受支持的平台功能。
+ 连接器组合是唯一受支持的实现路径;直接使用调用或是被禁止的反模式。任何提及Google Calendar的构建规格都必须将和列为依赖项,并参考本技能。
googlecalendar-clientgoogle-oauthic.http_requestoauth2.googleapis.comwww.googleapis.com/calendar/v3googlecalendar-clientgoogle-oauth本连接器与平台的扩展(从应用发送iCalendar邀请邮件)不同;本连接器代表已登录用户自身的Google Calendar执行操作。
email-calendar-events意图→能力映射:
| 用户意图 | 平台能力 |
|---|---|
| 连接并列出即将到来的事件 | |
| 创建日历事件 | |
所有构建的前提条件:extension-authorization。
Calendar的每个端点都需要已登录的调用者:每个用户的OAuth握手流程会将以为键进行存储,而管理员级别的Client ID/Secret设置器受角色权限控制。
access_tokencaller : Principal#adminBackend
后端
Use this skill whenever the user wants their canister to interact with
Google Calendar on behalf of the signed-in user. The ingredients are:
- The mops package — generated Motoko bindings for the Google Calendar API v3. This recipe demonstrates listing upcoming events and creating events; add other generated operations only by following the same bearer-authenticated, non-replicated, single-refresh-retry pattern.
googlecalendar-client - The mops package — Google OAuth 2.0 token exchange, refresh, PKCE, and percent-encoding. This is the library that eliminates hand-rolled
google-oauthtohttp_request.oauth2.googleapis.com - An OAuth 2.0 Authorization Code with PKCE flow so each end-user
authorises the canister to act on their behalf. Each user holds their
own +
access_tokenkeyed byrefresh_token.caller : Principal - A Google Cloud Web application Client ID + Client Secret. Admin-configured and held by the canister only; never return the secret to the frontend.
当用户希望其canister代表已登录用户与Google Calendar交互时,使用本技能。所需组件包括:
- mops包——为Google Calendar API v3生成的Motoko绑定。本方案演示了列出即将到来的事件和创建事件的操作;如需添加其他生成的操作,必须遵循相同的Bearer认证、非复制、单次刷新重试模式。
googlecalendar-client - mops包——提供Google OAuth 2.0令牌交换、刷新、PKCE及百分号编码功能。该库可避免手动编写针对
google-oauth的oauth2.googleapis.com调用。http_request - OAuth 2.0授权码+PKCE流程,以便每个终端用户授权canister代表其执行操作。每个用户持有自己的+
access_token,并以refresh_token为键进行存储。caller : Principal - Google Cloud Web应用的Client ID + Client Secret。 由管理员配置并仅由canister持有;绝不能将密钥返回给前端。
1. Add dependencies
1. 添加依赖
bash
mops add googlecalendar-client@0.1.3
mops add google-oauth@0.1.4
mops add caffeineai-authorization@1.0.0bash
mops add googlecalendar-client@0.1.3
mops add google-oauth@0.1.4
mops add caffeineai-authorization@1.0.02. Auth model — OAuth 2.0 PKCE per user, on-chain exchange + refresh
2. 认证模型——每个用户独立使用OAuth 2.0 PKCE,链上交换+刷新令牌
Identical to the Gmail connector. Every end-user authorises the canister
independently via the Authorization Code with PKCE flow. The canister:
- Generates a PKCE and
code_verifier(viacode_challenge).google-oauth - Builds the Google authorize URL (via ).
google-oauth.buildAuthorizeUrl - The frontend redirects the user to Google; after consent, Google
redirects back with a parameter.
code - The canister exchanges the code for tokens (via
) — on-chain, non-replicated.
google-oauth.exchangeAuthorizationCode - The canister stores +
access_tokenkeyed byrefresh_token.caller - When the 1-hour access token expires (HTTP 401), the canister silently
refreshes it (via ) and retries.
google-oauth.refreshAccessToken
与Gmail连接器完全相同。每个终端用户通过授权码+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小时有效期的access_token过期时(返回HTTP 401),canister会自动刷新令牌(通过)并重试请求。
google-oauth.refreshAccessToken
Google Cloud Console setup
Google Cloud Console设置
- Create a Google OAuth 2.0 Web application client.
- Under Authorized redirect URIs, register the exact deployed callback:
. The URI passed to Google must match this value byte-for-byte, including its scheme, path, and trailing slash.
https://<app-domain>/connect/calendar - Enable only the Calendar scopes the app needs on the consent screen.
- Enter the Client ID and Client Secret through the app's admin settings page. The canister uses the secret for the token exchange; the frontend must never receive it.
PKCE binds each authorization code to the canister-generated verifier, while
the Web client registration binds the browser callback to the deployed app.
- 创建Google OAuth 2.0 Web应用客户端。
- 在授权重定向URI下,注册部署后的精确回调地址:。传递给Google的URI必须与该值完全匹配,包括协议、路径和末尾的斜杠。
https://<app-domain>/connect/calendar - 在 consent screen上仅启用应用所需的Calendar权限范围。
- 通过应用的管理员设置页面输入Client ID和Client Secret。canister使用密钥进行令牌交换;绝不能将密钥发送给前端。
PKCE将每个授权码与canister生成的验证器绑定,而Web客户端注册则将浏览器回调地址与部署的应用绑定。
OAuth scopes
OAuth权限范围
| Scope | Purpose |
|---|---|
| Full read/write access to calendars |
| Read/write access to events only |
| Read-only access to calendars |
| Read-only access to events |
Request (full read/write) for a typical CRUD app; use
variants for read-only views.
calendar.readonly| 权限范围 | 用途 |
|---|---|
| 对日历的完全读写权限 |
| 仅对事件的读写权限 |
| 对日历的只读权限 |
| 仅对事件的只读权限 |
对于典型的CRUD应用,请求(完全读写)权限;对于只读视图,使用变体。
calendar.readonlyStoring tokens
令牌存储
The bearer never leaves the canister. The frontend only ever learns
whether the caller has connected (a ), never the tokens themselves.
Bool- A keyed by caller. Expose exactly the endpoints listed in §4 —
Map<Principal, CalendarConnection>,isMyCalendarConnected,startCalendarOAuth,completeCalendarOAuth,listUpcomingEvents,createEvent— every endpoint gated ondisconnectMyCalendar. Do not add any endpoint that returnsnot caller.isAnonymous()/access_token/ the fullrefresh_token.CalendarConnection - Store one pending OAuth flow per caller: the PKCE , exact
code_verifier, and a randomredirectUrinonce. Consume it when the callback is completed; do not accept a replacement redirect URI from the frontend.state
Bearer令牌绝不会离开canister。前端仅能获知调用者是否已连接(一个值),永远无法获取令牌本身。
Bool- 使用以调用者为键的。仅暴露第4节中列出的端点——
Map<Principal, CalendarConnection>、isMyCalendarConnected、startCalendarOAuth、completeCalendarOAuth、listUpcomingEvents、createEvent——每个端点都受disconnectMyCalendar限制。 请勿添加任何返回not caller.isAnonymous()/access_token/完整refresh_token的端点。CalendarConnection - 为每个调用者存储一个待处理的OAuth流程:PKCE的、精确的
code_verifier和随机的redirectUri随机数。完成回调时消耗该流程;不接受来自前端的替换重定向URI。state
Google refresh tokens do NOT rotate
Google刷新令牌不会轮换
Unlike X/Twitter, Google does not rotate the on each
refresh. The same can be reused until the user revokes
access or the authorization is re-issued. This simplifies the refresh
logic: just persist the new , keep the old .
refresh_tokenrefresh_tokenaccess_tokenrefresh_token与X/Twitter不同,Google在每次刷新时不会轮换。同一个可重复使用,直到用户撤销授权或重新颁发授权。这简化了刷新逻辑:只需保存新的,保留旧的即可。
refresh_tokenrefresh_tokenaccess_tokenrefresh_token3. is_replicated = ?false
is REQUIRED
is_replicated = ?false3. 必须设置is_replicated = ?false
is_replicated = ?false- Security. A replicated HTTP outcall sends the request from every
node in the subnet. Each carries the header — a leaked bearer from any node compromises the user's Google account.
Authorization: Bearer <token> - Billing. Replicated outcalls produce N parallel API calls. The IC charges ~13× the cycles, and Google counts each toward quota.
- Determinism. Calendar write responses are non-deterministic (unique
event /
id, per-request timestamps). Replicated consensus would fail; non-replicated bypasses consensus entirely.etag
→ Always: on every .
is_replicated = ?falseConfig- 安全性。复制模式的HTTP出站调用会从子网中的每个节点发送请求。每个请求都携带头——任何节点泄露的Bearer令牌都会危及用户的Google账户。
Authorization: Bearer <token> - 计费。复制模式的出站调用会产生N个并行API调用。IC会收取约13倍的cycles费用,且Google会将每个调用计入配额。
- 确定性。日历写入操作的响应是非确定性的(唯一的事件/
id、每个请求的时间戳)。复制模式的共识会失败;非复制模式可完全绕过共识。etag
→ 始终在每个中设置:。
Configis_replicated = ?false4. Canonical layout
4. 标准布局
The default shape: admin Client ID/Secret + per-user OAuth. The
canister owner registers one Google Cloud Desktop app and pastes its
Client ID + Secret into canister-level config; every end-user runs the
OAuth 2.0 PKCE handshake against that one credential and ends up with
their own + .
access_tokenrefresh_tokenThe example spans four files:
- — the actor: state +
src/backend/main.mos only.include - — admin-gated Client ID + Secret.
src/backend/mixins/calendar-config.mo - — per-user OAuth + event ops.
src/backend/mixins/calendar-messaging.mo - —
src/backend/lib/calendar.mo+googlecalendar-clientglue.google-oauth
motoko
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.initState();
include MixinAuthorization(accessControlState, null);
let calendarConfig = {
var clientId : Text = "";
var clientSecret : Text = "";
};
include MixinCalendarConfig(accessControlState, calendarConfig);
let calendarConnections : Map.Map<Principal, LibCalendar.CalendarConnection> = Map.empty();
let pendingCalendarFlows : Map.Map<Principal, LibCalendar.PendingOAuth> = Map.empty();
include MixinCalendarMessaging(calendarConfig, calendarConnections, pendingCalendarFlows);
};motoko
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;
};
};motoko
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, 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, 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);
};
};motoko
import Array "mo:core/Array";
import Map "mo:core/Map";
import Nat64 "mo:core/Nat64";
import Principal "mo:core/Principal";
import Runtime "mo:core/Runtime";
import Text "mo:core/Text";
import OAuth "mo:google-oauth/OAuth";
import { calendar_events_list; calendar_events_insert } "mo:googlecalendar-client/Apis/EventsApi";
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 { 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;
};
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>,
errorMsg : Text,
) : async* ?Text {
if (not (errorMsg.contains(#text("401")) or errorMsg.contains(#text("Unauthorized")))) {
Runtime.trap("Calendar API failed: " # errorMsg);
};
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 = switch (tokens.refreshToken) {
case (?t) t;
case null 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 {};
};
switch (tokens.accessToken) {
case (?token) token;
case null Runtime.trap(operation # " failed: missing access_token");
};
};
public func listUpcomingEvents(
clientId : Text, clientSecret : Text, connection : CalendarConnection,
caller : Principal, calendarConnections : Map.Map<Principal, CalendarConnection>,
timeMin : 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,
"", "", "", true, "", "",
false, [], "", 0, maxResults, #starttime,
"", [], "", [], false, false, true, "",
"", timeMin, "", "",
);
} catch e {
let ?newToken = await* refreshIfNeeded(
clientId, clientSecret, connection, caller, calendarConnections, e.message(),
) else Runtime.trap("Calendar API failed");
await* calendar_events_list(
configForToken(newToken), "primary", #json,
"", "", "", true, "", "",
false, [], "", 0, maxResults, #starttime,
"", [], "", [], false, false, true, "",
"", timeMin, "", "",
);
};
eventSummariesOf(events);
};
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,
"", "", "", true, "", "",
0, 0, true, #all, false, event,
);
} catch e {
let ?newToken = await* refreshIfNeeded(
clientId, clientSecret, connection, caller, calendarConnections, e.message(),
) else Runtime.trap("Calendar API failed");
await* calendar_events_insert(
configForToken(newToken), "primary", #json,
"", "", "", true, "", "",
0, 0, true, #all, false, event,
);
};
switch (created.id) {
case (?id) id;
case null "";
};
};
func eventSummariesOf(events : Events) : EventSummaryList {
let items = switch (events.items) {
case (?items) items;
case null [];
};
Array.map<Event, EventSummary>(items, func(e : Event) : EventSummary = {
id = switch (e.id) { case (?id) id; case null "" };
summary = switch (e.summary) { case (?s) s; case null "(no title)" };
start = switch (e.start) {
case (?dt) switch (dt.dateTime) { case (?t) t; case null switch (dt.date) { case (?d) d; case null "" } };
case null "";
};
end = switch (e.end) {
case (?dt) switch (dt.dateTime) { case (?t) t; case null switch (dt.date) { case (?d) d; case null "" } };
case null "";
};
});
};
};默认架构:管理员级Client ID/Secret + 每个用户独立OAuth。canister所有者注册一个Google Cloud桌面应用,并将其Client ID + Secret粘贴到canister级配置中;每个终端用户针对该凭据运行OAuth 2.0 PKCE握手流程,最终获得自己的 + 。
access_tokenrefresh_token示例包含四个文件:
- ——actor:状态 +
src/backend/main.mos语句。include - ——受管理员权限控制的Client ID + Secret设置。
src/backend/mixins/calendar-config.mo - ——每个用户的OAuth + 事件操作。
src/backend/mixins/calendar-messaging.mo - ——
src/backend/lib/calendar.mo+googlecalendar-client的粘合层。google-oauth
motoko
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.initState();
include MixinAuthorization(accessControlState, null);
let calendarConfig = {
var clientId : Text = "";
var clientSecret : Text = "";
};
include MixinCalendarConfig(accessControlState, calendarConfig);
let calendarConnections : Map.Map<Principal, LibCalendar.CalendarConnection> = Map.empty();
let pendingCalendarFlows : Map.Map<Principal, LibCalendar.PendingOAuth> = Map.empty();
include MixinCalendarMessaging(calendarConfig, calendarConnections, pendingCalendarFlows);
};motoko
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;
};
};motoko
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, 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, 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);
};
};motoko
import Array "mo:core/Array";
import Map "mo:core/Map";
import Nat64 "mo:core/Nat64";
import Principal "mo:core/Principal";
import Runtime "mo:core/Runtime";
import Text "mo:core/Text";
import OAuth "mo:google-oauth/OAuth";
import { calendar_events_list; calendar_events_insert } "mo:googlecalendar-client/Apis/EventsApi";
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 { 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;
};
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>,
errorMsg : Text,
) : async* ?Text {
if (not (errorMsg.contains(#text("401")) or errorMsg.contains(#text("Unauthorized")))) {
Runtime.trap("Calendar API failed: " # errorMsg);
};
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 = switch (tokens.refreshToken) {
case (?t) t;
case null 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 {};
};
switch (tokens.accessToken) {
case (?token) token;
case null Runtime.trap(operation # " failed: missing access_token");
};
};
public func listUpcomingEvents(
clientId : Text, clientSecret : Text, connection : CalendarConnection,
caller : Principal, calendarConnections : Map.Map<Principal, CalendarConnection>,
timeMin : 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,
"", "", "", true, "", "",
false, [], "", 0, maxResults, #starttime,
"", [], "", [], false, false, true, "",
"", timeMin, "", "",
);
} catch e {
let ?newToken = await* refreshIfNeeded(
clientId, clientSecret, connection, caller, calendarConnections, e.message(),
) else Runtime.trap("Calendar API failed");
await* calendar_events_list(
configForToken(newToken), "primary", #json,
"", "", "", true, "", "",
false, [], "", 0, maxResults, #starttime,
"", [], "", [], false, false, true, "",
"", timeMin, "", "",
);
};
eventSummariesOf(events);
};
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,
"", "", "", true, "", "",
0, 0, true, #all, false, event,
);
} catch e {
let ?newToken = await* refreshIfNeeded(
clientId, clientSecret, connection, caller, calendarConnections, e.message(),
) else Runtime.trap("Calendar API failed");
await* calendar_events_insert(
configForToken(newToken), "primary", #json,
"", "", "", true, "", "",
0, 0, true, #all, false, event,
);
};
switch (created.id) {
case (?id) id;
case null "";
};
};
func eventSummariesOf(events : Events) : EventSummaryList {
let items = switch (events.items) {
case (?items) items;
case null [];
};
Array.map<Event, EventSummary>(items, func(e : Event) : EventSummary = {
id = switch (e.id) { case (?id) id; case null "" };
summary = switch (e.summary) { case (?s) s; case null "(no title)" };
start = switch (e.start) {
case (?dt) switch (dt.dateTime) { case (?t) t; case null switch (dt.date) { case (?d) d; case null "" } };
case null "";
};
end = switch (e.end) {
case (?dt) switch (dt.dateTime) { case (?t) t; case null switch (dt.date) { case (?d) d; case null "" } };
case null "";
};
});
};
};5. Available API surface
5. 可用API范围
google-oauth
(OAuth 2.0 mechanics)
google-oauthgoogle-oauth
(OAuth 2.0机制)
google-oauth| Function | Purpose |
|---|---|
| RFC 3986 percent-encoding for form bodies |
| Parse Google token-endpoint JSON |
| Exchange auth code for tokens |
| Refresh an expired access token |
| Generate PKCE |
| Compute PKCE |
| Build the Google OAuth authorize URL |
| 函数 | 用途 |
|---|---|
| 针对表单主体的RFC 3986百分号编码 |
| 解析Google令牌端点的JSON响应 |
| 将授权码交换为令牌 |
| 刷新过期的access token |
| 生成PKCE的 |
| 计算PKCE的 |
| 构建Google OAuth授权URL |
googlecalendar-client
(Calendar REST API v3)
googlecalendar-clientgooglecalendar-client
(Calendar REST API v3)
googlecalendar-clientThe canonical actor above intentionally implements only upcoming-event listing
and event creation. For another generated operation, keep bearer
authentication and , then apply the same
single-refresh-retry pattern as .
is_replicated = ?falserefreshIfNeededThe generated package also exposes:
| Function | Module | Purpose |
|---|---|---|
| EventsApi | List events on a calendar |
| EventsApi | Get an event by id |
| EventsApi | Create an event |
| EventsApi | Update an event (PUT) |
| EventsApi | Patch an event (PATCH) |
| EventsApi | Delete an event |
| EventsApi | Move an event to another calendar |
| EventsApi | Create event from text ("Lunch at noon") |
| EventsApi | List instances of a recurring event |
| FreebusyApi | Check free/busy across calendars |
| CalendarListApi | List user's calendars |
| CalendarListApi | Get a calendar list entry |
| CalendarsApi | Get calendar metadata |
| CalendarsApi | Create a secondary calendar |
上述标准actor仅实现了列出即将到来的事件和创建事件的功能。如需使用其他生成的操作,请保持Bearer认证和设置,并应用与相同的单次刷新重试模式。
is_replicated = ?falserefreshIfNeeded生成的包还提供以下功能:
| 函数 | 模块 | 用途 |
|---|---|---|
| EventsApi | 列出日历中的事件 |
| EventsApi | 通过ID获取事件 |
| EventsApi | 创建事件 |
| EventsApi | 更新事件(PUT请求) |
| EventsApi | 部分更新事件(PATCH请求) |
| EventsApi | 删除事件 |
| EventsApi | 将事件移动到另一个日历 |
| EventsApi | 通过文本创建事件(如“中午吃午餐”) |
| EventsApi | 列出重复事件的实例 |
| FreebusyApi | 检查多个日历的忙闲状态 |
| CalendarListApi | 列出用户的日历 |
| CalendarListApi | 获取日历列表条目 |
| CalendarsApi | 获取日历元数据 |
| CalendarsApi | 创建次级日历 |
6. Cycles and response sizes
6. Cycles与响应大小
The library uses from , which
auto-computes and attaches the exact required cycles via the
system API. No manual cycle budgeting is needed
for token exchange or refresh calls.
google-oauthCall.httpRequestmo:ic/Callic0.cost_http_requestFor calls,
(30B). A typical list/insert costs ~10–15B cycles. Set
for event list reads that may include
large payloads.
googlecalendar-clientdefaultConfig.cycles = 30_000_000_000max_response_bytes = ?2_000_000google-oauthmo:ic/CallCall.httpRequestic0.cost_http_request对于调用,(300亿)。典型的列表/插入操作约消耗100-150亿cycles。对于可能包含大 payload的事件列表读取,设置。
googlecalendar-clientdefaultConfig.cycles = 30_000_000_000max_response_bytes = ?2_000_0007. Things that will bite you
7. 需要注意的陷阱
- — see §3. Non-negotiable.
is_replicated = ?false - Google refresh tokens do NOT rotate. Unlike X/Twitter, Google does
not issue a new on each refresh. Keep the original
refresh_tokenand only persist the newrefresh_token.access_token - Access tokens expire in 1 hour. The helper catches HTTP 401, silently refreshes via
refreshIfNeeded, and retries once. If the refresh also fails, surface "re-connect your account".google-oauth.refreshAccessToken - Callback URI exact-match. Every character (trailing slash, query
string, port) must match between the authorize URL and the redirect.
Google returns otherwise. Always use
redirect_uri_mismatchforwindow.location.origin + window.location.pathnameand register that exact URI on the Google Web client.redirectUri - RFC 3339 timestamps. Calendar uses RFC 3339 strings
(). For all-day events set
2026-07-10T15:00:00-07:00(EventDateTime.date) instead ofYYYY-MM-DD.dateTime - refers to the authenticated user's default calendar. Named/shared calendars use their calendar-ID (an email-like address).
calendarId = "primary" - HTTP 429 rate-limit. Surface the error to the caller; never silently retry a write inside the canister — a retry may create a duplicate event.
- Don't expose the access token. is read only by
calendarConnectionsinside API calls. NoMap.get(calendarConnections, ..., caller), nogetMyCalendarConnection, no iterator. A leaked bearer is a per-user account compromise.getMyAccessToken - for all Calendar API v3 calls. Leave optional string parameters
alt = #jsonand"".prettyPrint = false - Build /
EventwithEventDateTimethen record-update the fields you need — all fields are optional (init {}); leave the rest null.?T - PATCH/PUT/DELETE are forced non-replicated in the generated client
(the sets
googlecalendar-clienton these methods automatically). For GET/POST, set it explicitly in youris_replicated = ?false.Config
- ——参见第3节。这是不可协商的要求。
is_replicated = ?false - Google刷新令牌不会轮换。与X/Twitter不同,Google在每次刷新时不会颁发新的。保留原始的
refresh_token,仅保存新的refresh_token即可。access_token - Access token有效期为1小时。助手会捕获HTTP 401错误,通过
refreshIfNeeded自动刷新令牌,并重试一次请求。如果刷新也失败,则提示用户“重新连接您的账户”。google-oauth.refreshAccessToken - 回调URI必须完全匹配。授权URL和重定向URI的每个字符(末尾斜杠、查询字符串、端口)都必须完全匹配。否则Google会返回错误。始终使用
redirect_uri_mismatch作为window.location.origin + window.location.pathname,并在Google Web客户端中注册该精确URI。redirectUri - RFC 3339时间戳。Calendar使用RFC 3339格式的字符串(如)。对于全天事件,设置
2026-07-10T15:00:00-07:00(格式为EventDateTime.date)而非YYYY-MM-DD。dateTime - ****指已认证用户的默认日历。命名/共享日历使用其日历ID(类似邮箱地址的格式)。
calendarId = "primary" - HTTP 429速率限制。将错误提示给调用者;绝不要在canister内自动重试写入操作——重试可能会创建重复事件。
- 不要暴露access token。仅在API调用内部通过
calendarConnections读取。不要提供Map.get(calendarConnections, ..., caller)、getMyCalendarConnection或迭代器接口。泄露的Bearer令牌会导致用户账户被入侵。getMyAccessToken - 所有Calendar API v3调用都需设置。将可选字符串参数设为
alt = #json,""。prettyPrint = false - 使用构建
init {}/Event,然后更新所需字段——所有字段都是可选的(EventDateTime类型);其余字段保持null即可。?T - PATCH/PUT/DELETE操作会被强制设置为非复制模式——生成的客户端()会自动为这些方法设置
googlecalendar-client。对于GET/POST请求,请在is_replicated = ?false中显式设置该参数。Config
Frontend
前端
Every build using this skill must ship:
-
A login flow — required. Calendar cannot work without a non-anonymous caller; the per-user OAuth handshake stores tokens keyed by, and the admin credential setter gates on
caller : Principal. The login flow comes from#admin:extension-authorization, login/logout buttons, theuseInternetIdentityplumbing that injects the authenticated identity into every backend call.useActor -
An admin settings page —(admin-gated):
/settings/calendar- Two password-inputs bound to . Submit on enter; clear inputs on success.
setCalendarCredentials(clientId, clientSecret) - Status indicator driven by (returns
isCalendarConfigured()). Show "Configured" / "Not configured" — never display the credentials.Bool - Hide from non-admins via
's
extension-authorizationquery — non-admins should not see the link in the nav.isCallerAdmin
- Two password-inputs bound to
-
A "Connect Calendar" page —(any signed-in user):
/connect/calendar- "Connect Google Calendar" button bound to
. Redirect the browser to the URL returned by the canister. Register this exact URI in the Google Cloud Console first.
startCalendarOAuth(window.location.origin + window.location.pathname) - On the return leg, read ,
error, andcodefromstate. IfURLSearchParamsis present, show the failed/declined connection state and do not call the canister. Only when botherrorandcodeare present, callstate.completeCalendarOAuth(code, state) - After either terminal path, call to remove the OAuth query parameters. This prevents a page refresh from reusing a one-time authorization code.
history.replaceState - Status driven by (returns
isMyCalendarConnected()).Bool - Optional "Disconnect Calendar" button bound to .
disconnectMyCalendar()
- "Connect Google Calendar" button bound to
-
Calendar UI — the main page shows upcoming events. Pass the current time as the RFC 3339value:
timeMin. This is required when usinglistUpcomingEvents(new Date().toISOString(), 10)andsingleEvents = true. Also include a "create event" form.orderBy = startTimevalues have no offset, so convert each browser-local value to an RFC 3339 instant before calling the actor:datetime-local. WhencreateEvent(summary, new Date(startInput).toISOString(), new Date(endInput).toISOString())isisMyCalendarConnected(), render an inline "Connect Google Calendar" link tofalse./connect/calendar
Suggested route layout:
/ → Main UI (upcoming events + create form)
/settings/calendar → Admin credential config (admin-only)
/connect/calendar → Per-user OAuth handshake (any signed-in user)使用本技能的所有构建都必须包含以下内容:
-
登录流程——必填。Calendar无法在匿名调用者的情况下工作;每个用户的OAuth握手流程会将令牌以为键进行存储,而管理员凭据设置器受
caller : Principal角色权限控制。登录流程来自#admin:extension-authorization、登录/登出按钮、useInternetIdentity管道(将已认证身份注入每个后端调用)。useActor -
管理员设置页面——(仅管理员可访问):
/settings/calendar- 两个密码输入框,绑定到方法。按回车键提交;成功后清空输入框。
setCalendarCredentials(clientId, clientSecret) - 由(返回
isCalendarConfigured()值)驱动的状态指示器。显示“已配置”/“未配置”——绝不要显示凭据内容。Bool - 通过的
extension-authorization查询隐藏非管理员用户的访问入口——非管理员用户不应在导航栏中看到该链接。isCallerAdmin
- 两个密码输入框,绑定到
-
“连接日历”页面——(任何已登录用户均可访问):
/connect/calendar- “连接Google Calendar”按钮,绑定到方法。将浏览器重定向到canister返回的URL。请先在Google Cloud Console中注册该精确URI。
startCalendarOAuth(window.location.origin + window.location.pathname) - 在返回流程中,从读取
URLSearchParams、error和code参数。如果存在state,显示连接失败/被拒绝的状态,不要调用canister。仅当error和code都存在时,调用state。completeCalendarOAuth(code, state) - 在完成任一终端流程后,调用移除OAuth查询参数。这可防止页面刷新时重复使用一次性授权码。
history.replaceState - 由(返回
isMyCalendarConnected()值)驱动的状态显示。Bool - 可选的“断开日历连接”按钮,绑定到方法。
disconnectMyCalendar()
- “连接Google Calendar”按钮,绑定到
-
Calendar UI——主页面显示即将到来的事件。将当前时间作为RFC 3339格式的值传入:
timeMin。当使用listUpcomingEvents(new Date().toISOString(), 10)和singleEvents = true时,这是必填项。还需包含“创建事件”表单。orderBy = startTime值没有时区偏移,因此在调用actor之前,需将每个浏览器本地时间转换为RFC 3339格式的时间戳:datetime-local。 当createEvent(summary, new Date(startInput).toISOString(), new Date(endInput).toISOString())为isMyCalendarConnected()时,渲染一个内联的“连接Google Calendar”链接,指向false。/connect/calendar
建议的路由布局:
/ → 主UI(即将到来的事件 + 创建表单)
/settings/calendar → 管理员凭据配置(仅管理员)
/connect/calendar → 每个用户的OAuth握手流程(任何已登录用户)Common to all variants
所有变体的通用规则
- Sign-in is required for every Calendar-related route. Wire the
and
/settings/...routes through/connect/calendar's auth guard (extension-authorization+ redirect whenuseInternetIdentity).!isAuthenticated - The frontend never persists tokens. No , no
localStorage, no cookies — the canister mediates everything. The browser only ever seesIndexedDBstatus flags and the OAuth redirect URLs.Bool - The OAuth parameter is canister-generated and validated. The canister stores a random nonce with the pending verifier and callback URI. The frontend must pass both
stateandcodetostate; it never creates or modifies either value.completeCalendarOAuth - The calendar UI is trivial: a list of upcoming events, a create-event form with summary + start/end datetime inputs. No client-side Google SDK, no token handling, no JSON serialization — the canister is the Calendar client.
- 所有Calendar相关路由都需要登录。将和
/settings/...路由通过/connect/calendar的认证守卫(extension-authorization+ 未认证时重定向)进行处理。useInternetIdentity - 前端绝不存储令牌。不使用、
localStorage或cookie——所有操作都由canister中介。浏览器仅能看到IndexedDB类型的状态标志和OAuth重定向URL。Bool - OAuth的参数由canister生成并验证。canister会将随机数与待处理的验证器和回调URI一起存储。前端必须将
state和code都传递给state;前端绝不能创建或修改这两个值。completeCalendarOAuth - Calendar UI非常简洁:一个即将到来的事件列表,一个包含摘要+开始/结束时间输入框的创建事件表单。无需客户端Google SDK、令牌处理或JSON序列化——canister充当Calendar客户端。
Related
相关资源
- — Calendar REST API v3 bindings.
mops add googlecalendar-client@0.1.3 - — Google OAuth 2.0 library (token exchange, refresh, PKCE).
mops add google-oauth@0.1.4 - Google OAuth 2.0 for Web Server Applications — Web-client redirect URI and authorization-code flow reference.
- Google Calendar API v3 reference — what wraps.
googlecalendar-client - RFC 7636 — Proof Key for Code Exchange — PKCE spec.
- extension-authorization — required prerequisite. Provides Internet Identity login, /
useInternetIdentityfrontend plumbing, and theuseActorrole gate.#admin - connector-googlemail — sister connector using the same library for Gmail.
google-oauth
- ——Calendar REST API v3绑定。
mops add googlecalendar-client@0.1.3 - ——Google OAuth 2.0库(令牌交换、刷新、PKCE)。
mops add google-oauth@0.1.4 - Google OAuth 2.0 for Web Server Applications——Web客户端重定向URI和授权码流程参考文档。
- Google Calendar API v3 reference——封装的API参考。
googlecalendar-client - RFC 7636 — Proof Key for Code Exchange——PKCE规范。
- extension-authorization——必填前提。提供Internet Identity登录、/
useInternetIdentity前端管道,以及useActor角色权限控制。#admin - connector-googlemail——使用相同库的Gmail姐妹连接器。
google-oauth