🔔

Notion Webhookで実現するNotion x DiscordのChatOps

に公開

はじめに

Notionのデータベースはチームでのタスク管理やプロジェクト管理に便利ですが、これまでデータベースの変更通知はSlackにしか対応していませんでした。Slack を採用しているチームではこの機能を活用してCSチームから来た問い合わせのステータスなどをSlackに通知するなどの ChatOps を実現しており、Discordを採用しているチームにとっては不便な状況が続いていました。

そんな中、つい最近NotionにWebhookオートメーションが追加されました!

https://x.com/NotionJP/status/1864201607698894850

これを活用すればSlackと同様のことが実現できるのではないかと考え、Cloudflare WorkersとHonoを活用してDiscord Botを作成してみました。

https://github.com/nakanoasaservice/notion-to-discord-bot

システム構成

システムの基本的な流れは以下の通りです:

ユーザーは Notion データベースの Webhook 設定に、Discord のチャネル ID を含む URL を設定します。データベースが更新されると、Cloudflare Workers がイベントを受け取り、指定されたDiscord チャネル にメッセージを投稿します。

使い方

1. Discord Botの追加

以下のURLからBotをDiscordサーバーに追加します:

https://discord.com/oauth2/authorize?client_id=1314524073170042962&permissions=2048&integration_type=0&scope=bot

必要な権限は「メッセージの送信」のみです。

2. Discord チャネルIDの取得

Discordの設定で開発者モードを有効にします

ユーザー設定 > 詳細設定 > 開発者モード をONにします

通知を送信したいチャネルを右クリックし、「IDをコピー」を選択します

3. Notion Webhookの設定

Notionの通知を設定したいデータベースのオートメーション設定にて、以下の形式でWebhook URLを設定します:

https://notion-to-discord-bot.yoshinani.workers.dev/{{DiscordチャネルID}}?title={{タイトル(オプション)}}
  • {{DiscordチャネルID}}: 先ほどコピーしたチャネルIDを入力
  • {{タイトル (オプション)}}: メッセージの先頭に表示されるタイトル。なくてもよい。

工夫したポイント

Discord Bot として実装

Discord Webhook ではなく Bot として実装することを選択しました。Discord Webhook を使用する場合、チャネルごとに Webhook URL を発行する必要があり非常に手間がかかります。

Bot として実装することで、URL に直接 Discord のチャネル ID を指定できるため、非エンジニアでも比較的容易に設定できます。

Cloudflare Workers の活用

Cloudflare Workers を選択した理由は以下の通りです:

優れたコストパフォーマンス

  • 1日あたり10万件のリクエストまで無料で利用可能
  • 多くのユースケースでは無料枠で十分にカバー可能

圧倒的な起動速度

  • HTTPSのハンドシェイク中にコールドスタートを完了
  • 実質的にコールドスタートの影響を受けないため、常に高速なレスポンスを実現

シンプルなデプロイプロセス

wrangler deploy

このコマンド1つでデプロイが完了します。複雑な設定や手順は不要です。

容易なオブザーバビリティの実現

wrangler.tomlに以下の設定を追加するだけで、Cloudflareダッシュボード上でログを閲覧できます:

[observability]
enabled = true

低レイテンシー

wrangler.tomlに以下の設定を追加することで、Notionのサーバーに最も近いリージョンにコードが自動的にデプロイされ、低レイテンシーでの通信が可能になります。

[placement]
mode = "smart"

Hono フレームワークの採用

HonoはWeb標準のAPIに根ざしており、Cloudflare Workerに限らず多くのプラットフォーム上で動作します。なので一度Honoの使い方を覚えてしまえばその知識はなかなか腐りません。みなさんも簡単なAPIを作るときは胸を張って何も考えずにHonoを選択しましょう。

Discord公式のSDKから型情報だけを活用

Cloudflare Workers上ではNode.jsのAPIは(一部しか)利用できませんが、DiscordのSDKはNode.jsのAPIに多く依存しています。今回の場合呼び出すAPIは1つだけなので、Fetch APIを用いてシンプルに実装しました。一方で型安全性を保つために、Discord公式が提供する型情報だけを用いてリクエストの型を定義しました。

https://www.npmjs.com/package/discord-api-types

import type { RESTPostAPIChannelMessageJSONBody } from "discord-api-types/v10";

async function sendDiscordMessage(
    token: string,
    channelId: string,
    message: RESTPostAPIChannelMessageJSONBody,
) {
    const response = await fetch(
        `https://discord.com/api/v10/channels/${channelId}/messages`,
        {
            method: "POST",
            headers: {
                Authorization: `Bot ${token}`,
                "Content-Type": "application/json",
            },
            body: JSON.stringify(message),
        },
    );
    ...
}

Notion公式のSDKからプロパティの型情報を活用

Notionのデータベースプロパティは非常に多様で複雑です。以下のような課題がありました:

  • 20種類以上の異なるプロパティタイプが存在
  • 各プロパティタイプごとに異なるデータ構造
  • プロパティタイプによって必須/オプショナルの項目が異なる
  • 各プロパティの中にさらにネストされたデータが存在

例えば、以下のようなプロパティの違いに対応する必要があります:

// タイトルプロパティの例
{
  type: "title",
  title: [
    {
      type: "text",
      text: { content: "プロジェクトA" },
      plain_text: "プロジェクトA"
    }
  ]
}

// 選択肢プロパティの例
{
  type: "select",
  select: {
    name: "進行中",
    color: "green"
  }
}

// 日付プロパティの例
{
  type: "date",
  date: {
    start: "2024-01-01",
    end: "2024-12-31",
    time_zone: null
  }
}

Notion SDKの型情報を活用した解決策

この課題に対して、Notion公式SDKの型定義を活用することで、型安全かつ保守性の高い実装を実現しました:

import type { PageObjectResponse } from "@notionhq/client/build/src/api-endpoints";

type RemoveId<T> = T extends unknown ? Omit<T, "id"> : never;
type Property = PageObjectResponse["properties"][number];

function formatProperty(property: RemoveId<Property>): string {
  switch (property.type) {
    case "title":
      return property.title.map((title) => title.plain_text).join("") || "[Empty Title]";

    case "select":
      return property.select?.name ?? "[No Selection]";

    case "date":
      if (!property.date) return "[No Date]";
      return property.date.end
        ? `${property.date.start} - ${property.date.end}`
        : (property.date.start ?? "[Invalid Date]");

    case "multi_select":
      return property.multi_select?.map((select) => select.name).join(", ") || "[No Selections]";

    case "people":
      return property.people
        .map((person) => isUserObjectResponse(person) ? (person.name ?? person.id) : person.id)
        .join(", ") || "[No People]";

    // 他のプロパティタイプも同様に処理
  }
}

この実装には以下のような利点があります:

  • 型安全性の確保

    • プロパティタイプに応じた正しいデータ構造のアクセスを型システムが保証
    • タイプミスや存在しないプロパティへのアクセスを防止
  • IDE補完の活用

    • 各プロパティタイプで利用可能なフィールドをIDEが提案
    • GitHub Copilotが型情報を基に適切なコード補完を提供
  • 保守性の向上

    • Notionの型定義が更新されると、自動的に変更が必要な箇所を特定可能
    • 新しいプロパティタイプの追加時も、型エラーによって修正が必要な箇所を把握可能
  • null/undefinedの適切な処理

    • オプショナルな値の存在チェックを型システムが強制
    • フォールバック値の設定漏れを防止

この実装により、Notionのデータベースプロパティを安全かつ効率的に文字列化できるようになり、Discordへの通知メッセージを適切に生成できています。

セキュリティに関する注意点

現在の実装では、Webhook URLにDiscordチャネルIDを直接指定する方式を採用しています。この方式は利便性が高い一方で、URLが第三者に漏洩した場合、そのチャネルに不正なメッセージが送信される可能性があります。

より厳密なセキュリティが必要な場合は、カスタムヘッダーにシークレットキーを設定して検証するような実装を追加することをお勧めします。これにより、正規のWebhookリクエストのみを処理することが可能になります。

おわりに

この実装を通じて、Cloudflare Workersの開発体験の素晴らしさを実感しました。デプロイの容易さ、優れたパフォーマンス、そして充実した開発者ツールにより、アイデアを素早く形にすることができました。

また、この実装により、Slackユーザーだけでなく、Discordを使用しているチームでも、非エンジニアのメンバーが簡単にChatOpsを実現できるようになりました。実際に、私たちのチームでは技術書データベースの更新をDiscordに通知する運用が順調に進んでおり、情報共有の効率が大きく向上しています。

最後に、NotionがWebhook機能を追加してくれたことに感謝したいと思います。この機能追加により、私たちは新しい可能性を探求し、より良い開発体験を実現することができました。今後も、この機能を活用してさらなる改善を進めていきたいと考えています。

YOSHINANI

Discussion