0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

FastAPI・Cloud Run・Firestore・LIFFで毎朝「傘いる?」を通知するLINE Botを作った

0
Last updated at Posted at 2026-08-12

警告

下記の問題が確認されました.

  • 予報の取得に失敗
    • 原因調査中
  • 雨ではないが傘が必要と判定
    • 傘の判定基準を変更予定

はじめに

出発前に天気アプリを開いて、降水確率と雨量を見比べて、「普通の傘か、折りたたみ傘
か」を考えるのが少し面倒でした。そこで、複数地域の時間別予報を判定し、指定した
曜日・時刻にLINEへ傘の種類を通知するBotを作りました。

この記事では、次の技術要素を中心に紹介します。

  • FastAPIでWebhook、LIFF API、Cloud Scheduler用APIを実装
  • Open-Meteoの時間別予報をドメインモデルへ正規化
  • FirestoreとCloud Schedulerで定時通知
  • LINE LoginのIDトークンをサーバー側で検証
  • Flex Messageで全体判定と地域詳細を表示
  • リトライ時の二重送信を防止
  • Cloud LoggingとCloud Monitoringで障害を検知

なお、この記事とGitHubで公開するのは技術リファレンスです。実際に友だち追加できる
LINE公式アカウントや、本番のLIFF URLは公開していません。

設定画面は、LINEのリッチメニューから開く構成にしました。

傘予報の設定画面を開くリッチメニュー

全体構成

主な構成は次のとおりです。

領域 使用技術
API Python 3.11 / FastAPI
実行環境 Cloud Run
永続化 Firestore Native mode
定時実行 Cloud Scheduler
認証 LINE Webhook署名 / LINE Login IDトークン / Google OIDC
天気 Open-Meteo Forecast API / Geocoding API
UI LIFF / Vanilla JavaScript / CSS
テスト pytest / pytest-asyncio / HTTPX

最初にドメインロジックを外部サービスから分離した

傘判定をFastAPIのルートやOpen-Meteoのレスポンス処理へ直接書くと、境界値テストが
難しくなります。そこで、構成をおおむね次の4層に分けました。

src/umbrella_bot/
├── domain/          # 傘判定、時間区間、メッセージ
├── application/     # ユースケースとPort
├── infrastructure/  # Open-Meteo、LINE、Firestore
└── api/             # FastAPIルートと認証依存

外部APIのレスポンスはAdapterで共通の予報モデルへ変換し、FirestoreはRepository経由で
扱います。ドメインテストはネットワークにも実トークンにも依存しません。

傘判定を3段階にした

傘レベルは「不要」「折りたたみ傘」「普通の傘」の3段階です。初期閾値は気象機関の
公式な傘基準ではなく、このBot独自の設定です。

@dataclass(frozen=True, slots=True)
class JudgementConfig:
    rain_amount_interval_threshold_mm: float = 0.1
    rain_possibility_probability_threshold_percent: float = 40.0
    standard_umbrella_total_rain_mm: float = 3.0
    standard_umbrella_max_hourly_rain_mm: float = 2.0
    standard_umbrella_long_rain_duration_minutes: int = 180
    standard_umbrella_long_rain_min_total_mm: float = 1.0

普通の傘は、合計雨量3.0 mm以上、最大1時間雨量2.0 mm以上、長時間の雨、雷雨、強い
降水のいずれかで判定します。そこまで強くなくても、雨量0.1 mm以上、降水確率40%以上、
雨・雪コードがあれば折りたたみ傘です。

複数地域を登録した場合は、地域ごとに判定してから最も強い傘レベルを全体判定にします。
同率の地域はすべて「判定基準」として残します。

地域ごとの予報
    ↓
時間帯を抽出・集計
    ↓
地域ごとの傘レベル
    ↓
最大レベルを全体判定へ採用

一部地域の予報取得に失敗し、取得できた地域がすべて「傘不要」だった場合は、傘不要と
断定せず、安全側の「折りたたみ傘」へ倒します。

定時実行では「送ったか」だけでなく実行そのものを永続化した

Cloud Schedulerは毎分、内部APIを呼びます。

POST /internal/scheduler/run-due

単に「現在時刻と同じ通知設定」を検索してPushすると、再試行や同時実行で二重送信が
起こり得ます。そのため、通知設定とは別にNotificationRunを保存します。

実行IDは通知設定IDと予定時刻から決定的に作り、processingには期限付きリースを持たせ
ます。プロセスが途中で落ちても、リース期限後に同じ実行を回収できます。

LINE Push APIには、同じ通知実行から生成した安定したX-Line-Retry-Keyを付けます。
アプリ側の実行IDとLINE側のリトライキーを対応させることで、再試行時の重複送信を
抑えました。

3種類の入口を別々に認証した

LINE Webhook

WebhookはJSONへ変換する前の生ボディに対して、Channel Secretを使ったHMAC-SHA256署名を
検証します。署名を確認する前にリクエスト内容を信用しません。

LIFF設定API

ブラウザでデコードしたユーザーIDをAPIへ送る方式にはしていません。LIFF SDKが取得した
生のIDトークンをBearerトークンとしてバックエンドへ送り、バックエンドからLINE Login
v2.1の検証APIへ問い合わせます。iss、aud、sub、expも確認します。

Cloud Scheduler

Cloud Runサービス自体はWebhookとLIFFのため公開していますが、内部Scheduler APIでは
Google署名に加え、次を検証します。

  • OIDC audienceがCloud RunのサービスURLと一致
  • 呼出元メールがScheduler専用サービスアカウントと一致
  • 認証なしは401、別サービスアカウントは403

Firestoreで必要になった複合インデックス

定時処理では、次のような複合条件を使用します。

  • 有効な通知設定をnext_run_at順で取得
  • pending実行をscheduled_at順で取得
  • 期限切れのprocessing実行をlease_until順で取得

そのため、リポジトリにはfirestore.indexes.jsonを含めました。インデックスが
CREATINGの間にSchedulerを有効化すると、クエリが失敗してCloud Runが503を返します。
すべてREADYになってからSchedulerを再開するのがポイントでした。

LIFF設定画面で詰まったところ

以下は実際のHTML/CSSを使い、記事用のダミー地域と通知設定だけを表示した画面です。
本番のユーザー情報、ID、URLは含めていません。

蒲郡市と西尾市をダミー登録したLIFF傘予報設定画面

LIFF SDK初期化が終わらない

最初は「LINEに接続しています…」から進まないことがありました。原因はCSPとLIFF SDKの
実行条件でした。API全体のCSPを緩めるのではなく、/liff/だけに必要なLINE公式ドメインと
SDK実行条件を許可しました。

初期化にはタイムアウトを設け、失敗時に再試行ボタンと問い合わせ番号を表示しています。

短い市町村名を検索できない

Geocoding APIでは「蒲郡」より「蒲郡市」のほうが安定して検索できる場合がありました。
そこで、1〜2文字の日本語検索では「市」「町」「村」を補った候補を優先検索し、結果を
重複排除しています。「堺」のような1文字の市にも対応できます。

通知は全体判定と地域詳細を分けた

最初のBubbleに全地域の結論を置き、続くBubbleに地域ごとの時間帯や雨量を載せました。
画像はFlex Messageの配色と項目を使った記事用の表示再現で、実際のLINEアカウントを
撮影したものではありません。

普通の傘という全体判定と蒲郡市の地域詳細を並べた通知表示例

日付と説明文の余白を端末向けに調整した

日付のwrap: trueによって、右側に余白が見えても曜日だけ次の行へ送られる端末が
ありました。日付はwrap: falseとadjustMode: shrink-to-fitを組み合わせ、必要な端末だけ
フォントを縮小しています。

また、親Boxにspacingを設定していても、子要素のmargin: noneが上書きしていたため、
傘レベルと「雨予報」が密着していました。不要なmarginを外して親の余白を効かせました。

構造化ログと監視

標準出力へ1行JSONで次のイベントを記録します。

event 用途
http_request_finished HTTPステータスと処理時間
location_forecast_evaluated 地域ごとの判定結果
notification_run_finished 通知実行の最終結果
scheduler_run_finished Scheduler 1回分の集計

生のLINEユーザーIDは記録せず、名前空間付きハッシュへ変換します。IDトークン、Webhook署名、
Channel Access Token、外部APIのレスポンス本文もログへ出しません。

Cloud Loggingのログベース指標から、通知失敗、Scheduler失敗、Cloud Run 5xx、Cloud
Scheduler失敗のアラートを作成し、通知チャネルまで実際にテストしました。

Cloud Runへ配置するときの注意

秘密値はコマンドへ直接書かず、Secret Managerの明示バージョンを参照します。

gcloud run services update "${SERVICE}" \
  --region="${REGION}" \
  --update-secrets="LINE_CHANNEL_SECRET=line-channel-secret:1,LINE_CHANNEL_ACCESS_TOKEN=line-channel-access-token:1,LOCATION_SELECTION_SECRET=location-selection-secret:1"

シェルコマンドをコピーするとき、イメージタグやSecret名の途中で改行すると、その改行も
値に入り、Dockerイメージ名やSecret名の解析エラーになります。変数への代入は1行に保ち、
実行前にprintf '%s\n' "${IMAGE}"で確認すると安全です。

テスト

python3.11 -m venv .venv
source .venv/bin/activate
python -m pip install -e '.[dev]'
pytest
ruff check .

境界値、時間区間結合、部分障害、安全側判定、Webhook署名、IDトークン検証、Firestoreの
競合、Schedulerのリース、LINEの二重送信防止、Flex Message上限などを自動テストして
います。外部通信はHTTPXのモックを使い、実トークンや実サービスへ依存しません。

ソースコード

YamatoDoi/umbrella-line-bot
で公開しています。

公開リポジトリには本番のプロジェクトID、Channel ID、LIFF ID、Cloud Run URL、秘密値を
含めていません。実際に動かす場合は、各自のGoogle CloudプロジェクトとLINEチャネルを
作成してください。

参考にした公式資料

おわりに

最も時間がかかったのは、傘判定そのものよりも「失敗しても二重送信しないこと」と、
LINE・Google Cloud・ブラウザの3種類の認証境界を整理することでした。

ドメインロジックを先に外部サービスから分離したことで、後からFirestore、LIFF、Flex
Messageを追加しても判定部分を崩さずに済みました。小さなBotでも、定時実行、再試行、
秘密管理、監視まで含めると、実運用に近い題材になります。

Weather data by Open-Meteo.com under
CC BY 4.0。無料APIを利用する場合は、
非商用条件と最新の利用上限を確認してください。

0
0
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?