Calendar Webhook の実装(2) : hkob の雑記録 (514)

はじめに

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 不要)
    1. Google Cloud Console: https://console.cloud.google.com/ でプロジェクト作成(既存でもOK)
    2. Google Calendar API を有効化(済)

      • (参考: 有効化済み画面)
        Google Calendar API 有効化済み
    3. 「認証情報」→ OAuth クライアント ID(ウェブアプリケーション)を作成し、redirect URI を設定する

      • 承認済みのリダイレクト URI: https://developers.google.com/oauthplayground
      • GOOGLE_CLIENT_ID / GOOGLE_CLIENT_SECRET を控える(secret はログ/手順書に貼らない)
        • GOOGLE_CLIENT_ID: (秘密)
    4. 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 が更新され、StatusWatching のままになっていること

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 を作り直す)
      1. Google Cloud Console → APIs & Services → Credentials で、対象の OAuth Client(Web application)が存在することを確認
      2. OAuth Playground で その CLIENT_ID/CLIENT_SECRET を指定して refresh token を再発行
        • redirect URI は https://developers.google.com/oauthplayground を承認済みにしておく
      3. Workers の env を更新
        • GOOGLE_CLIENT_ID / GOOGLE_CLIENT_SECRET / GOOGLE_REFRESH_TOKEN
          • secret はログに貼らない
      4. 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 を呼び直す(resourceIdexpiration が更新される)
    • 既存 channel の停止は任意(ここでは「先に stop してから watch」を推奨)
    • syncToken は維持(watch を張り直しても差分取得の基準は変えない)

今回問題になるのは、event.watch の有効期限でした。意外とこの処理が面倒そうです。

  • [ ] (推奨)定期実行で自動更新する(外部スケジューラ + worker.tool)
    • 状況整理:
      • @notionhq/workersWorker API には 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_URLonCalendarNotification の 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 の前までは簡単でしたが、それ以外の運用部分で面倒なスクリプト運用が必要となるようで面倒だと感じたところでした。そこで、こんな提案をしてみました。

GASの提案

ちなみにその時のブログはこちらです。2021年ですね。この時にはいろいろと不具合もありましたが、今回の冪等性のための仕組みを使えば、改善するはずです。これをベースにまた設計書を作成し、実装していくことにしました。

hkob.hatenablog.com

おわりに

せっかくここまで記録してきましたが、こちらについては停止し、workspace 自体も削除しました。次は GAS 版の設計書作成と実装記述を進めていきます。

https://hkob.notion.site/hkob-16dd8e4e98ab807cbe3cf3cc94cdfe0f?pvs=4hkob.notion.site