【第4回】パチ飯マップ — Cloud Functions + Twitter API で新着投稿を自動ツイート

はじめに

前回は、Firebase の名前付きデータベースとサブドメインを組み合わせて複数エリアのデータを完全に分離する設計を紹介しました。今回は最終回として、Firestore に新しい投稿が書き込まれた瞬間に Cloud Functions が自動発火し、X(Twitter)へ通知ツイートを投稿する仕組みを解説します。

まず、登場する技術を簡単に整理しておきます。

  • Cloud Functions:サーバーを自分で用意しなくても、サーバー側の処理を実行できる Google のクラウドサービス。コードをデプロイするだけで、必要なときだけ自動的に起動します
  • Firestore トリガー:Firestore(クラウドデータベース)にデータが書き込まれたとき、指定した関数を自動で実行する仕組み。「誰かが投稿した → 自動でツイート」という連携の起点になります

実装の骨格はシンプルですが、日本語コンテンツならではの文字数計算の落とし穴や、Twitter API の一時的なエラーへの対処など、実運用で気づいた工夫を詳しく紹介します。「イベントドリブンな SNS 連携」を検討している方の参考になれば幸いです。

自動ツイートの全体フロー

ユーザーが投稿を作成してから X にツイートが届くまでの流れをまとめると、次のようになります。

ユーザーが投稿を作成
   ↓
Firestore に posts/{postId} を書き込み
   ↓
Cloud Functions(onDocumentCreated)が自動発火
   ↓
stores/{placeId} から店舗情報(店名・カテゴリ・住所・評価)を取得
   ↓
ツイート本文を組立 + 投稿写真をアップロード(最大4枚)
   ↓
Twitter API でツイートを投稿
   ↓
Firestore に tweetId を記録(二重投稿防止・追跡に使用)

管理画面からは同じ処理を呼び出す手動ツイート機能も提供しており、自動ツイートが失敗した場合のリカバリー手段として機能します。

Cloud Functions × Twitter API で実現した4つの技術ポイント

1. エリアごとの Firestore トリガーを共通ハンドラで処理する

課題:エリアが増えるたびに同じ処理をエリアごとに書くと、コードが重複して修正漏れが起きやすい。
解決策:database パラメータだけを変えた onDocumentCreated を複数定義し、共通の handleNewPost 関数に処理を集約する。

以下は3つのエリアのトリガーを定義したコードです。

import { onDocumentCreated } from 'firebase-functions/v2/firestore'

// 共通オプション(全エリアで同じ設定を使う)
const OPTIONS = {
  secrets: SECRETS,
  region: 'asia-northeast1',  // 東京リージョン
  timeoutSeconds: 120,        // タイムアウトをデフォルト30秒から120秒に拡張
}

// エリアAの posts に新規ドキュメントが作成されたとき自動発火
export const tweetOnNewPostAreaA = onDocumentCreated(
  { document: 'posts/{postId}', database: 'area-a', ...OPTIONS },
  handleNewPost  // どのエリアでも同じハンドラを呼び出す
)
// エリアB
export const tweetOnNewPostAreaB = onDocumentCreated(
  { document: 'posts/{postId}', database: 'area-b', ...OPTIONS },
  handleNewPost
)
// エリアC
export const tweetOnNewPostAreaC = onDocumentCreated(
  { document: 'posts/{postId}', database: 'area-c', ...OPTIONS },
  handleNewPost
)

新エリアを追加するときはこの3行を追加するだけで対応できます。handleNewPost の実装を変更する必要はありません。Cloud Functions は event.firestore.databaseId でどのデータベースから発火したかを識別できるため、エリア固有のラベルやハッシュタグもハンドラ内で自動的に切り替えられます。

2. Twitter 加重文字数を正確に計算して日本語コメントを安全にカットする

課題:Twitter の文字数制限は単純な文字数ではなく「加重文字数」で計算される。日本語のひらがな・カタカナ・漢字(まとめて CJK 文字=中国語・日本語・韓国語の文字の総称)は 1文字 = 2点 とカウントされるため、JavaScript の String.length で判定すると誤った位置でカットが発生する。
解決策:Unicode コードポイントの範囲で CJK 文字を判定し、2点として積算する twitterWeightedLength() 関数を実装する。

たとえば「ありがとう」は5文字ですが、加重文字数では 10点 になります。日本語の投稿コメントは100文字でも加重文字数は約200点になるため、ツイート本文全体が280点に収まるよう調整が必要です。

function twitterWeightedLength(text: string): number {
  let weight = 0
  for (const ch of [...text]) {  // 文字列を1文字ずつ分解して処理
    const cp = ch.codePointAt(0) ?? 0
    // CJK(中国語・日本語・韓国語)の Unicode 範囲かどうかを判定
    const isCJK =
      (cp >= 0x2e80 && cp <= 0x303f) ||  // CJK 部首・記号
      (cp >= 0x3040 && cp <= 0xa4cf) ||  // ひらがな・カタカナ・漢字
      (cp >= 0xac00 && cp <= 0xd7af) ||  // ハングル音節
      (cp >= 0xf900 && cp <= 0xfaff)     // CJK 互換漢字
    weight += isCJK ? 2 : 1  // CJK は2点、英数字などは1点として加算
  }
  return weight
}

ツイート本文全体の加重文字数が280を超えないよう、投稿コメントの末尾を1文字ずつ削りながら調整しています。

3. リトライロジックと Cloud Functions タイムアウトの拡張で信頼性を高める

課題:Twitter API はレート制限や一時的なエラーが発生することがある。Cloud Functions のデフォルトタイムアウト(30秒)では、リトライのための待機時間を確保できない。
解決策:Functions のタイムアウトを 120秒に拡張し、30秒間隔で最大3回リトライするループを実装する。

const MAX_RETRIES = 2  // 最大2回まで再試行(初回を含めると合計3回)

async function postTweet(/* ... */): Promise<string> {
  for (let attempt = 1; attempt <= MAX_RETRIES + 1; attempt++) {
    try {
      const result = await client.v2.tweet(tweetPayload)     // ツイートを投稿
      await postRef.update({ tweetId: result.data.id })      // 成功したら tweetId を記録
      return result.data.id
    } catch (err) {
      if (attempt <= MAX_RETRIES) {
        console.warn(`Attempt ${attempt} failed. Retrying in 30s...`)
        await new Promise(resolve => setTimeout(resolve, 30_000))  // 30秒待ってから再試行
      } else {
        console.error(`All retries exhausted: ${err}`)
        throw err  // 全リトライ失敗時はエラーをそのまま上位に投げる
      }
    }
  }
  throw new Error('unreachable')
}

初回 + 2回リトライで合計最大90秒かかります。タイムアウトを120秒に設定することで、30秒 × 2回のリトライ間隔を確保できます。ツイートが成功したタイミングで tweetId を Firestore に記録するため、リトライが途中で成功した場合も正確に記録が残ります。

4. Firebase Secret Manager で Twitter API キーをコードから分離する

Twitter API を使うには4種類のキー(API Key / API Secret / Access Token / Access Secret)が必要です。これらをコードや .env ファイルに直接書くと、Git リポジトリに誤ってコミットしてしまうリスクがあります。

そこで Firebase Secret Manager(API キーなどの機密情報を安全に保管・管理するサービス)を活用します。Secret Manager にキーを登録しておくと、Cloud Functions のデプロイ時に自動的に注入されるため、コード上には一切残りません。

課題:Twitter API のキー4種類を .env ファイルや Functions のコードに直接書くと、リポジトリに混入するリスクがある。
解決策:Firebase Secret Manager に登録し、defineSecret() を使ってデプロイ時に自動的に Functions へ注入する。

import { defineSecret } from 'firebase-functions/params'

// Secret Manager に登録した値を参照(コードには実際の値は書かない)
const twitterApiKey       = defineSecret('TWITTER_API_KEY')
const twitterApiSecret    = defineSecret('TWITTER_API_SECRET')
const twitterAccessToken  = defineSecret('TWITTER_ACCESS_TOKEN')
const twitterAccessSecret = defineSecret('TWITTER_ACCESS_SECRET')

const SECRETS = [
  twitterApiKey,
  twitterApiSecret,
  twitterAccessToken,
  twitterAccessSecret,
]

// トリガーの secrets に渡すことで、実行時に自動的に値が注入される
export const tweetOnNewPostAreaA = onDocumentCreated(
  { document: 'posts/{postId}', database: 'area-a', secrets: SECRETS, ... },
  handleNewPost
)

シークレットは Firebase Console の「Secret Manager」タブで管理します。Twitter API のトークンを再生成した場合も、Console 上で値を更新して firebase deploy --only functions を実行するだけで反映されます。

まとめ:Cloud Functions × Firestore トリガーで SNS 連携を自動化する

全4回を通じて、クローズドコミュニティ向けグルメ共有マップのアーキテクチャを一通り解説しました。

  • 第1回:アプリ全体の概要・主要機能・技術スタック
  • 第2回:Google Maps + Places API でお店を探す仕組み
  • 第3回:Firebase 名前付き DB × サブドメインで複数エリアに対応する設計
  • 第4回(本記事):Cloud Functions × Twitter API でイベントドリブンな自動ツイートを実現

今回の実装で得た知見をまとめます。

  • Firestore トリガーに database パラメータを指定することで、エリアごとに独立したデータベースの書き込みを検知できる
  • Twitter の加重文字数(CJK = 2点)を JavaScript で正確に計算することで、日本語コンテンツでの文字数オーバーを防げる
  • Cloud Functions のタイムアウトを拡張してリトライを組み込むことで、外部 API の一時的なエラーに柔軟に対応できる
  • Firebase Secret Manager を使うことで、API キーをコードから完全に分離してセキュアに管理できる

今回のように「データ書き込みをトリガーに外部 API を叩く」パターンは、通知・集計・同期など幅広い用途に応用できます。同様の仕組みを検討している方の参考になれば幸いです。