LINE 公式アカウントで日次リマインダー Push を実装する — Cloud Functions cron + 月 200 通制限への対策

LINE ミニアプリ開発記 第 12 回

#LINE#Messaging API#Cloud Functions#Cloud Scheduler

LINE 公式アカウントで「日次でリマインダーを送る」機能は、個人開発でも需要が高いわりに、月 200 通の Push 上限 が地味に効く制約です。素朴に「該当レコード 1 件 = Push 1 通」で書くと、ユーザー数が伸びた瞬間に超過します。

本記事は Cloud Functions の cron で Firestore を集計し、ユーザー単位で 1 通に集約 して Push する実装の全体像をまとめます。多段階通知(1 ヶ月前 / 1 週間前 / 前日)も同じ仕組みに乗せて、月 200 通の枠に押し込む設計です。

状況・前提

全体図

Cloud Scheduler(毎朝 09:00 JST)

    │ ① cron トリガー

Cloud Functions: sendDailyReminders

    │ ② Firestore collectionGroup で
    │    "scheduledDate が 1ヶ月後 / 1週間後 / 明日 のいずれか"
    │    かつ "completedDate が null" を抽出

ユーザー単位でグルーピング

    │ ③ uid ごとに「対象レコード一覧」を作る

ユーザーごとに Push を 1 通ずつ

    │ ④ uid → "line:Uxxxx" の prefix を取って LINE userId に
    │    Messaging API push に投げる

利用者の LINE に通知

ポイントは ③ のグルーピング。ここを抜くと「1 ユーザーに 1 日 3 通」みたいなことが起こります。

Cloud Functions 側の実装

functions/src/sendDailyReminders.ts の主要部:

import { onSchedule } from "firebase-functions/v2/scheduler";
import { defineSecret } from "firebase-functions/params";
import { getFirestore } from "firebase-admin/firestore";
import { initializeApp } from "firebase-admin/app";

initializeApp();
const LINE_CHANNEL_ACCESS_TOKEN = defineSecret("LINE_CHANNEL_ACCESS_TOKEN");

export const sendDailyReminders = onSchedule(
  {
    schedule: "every day 09:00",
    timeZone: "Asia/Tokyo",
    secrets: [LINE_CHANNEL_ACCESS_TOKEN],
    region: "asia-northeast1",
  },
  async () => {
    const today = jstDate();
    const targets = {
      monthAhead: addDaysJst(today, 30),
      weekAhead: addDaysJst(today, 7),
      tomorrow: addDaysJst(today, 1),
    };

    // [A] 対象レコードを 1 度の collectionGroup クエリで取り切る
    const snapshot = await getFirestore()
      .collectionGroup("records")
      .where("completedDate", "==", null)
      .where("scheduledDate", "in", Object.values(targets))
      .get();

    // [B] uid 単位にグルーピング
    const grouped = new Map<string, Array<{ tip: string; record: any }>>();
    for (const doc of snapshot.docs) {
      const uid = uidFromPath(doc.ref.path); // "users/{uid}/children/.../records/..." から uid を取り出す
      if (!uid.startsWith("line:")) continue;

      const data = doc.data();
      const tip = tipBy(data.scheduledDate, targets);
      if (!grouped.has(uid)) grouped.set(uid, []);
      grouped.get(uid)!.push({ tip, record: data });
    }

    // [C] ユーザーごとに Push 1 通
    for (const [uid, items] of grouped.entries()) {
      const lineUserId = uid.slice("line:".length);
      const text = buildText(items);
      await pushTo(lineUserId, text);
    }
  }
);

function tipBy(scheduledDate: string, t: { monthAhead: string; weekAhead: string; tomorrow: string }) {
  if (scheduledDate === t.tomorrow) return "明日が予定日です";
  if (scheduledDate === t.weekAhead) return "1 週間後が予定日です";
  if (scheduledDate === t.monthAhead) return "1 ヶ月後が予定日です";
  return "";
}

function buildText(items: Array<{ tip: string; record: any }>) {
  if (items.length === 1) {
    return `${items[0].tip}: ${items[0].record.name}`;
  }
  const lines = items.map((i) => `・${i.tip}: ${i.record.name}`);
  return `本日のお知らせ\n${lines.join("\n")}`;
}

async function pushTo(lineUserId: string, text: string) {
  await fetch("https://api.line.me/v2/bot/message/push", {
    method: "POST",
    headers: {
      "Content-Type": "application/json",
      Authorization: `Bearer ${LINE_CHANNEL_ACCESS_TOKEN.value()}`,
    },
    body: JSON.stringify({
      to: lineUserId,
      messages: [{ type: "text", text }],
    }),
  });
}

[A] collectionGroup で「1 度に取り切る」

ユーザーごとに 1 件ずつクエリすると、Firestore の読み取り回数が膨れます。collectionGroup("records") で全ユーザーを横断し、scheduledDate in [...] の 1 クエリ に圧縮するのが基本。completedDate == null の条件と組み合わせるため、複合インデックスが必要です。

firestore.indexes.json に書いておきます。

{
  "indexes": [
    {
      "collectionGroup": "records",
      "queryScope": "COLLECTION_GROUP",
      "fields": [
        { "fieldPath": "scheduledDate", "order": "ASCENDING" },
        { "fieldPath": "completedDate", "order": "ASCENDING" }
      ]
    }
  ]
}

[B] uid 単位にグルーピング

ドキュメントパス users/{uid}/children/{cid}/records/{rid} から uid を取り出します。階層設計の素直なメリットで、パスを切るだけで所有者が分かります。

function uidFromPath(path: string): string {
  const segments = path.split("/");
  return segments[1] ?? "";
}

[C] ユーザーごとに Push 1 通

buildText で対象 1 件なら短文、複数件なら箇条書きにします。これで Push 数 = アクティブユーザー数の上限に圧縮できます。

Push 数の見積もり

ユーザー数 N、各ユーザーが対象に該当する確率 p としたとき、1 日の Push 数の期待値はおおよそ N × p。月の上限が 200 通なので、N × p × 30 ≤ 200、つまり N × p ≤ 6.67/日 までが無料枠です。

p は機能の性質次第で、私のケースでは「3 種類の通知タイミングのいずれかに該当する確率」を 5% 程度と見積もっています。

N = 100 人、p = 5% の場合
  → 1 日の Push = 5 通
  → 30 日で 150 通
  → 月 200 通の無料枠内 ✅

N = 500 人、p = 5% の場合
  → 1 日の Push = 25 通
  → 30 日で 750 通
  → 上限超過 ❌

500 ユーザーを越えるあたりで有料プランへの切り替えを判断するライン、というのが私のメンタルモデルです。

「Cloud Scheduler は成功と言うのに通知が届かない」問題

cron の動作確認は別記事 #14 で扱いますが、ここでも軽く触れます。Cloud Scheduler のジョブ詳細画面で「成功」と表示されていても、それは HTTP 200 が返った という意味です。Function の中で fetch エラーが起きて console.error だけで止まっていても、外側からは成功扱い。

対策: Push 失敗時はサーバー側のエラーチャンネル(Slack / メール / 別 LINE)にも通知 する仕組みを 1 行入れておきます。これがないと「届かない」のに気付くのが利用者からの問い合わせ後になります。

学び・余談

cron + Push の機能は、コードの行数は短いのに 設計の選択肢が多い やつでした。1 件ずつ送る / ユーザー単位で集約する / 通知タイミングを別 Function に分ける、いずれの選択も実装は通せる。なかでも「ユーザー単位で 1 通に集約」を最初から選んでおくと、後から無料枠に対応するためにコードを書き直す量が一番少なく済みます。

公開前にもうひとつ準備しておきたいのは、「Push 失敗時に運営側に通知を回すフロー」です。届かない症状を利用者に教えてもらってから動くのは、個人開発で一番怖いパターンです。

関連記事

参考


← ブログ一覧へ