はじめに
hkob の雑記録の第514回目(連続87日目)は、先日設計した Calendar Webhook の実装の第二部を記録しておきます。先日書いたように現在はこの実装は使っていないのですが、学習記録として掲載しておきます。実装記録は引用の形で、途中で私のコメントを入れます。
第2部: Google Calendar watch / 差分取得 / Tasks upsert
2-0. 前提確認(1回だけ)
- [x] 保存先(永続化)の置き場を決める:
channelId / resourceId / syncToken / expiration
- 保存先DB: Calendar watch state
- 用途:
channelId / resourceId / syncToken / expirationを1行で管理- 作成場所: データベース一覧 配下
最初に永続化用のデータベースを作成しました。シングルトンでページは一つだけ持ちます。
2-1. Google 側の watch を作る(初回)
- [x] OAuth(refresh token)を用意(OAuth Playground 利用 / gcloud 不要)
- Google Cloud Console: https://console.cloud.google.com/ でプロジェクト作成(既存でもOK)
Google Calendar API を有効化(済)
- (参考: 有効化済み画面)
Google Calendar API 有効化済み 「認証情報」→ OAuth クライアント ID(ウェブアプリケーション)を作成し、redirect URI を設定する
- 承認済みのリダイレクト URI:
https://developers.google.com/oauthplaygroundGOOGLE_CLIENT_ID / GOOGLE_CLIENT_SECRETを控える(secret はログ/手順書に貼らない)
- GOOGLE_CLIENT_ID: (秘密)
- OAuth Playground で refresh token を取得
- https://developers.google.com/oauthplayground/
- 右上の歯車 → 「Use your own OAuth credentials」を ON →
GOOGLE_CLIENT_ID / GOOGLE_CLIENT_SECRETを入力- Scope:
https://www.googleapis.com/auth/calendar- Authorize → Exchange →
GOOGLE_REFRESH_TOKENを控える(secret はログ/手順書に貼らない)
- (実施済み)Notion Workers の環境変数
GOOGLE_REFRESH_TOKENに登録- (未実施なら)Notion Workers の環境変数に OAuth 情報を登録
ntn workers env set GOOGLE_CLIENT_ID=...ntn workers env set GOOGLE_CLIENT_SECRET=...(ログに貼らない)
ここは GUI の設定になります。前回、Webhook の受け口を作ったので、OAuth の callback をそこに設定します。このように OAuth を使うアプリの場合には、最初に受けるだけの Webhook を先にデプロイする必要があります。
- [x]
events.listを 1回実行して初期syncTokenを取得・保存
実行ログ:
sh gist.sh nextSyncToken = (値は省略)依存追加:
- [x]
npm i googleapis
実行ログ:
npm i googleapis npm warn deprecated node-domexception@1.0.0: Use your platform's native DOMException instead added 46 packages, and audited 61 packages in 3s 18 packages are looking for funding run `npm fund` for details found 0 vulnerabilities
scripts/gcal-init-sync-token.mjsを作成:
- ※
nextSyncTokenは「全ページ取り切った最後のレスポンス」にしか付かないため、nextPageTokenが無くなるまでページングする。import { google } from "googleapis"; const calendarId = process.env.CALENDAR_ID; if (!calendarId) throw new Error("CALENDAR_ID is required"); if (!process.env.GOOGLE_CLIENT_ID) throw new Error("GOOGLE_CLIENT_ID is required"); if (!process.env.GOOGLE_CLIENT_SECRET) throw new Error("GOOGLE_CLIENT_SECRET is required"); if (!process.env.GOOGLE_REFRESH_TOKEN) throw new Error("GOOGLE_REFRESH_TOKEN is required"); const oauth2 = new google.auth.OAuth2( process.env.GOOGLE_CLIENT_ID, process.env.GOOGLE_CLIENT_SECRET, ); oauth2.setCredentials({ refresh_token: process.env.GOOGLE_REFRESH_TOKEN }); const calendar = google.calendar({ version: "v3", auth: oauth2 }); let pageToken = undefined; let last; for (;;) { const res = await calendar.events.list({ calendarId, maxResults: 2500, singleEvents: true, showDeleted: false, pageToken, }); last = res; pageToken = res.data.nextPageToken ?? undefined; if (!pageToken) break; } console.log("nextSyncToken =", last?.data?.nextSyncToken);実行(例):
- 注意:
ntn workers env set ...で登録した環境変数は Worker 実行時 にのみ有効で、ローカルのnode実行には引き継がれない。ローカルで
node scripts/gcal-init-sync-token.mjsを動かす場合は、ローカル側にもGOOGLE_CLIENT_ID / GOOGLE_CLIENT_SECRET / GOOGLE_REFRESH_TOKEN / CALENDAR_IDを環境変数として渡す。
- 例(その場で一時的に渡す。値はログに貼らない):
CALENDAR_ID=".............@group.calendar.google.com" \ GOOGLE_CLIENT_ID="..." \ GOOGLE_CLIENT_SECRET="..." \ GOOGLE_REFRESH_TOKEN="..." \ node scripts/gcal-init-sync-token.mjs
- 例(ローカルのシェルに export してから実行):
export CALENDAR_ID="..." export GOOGLE_CLIENT_ID="..." export GOOGLE_CLIENT_SECRET="..." export GOOGLE_REFRESH_TOKEN="..." node scripts/gcal-init-sync-token.mjs出力例:
nextSyncToken = <token>- 取得した
nextSyncTokenを控えて、次のevents.watch実行時に(必要なら)DB(Calendar watch state)へ保存する
まず、Calendar API から nextSyncToken が取得できるかを確認しました。
- [x]
events.watchを呼び出し、以下を保存
実行ログ:
sh gw.sh watch response = { channelId: 'calendar-webhook-001', resourceId: '5AfudG0gnsARmhvyUsz64yBa-6k', expiration: '1780914534000' }事前に保存先DB(Calendar watch state)に「初期 syncToken」を入れておく
- 事前に
channelId(自前で生成した ID)を決める(例:calendar-webhook-001)
scripts/gcal-watch.mjsを作成:import { google } from "googleapis"; const calendarId = process.env.CALENDAR_ID; const webhookUrl = process.env.WEBHOOK_URL; // Notion Workers の onCalendarNotification URL const channelId = process.env.GCAL_CHANNEL_ID; if (!calendarId) throw new Error("CALENDAR_ID is required"); if (!webhookUrl) throw new Error("WEBHOOK_URL is required"); if (!channelId) throw new Error("GCAL_CHANNEL_ID is required"); if (!process.env.GOOGLE_CLIENT_ID) throw new Error("GOOGLE_CLIENT_ID is required"); if (!process.env.GOOGLE_CLIENT_SECRET) throw new Error("GOOGLE_CLIENT_SECRET is required"); if (!process.env.GOOGLE_REFRESH_TOKEN) throw new Error("GOOGLE_REFRESH_TOKEN is required"); const oauth2 = new google.auth.OAuth2( process.env.GOOGLE_CLIENT_ID, process.env.GOOGLE_CLIENT_SECRET, ); oauth2.setCredentials({ refresh_token: process.env.GOOGLE_REFRESH_TOKEN }); const calendar = google.calendar({ version: "v3", auth: oauth2 }); const res = await calendar.events.watch({ calendarId, requestBody: { id: channelId, type: "web_hook", address: webhookUrl, // params: { ttl: "86400" }, // 任意(秒)。未指定でも OK }, }); console.log("watch response =", { channelId: res.data.id, resourceId: res.data.resourceId, expiration: res.data.expiration, // ms epoch });実行(例):
- 値はログに貼らない(
...のままにする)GCAL_CHANNEL_ID="calendar-webhook-001" \ CALENDAR_ID="..." \ WEBHOOK_URL="https://www.notion.so/webhooks/worker/......./onCalendarNotification" \ GOOGLE_CLIENT_ID="..." \ GOOGLE_CLIENT_SECRET="..." \ GOOGLE_REFRESH_TOKEN="..." \ node scripts/gcal-watch.mjs[x]
channelId(自前で生成した ID):calendar-webhook-001- [x]
resourceId(Google が返す):5AfudG0gnsARmhvyUsz64yBa-6k- [x]
expiration(Google が返す; ms epoch):1780914534000- [x]
syncToken(差分取得用)
- (前手順で取得済みの
nextSyncTokenを保存先DB(Calendar watch state)の1行へ保存)
状況データベース
現在の状態を状態データベースに登録するために、必要な値を取得しました。その値をデータベースに貼り付けています。
- [x] Webhook 受信が届くことを確認(テストでイベント作成)
実行ログ:
ntn workers runs logs 019e82c0-0709-7a68-9711-cbb062f14036 [gcal] push { channelId: 'calendar-webhook-001', resourceId: '............', resourceState: 'exists', resourceUri: 'https://www.googleapis.com/calendar/v3/calendars/.........%40group.calendar.google.com/events?alt=json', messageNumber: '347042' } <notion_output>{"_tag":"success","value":{"status":"success"}}</notion_output>
event.watch を設定したので、カレンダーを変更すると Webhook が呼ばれるようになりました。実際に runs で動作していることが確認できました。
2-2. Webhook 受信処理(通知→差分 pull)
watch で来る情報にはほとんど必要なものが入っていないので、それをトリガとして Webhook 側から必要な情報を受け取りに行きます。そのための設定を行います。
- [x] 事前準備: env を追加
- [x]
WATCH_STATE_PAGE_ID(watch state の行ページID)
- 値:
3720ce2a65208066b67ae518271609f2- 設定コマンド:
ntn workers env set WATCH_STATE_PAGE_ID=3720ce2a65208066b67ae518271609f2実行ログ:
ntn workers env set WATCH_STATE_PAGE_ID=3720ce2a65208066b67ae518271609f2 ✔ Set environment variable 'WATCH_STATE_PAGE_ID’
先ほどの状態データベースの page_id を設定します。シングルトンなので、常にこのページになります。
- [ ]
src/index.tsを作成(2-2: 差分 pull まで)
- 方針:
- watch state は固定の 1 ページ(行)として扱い、
pages.retrieve/updateで読む・書くquerySqlは使わない(Workers のcontext.notionには無い)
src/index.ts(2-2 版; Tasks upsert はまだしない):import { CapabilityContext, WebhookEvent, Worker } from "@notionhq/workers"; import { google } from "googleapis"; const worker = new Worker(); export default worker; type HeaderMap = Record<string, string | undefined>; function pickGCalHeaders(headers: HeaderMap) { const get = (k: string) => headers[k] ?? headers[k.toLowerCase()]; return { channelId: get("X-Goog-Channel-Id"), resourceId: get("X-Goog-Resource-Id"), resourceState: get("X-Goog-Resource-State"), resourceUri: get("X-Goog-Resource-Uri"), messageNumber: get("X-Goog-Message-Number"), }; } function mustEnv(name: string) { const v = process.env[name]; if (!v) throw new Error(`${name} is required`); return v; } function getCalendarClient() { const oauth2 = new google.auth.OAuth2( mustEnv("GOOGLE_CLIENT_ID"), mustEnv("GOOGLE_CLIENT_SECRET"), ); oauth2.setCredentials({ refresh_token: mustEnv("GOOGLE_REFRESH_TOKEN") }); return google.calendar({ version: "v3", auth: oauth2 }); } type WatchState = { pageId: string; calendarId: string; channelId: string; resourceId: string; syncToken: string; }; function joinPlainText(arr: any[] | undefined) { return (arr ?? []).map((t) => t?.plain_text ?? "").join(""); } function getRichText(props: any, name: string) { return joinPlainText(props?.[name]?.rich_text); } async function loadWatchState(context: CapabilityContext): Promise<WatchState> { const notion = (context as any).notion; const pageId = mustEnv("WATCH_STATE_PAGE_ID"); const page = await notion.pages.retrieve({ page_id: pageId }); const props = page?.properties ?? {}; const calendarId = getRichText(props, "Calendar ID"); const channelId = getRichText(props, "channelId"); const resourceId = getRichText(props, "resourceId"); const syncToken = getRichText(props, "syncToken"); if (!calendarId) throw new Error("Calendar ID is empty in watch state"); if (!channelId) throw new Error("channelId is empty in watch state"); if (!resourceId) throw new Error("resourceId is empty in watch state"); if (!syncToken) throw new Error("syncToken is empty in watch state"); return { pageId, calendarId, channelId, resourceId, syncToken }; } async function saveSyncToken( context: CapabilityContext, pageId: string, syncToken: string, ) { const notion = (context as any).notion; await notion.pages.update({ page_id: pageId, properties: { syncToken: { rich_text: [{ text: { content: syncToken } }] }, Status: { status: { name: "Watching" } }, }, }); } worker.webhook("onCalendarNotification", { title: "Google Calendar push notification → sync Tasks", description: "Receives Google Calendar push notifications and triggers a pull-based sync.", execute: async (events: WebhookEvent[], context: CapabilityContext) => { const calendar = getCalendarClient(); const state = await loadWatchState(context); for (const ev of events) { const headers = (ev?.headers ?? {}) as HeaderMap; const h = pickGCalHeaders(headers); console.log("[gcal] push", h); // ホワイトリスト: channelId/resourceId が一致する通知だけ処理する if (!h.channelId || !h.resourceId) continue; if (h.channelId !== state.channelId || h.resourceId !== state.resourceId) { console.log("[gcal] skip: unknown channel/resource"); continue; } const res = await calendar.events.list({ calendarId: state.calendarId, singleEvents: true, showDeleted: true, maxResults: 2500, syncToken: state.syncToken, }); const items = res.data.items ?? []; console.log("[gcal] diff items =", items.length); const nextSyncToken = res.data.nextSyncToken; if (nextSyncToken) { await saveSyncToken(context, state.pageId, nextSyncToken); console.log("[gcal] syncToken updated"); } else { console.log("[gcal] nextSyncToken missing (no update)"); } } }, });
pull するまでのソースが記載されています。
- [x] deploy
ntn workers deploy実行ログ:
ntn workers deploy Deploying to workspace: hkob's ambassador workspace [build:out] Running npm install... [build:err] npm warn deprecated node-domexception@1.0.0: Use your platform's native DOMException instead [build:out] added 59 packages, and audited 60 packages in 3s [build:out] 18 packages are looking for funding [build:out] run `npm fund` for details [build:out] found 0 vulnerabilities [build:out] Running npm run build... [build:out] > @notionhq/workers-template@0.0.0 build [build:out] > tsc [build:out] Running npm install --omit=dev... [build:out] up to date, audited 54 packages in 368ms [build:out] 18 packages are looking for funding [build:out] run `npm fund` for details [build:out] found 0 vulnerabilities [build:out] Moving package.json... [build:out] Moving package-lock.json... [build:out] Moving node_modules... [build:out] Creating bundle from dist... [build:out] Uploading bundle... [build:out] Bundle upload complete. [build:out] Preparing runtime filesystem for snapshot... ✔ Worker updated Webhooks: onCalendarNotification Webhook URLs: → https://www.notion.so/webhooks/worker/...../onCalendarNotification (onCalendarNotification) To list webhook URLs later, run: ntn workers webhooks list
デプロイしました。
- [x] 動作確認(再テスト)
実行ログ:
[gcal] push { channelId: 'calendar-webhook-001', resourceId: '5AfudG0gnsARmhvyUsz64yBa-6k', resourceState: 'exists', resourceUri: 'https://www.googleapis.com/calendar/v3/calendars/........%40group.calendar.google.com/events?alt=json', messageNumber: '3827695' } [gcal] diff items = 1 [gcal] syncToken updated <notion_output>{"_tag":"success","value":{"status":"success"}}</notion_output>確認事項:
- [x] watch state ページの
syncTokenが更新され、StatusがWatchingのままになっていること
log を確認したところ、success になっていることを確認しました。
- [ ] トラブルシュート:
Error: deleted_client(Google OAuth)
発生ログ(抜粋):
ntn workers runs logs 019e82eb-c09a-715f-8446-6991dcf2d142 [gcal] push { ... } ExecutionError: Error during worker execution: Error: deleted_client cause: GaxiosError: deleted_client at OAuth2Client.refreshTokenNoCache (...)原因: refresh token が紐づく OAuth クライアント(CLIENT_ID)が 削除済み / 無効 / プロジェクト変更 などで使えなくなっている
- 対応(結論: token を作り直す)
- Google Cloud Console → APIs & Services → Credentials で、対象の OAuth Client(Web application)が存在することを確認
- OAuth Playground で その CLIENT_ID/CLIENT_SECRET を指定して refresh token を再発行
- redirect URI は
https://developers.google.com/oauthplaygroundを承認済みにしておく- Workers の env を更新
GOOGLE_CLIENT_ID/GOOGLE_CLIENT_SECRET/GOOGLE_REFRESH_TOKEN
- secret はログに貼らない
ntn workers deployして再テスト
その後、deleted_client となってしまいました。ここに書いてあるように OAuth の認証が切れてしまったようです。原因は不明ですが、ヘッドレスシステムにおける OAuth 認証のやり直しは面倒ですね(これが断念した一つの原因でした)。
2-3. イベント→Tasks への upsert(最小プロパティ)
- [ ] 対象イベントの判定(最小)
- [ ]
recurringEventId等があるもの(繰り返し)は一旦無視- [ ]
status=cancelledは Tasks を archive(Notion 側でアーカイブ)- [ ]
External ID = gcal:<calendarId>:event:<eventId>を作る- [ ] Tasks を External ID で検索(
dataSources.query)
- [ ] 無ければ作成(Task name / External ID / Date / Link)
- [ ] あれば更新(同じ 4項目のみ)
- [ ]
src/index.tsを拡張(2-3: Tasks upsert まで)
- 前提(env):
TASKS_DATA_SOURCE_ID(設定済み)CALENDAR_ID(設定済み)- 方針:
- 2-2 で取得した
itemsをループし、Notion Tasks を upsert- まずは「最低限の 4 プロパティ」だけを更新する(Task name / External ID / Date / Link)
src/index.ts(2-3 版; 2-2 の続きとして追記する):type GcalEvent = { id?: string; status?: string; htmlLink?: string; summary?: string; start?: { date?: string; dateTime?: string }; end?: { date?: string; dateTime?: string }; recurringEventId?: string; }; function toTaskDateStart(ev: GcalEvent): string | null { // まずは start を優先。dateTime があれば dateTime、なければ date(終日) return ev.start?.dateTime ?? ev.start?.date ?? null; } function toTaskDateEnd(ev: GcalEvent): string | null { // end があれば end を使う。dateTime があれば dateTime、なければ date(終日) // Google Calendar の end.date は「翌日 00:00」相当になることがある点に注意(終日イベント) return ev.end?.dateTime ?? ev.end?.date ?? null; } function gcalExternalId(calendarId: string, eventId: string) { return `gcal:${calendarId}:event:${eventId}`; } async function findTaskByExternalId( context: CapabilityContext, externalId: string, ) { const notion = (context as any).notion; const dataSourceId = mustEnv("TASKS_DATA_SOURCE_ID"); const res = await notion.dataSources.query({ data_source_id: dataSourceId, filter: { property: "External ID", rich_text: { equals: externalId } }, page_size: 1, }); return res.results?.[0] ?? null; } async function upsertTaskFromEvent( context: CapabilityContext, ev: GcalEvent, calendarId: string, ) { const notion = (context as any).notion; const dataSourceId = mustEnv("TASKS_DATA_SOURCE_ID"); const eventId = (ev.id ?? "").trim(); if (!eventId) return; // 繰り返しは一旦スキップ(後で対応) if (ev.recurringEventId) { console.log("[gcal] skip recurring", { eventId, recurringEventId: ev.recurringEventId }); return; } const externalId = gcalExternalId(calendarId, eventId); const taskName = (ev.summary ?? "").trim() || `Calendar event ${eventId}`; const link = (ev.htmlLink ?? "").trim(); const dateStart = toTaskDateStart(ev); const dateEnd = toTaskDateEnd(ev); const existing = await findTaskByExternalId(context, externalId); // cancelled → archive if ((ev.status ?? "") === "cancelled") { if (existing) { await notion.pages.update({ page_id: existing.id, archived: true, }); console.log("[gcal] archived task", { externalId }); } return; } const properties: Record<string, any> = { "Task name": { title: [{ text: { content: taskName } }] }, "External ID": { rich_text: [{ text: { content: externalId } }] }, ...(link ? { Link: { url: link } } : {}), ...(dateStart ? { Date: { date: { start: dateStart, end: dateEnd ?? undefined } } } : {}), }; if (!existing) { await notion.pages.create({ parent: { data_source_id: dataSourceId }, properties, }); console.log("[gcal] created task", { externalId }); } else { await notion.pages.update({ page_id: existing.id, properties, }); console.log("[gcal] updated task", { externalId }); } }2-2 の
events.listの直後(itemsが取れた後)に追記する処理:for (const item of items as any[]) { await upsertTaskFromEvent(context, item as any, state.calendarId); }
あとは Tasks の追加・更新処理となります。ここはもう moodle_webhooks と変わらないので、あっという間に終わりました。
- [x] deploy + 動作確認(Tasks が作成されること)
実行ログ:
ntn workers runs logs 019e82fc-ab97-7c94-a27b-2a3ee25f3eb6 [gcal] push { channelId: 'calendar-webhook-001', resourceId: '..............', resourceState: 'exists', resourceUri: 'https://www.googleapis.com/calendar/v3/calendars/........%40group.calendar.google.com/events?alt=json', messageNumber: '4321538' } [gcal] diff items = 1 [gcal] created task { externalId: 'gcal:..........@group.calendar.google.com:event:70a395119bcb42ada2970a6ad4ceb268' } [gcal] syncToken updated <notion_output>{"_tag":"success","value":{"status":"success"}}</notion_output>
デプロイしました。カレンダーの作成でタスクも作成され、日付などを更新するとタスクの日付も変更されました。
2-4. 再 watch(期限更新)
- [ ] 方針
- Google Calendar の watch は期限(
expiration)があるので、期限前に更新する- 更新時は
events.watchを呼び直す(resourceIdとexpirationが更新される)- 既存 channel の停止は任意(ここでは「先に stop してから watch」を推奨)
syncTokenは維持(watch を張り直しても差分取得の基準は変えない)
今回問題になるのは、event.watch の有効期限でした。意外とこの処理が面倒そうです。
- [ ] (推奨)定期実行で自動更新する(外部スケジューラ + worker.tool)
- 状況整理:
@notionhq/workersのWorkerAPI にはworker.recurrence(...)は存在しない(TypeScript でエラーになる)- 現状「worker の中だけで cron 的な定期実行」を完結させる方法は無い
- 目的: カレンダー更新が無い期間でも、watch を自動で更新して失効を防ぐ
- 方針:
- Worker に watch 更新専用の tool(例:
renewWatch)を追加- 外部スケジューラ(cron / GitHub Actions / launchd 等)で 1日1回
ntn workers exec renewWatchを実行する- tool の中では watch state ページから
calendarId/channelId/resourceId/expirationを読み、必要なら更新する
src/index.ts追加分(watch state ページから読んで更新する tool):import { j } from "@notionhq/workers/schema-builder"; // ===== 2-4: Auto renew watch (tool) ===== function getNumber(props: any, name: string): number | null { const n = props?.[name]?.number; return typeof n === "number" ? n : null; } type WatchStateWithExpiration = WatchState & { expirationMs: number; }; async function loadWatchStateWithExpiration( context: CapabilityContext, ): Promise<WatchStateWithExpiration> { const notion = (context as any).notion; const pageId = mustEnv("WATCH_STATE_PAGE_ID"); const page = await notion.pages.retrieve({ page_id: pageId }); const props = page?.properties ?? {}; const calendarId = getRichText(props, "Calendar ID"); const channelId = getRichText(props, "channelId"); const resourceId = getRichText(props, "resourceId"); const syncToken = getRichText(props, "syncToken"); const expirationMs = getNumber(props, "expiration"); // Number (ms epoch) if (!calendarId) throw new Error("Calendar ID is empty in watch state"); if (!channelId) throw new Error("channelId is empty in watch state"); if (!resourceId) throw new Error("resourceId is empty in watch state"); if (!syncToken) throw new Error("syncToken is empty in watch state"); if (!expirationMs) throw new Error("expiration is empty in watch state"); return { pageId, calendarId, channelId, resourceId, syncToken, expirationMs }; } async function saveWatchRenewed( context: CapabilityContext, pageId: string, resourceId: string, expirationMs: number, ) { const notion = (context as any).notion; await notion.pages.update({ page_id: pageId, properties: { resourceId: { rich_text: [{ text: { content: resourceId } }] }, expiration: { number: expirationMs }, Status: { status: { name: "Watching" } }, }, }); } async function stopChannel( calendar: ReturnType<typeof getCalendarClient>, channelId: string, resourceId: string, ) { await calendar.channels.stop({ requestBody: { id: channelId, resourceId, }, }); } async function renewWatchIfNeeded(context: CapabilityContext) { const calendar = getCalendarClient(); const state = await loadWatchStateWithExpiration(context); const now = Date.now(); const renewBeforeMs = 24 * 60 * 60 * 1000; if (state.expirationMs - now > renewBeforeMs) { return { renewed: false, expiresInMs: state.expirationMs - now, }; } // 先に stop(推奨) await stopChannel(calendar, state.channelId, state.resourceId); // 張り直し(resourceId/expiration が更新される) const res = await calendar.events.watch({ calendarId: state.calendarId, requestBody: { id: state.channelId, type: "web_hook", address: mustEnv("WEBHOOK_URL"), // onCalendarNotification の URL }, }); const newResourceId = res.data.resourceId; const newExpiration = res.data.expiration; if (!newResourceId) throw new Error("events.watch did not return resourceId"); if (!newExpiration) throw new Error("events.watch did not return expiration"); await saveWatchRenewed(context, state.pageId, newResourceId, Number(newExpiration)); return { renewed: true, resourceId: newResourceId, expiration: String(newExpiration), }; } worker.tool("renewWatch", { title: "Renew Google Calendar watch if needed", description: "Renew Google Calendar push notification watch if expiration is within 24 hours.", schema: j.object({}), // no args hints: { readOnlyHint: false }, execute: async (_args: {}, context: CapabilityContext) => { return await renewWatchIfNeeded(context); }, });追加 env:
WEBHOOK_URL(onCalendarNotificationの URL)
ntn workers env set WEBHOOK_URL=https://www.notion.so/webhooks/worker/..../onCalendarNotification- スケジューラ例(macOS / cron):
- 毎日 06:00 に実行:
0 6 * * * cd ~/Dropbox/workers/calendar-webhook && ntn workers exec renewWatch- 動作確認:
- watch state の
expirationを「現在時刻 + 数分」に手動で書き換えて、ntn workers exec renewWatchを実行- watch state の
resourceId/expirationが更新されること- 動作確認:
- watch state の
expirationを「現在時刻 + 数分」に手動で書き換えて、定期実行を手動実行(もしくは次回実行を待つ)- 実行ログに「renewed watch」相当が出て、watch state の
resourceId/expirationが更新されること- 注意:
- 実行頻度は 1日1回で十分(ただし余裕を見て 12時間ごと等も可)
- watch 更新に失敗した場合のために、watch state に
Last renew error等を書き込むと運用が楽になる
これを忘れないようにするには、別のマシンで cron などで定期実行などをする必要があるとのことです。
- [ ] watch を停止(推奨):
channels.stop
- 目的: 期限前に古い channel を明示的に閉じる
scripts/gcal-stop-watch.mjsを作成:import { google } from "googleapis"; const calendarId = process.env.CALENDAR_ID; const channelId = process.env.GCAL_CHANNEL_ID; const resourceId = process.env.GCAL_RESOURCE_ID; if (!calendarId) throw new Error("CALENDAR_ID is required"); if (!channelId) throw new Error("GCAL_CHANNEL_ID is required"); if (!resourceId) throw new Error("GCAL_RESOURCE_ID is required"); if (!process.env.GOOGLE_CLIENT_ID) throw new Error("GOOGLE_CLIENT_ID is required"); if (!process.env.GOOGLE_CLIENT_SECRET) throw new Error("GOOGLE_CLIENT_SECRET is required"); if (!process.env.GOOGLE_REFRESH_TOKEN) throw new Error("GOOGLE_REFRESH_TOKEN is required"); const oauth2 = new google.auth.OAuth2( process.env.GOOGLE_CLIENT_ID, process.env.GOOGLE_CLIENT_SECRET, ); oauth2.setCredentials({ refresh_token: process.env.GOOGLE_REFRESH_TOKEN }); const calendar = google.calendar({ version: "v3", auth: oauth2 }); await calendar.channels.stop({ requestBody: { id: channelId, resourceId, }, }); console.log("stopped", { channelId, resourceId });実行(例):
GCAL_CHANNEL_ID="calendar-webhook-001" \ GCAL_RESOURCE_ID="..." \ CALENDAR_ID="..." \ GOOGLE_CLIENT_ID="..." \ GOOGLE_CLIENT_SECRET="..." \ GOOGLE_REFRESH_TOKEN="..." \ node scripts/gcal-stop-watch.mjs
watch を停止してから、チャンネルを閉じる作業などが必要とのことです。こんな感じで本体よりも別の部分で動くものができてしまい余計な運用コストがかかりそうです。
ここまで作成してみたが…
2-4 の前までは簡単でしたが、それ以外の運用部分で面倒なスクリプト運用が必要となるようで面倒だと感じたところでした。そこで、こんな提案をしてみました。

ちなみにその時のブログはこちらです。2021年ですね。この時にはいろいろと不具合もありましたが、今回の冪等性のための仕組みを使えば、改善するはずです。これをベースにまた設計書を作成し、実装していくことにしました。
おわりに
せっかくここまで記録してきましたが、こちらについては停止し、workspace 自体も削除しました。次は GAS 版の設計書作成と実装記述を進めていきます。
https://hkob.notion.site/hkob-16dd8e4e98ab807cbe3cf3cc94cdfe0f?pvs=4hkob.notion.site

