LINE連携と予約配信アプリの設計ノート|カナポスのLIFF・Supabase・Cronを振り返る

手紙、時計、作業ノートを描いた、開発記録のイラスト。 個人開発・公開

未来の自分へ書いた手紙が、ある朝LINEに届いたら面白そう。そんなタイムカプセルのようなものを作りたくて、僕は「カナポス」を開発しました。現在は公開を終了していますが、画面づくりからログイン・保存・予約配信へ進んだときの難しさが詰まった作品です。

とくに苦労したのはLINEとの接続でした。Claude Codeに相談しながら実装を進めたものの、毎朝8時にその日の手紙を届ける予約配信は、完成まで持っていけませんでした。動いた部分と詰まった部分を振り返ります。

カナポスのログイン、手紙の保存、未完成だった予約配信の構想図。
当時目指した構成。ログイン・保存の先に、未完成だった予約配信がありました。

「未来へ届く手紙」を、どんな機能に分けたか

利用者には、LINEでログインし、未来の日付を選び、メッセージを書いて保存してもらう。指定日になったら、アプリ側から手紙を送る。この流れを考えると、入力欄を作るだけでは終わらないことが分かりました。

Money Counterのような計算ツールなら、画面を開いている間に入力と結果がつながります。カナポスでは、書いた本人が画面を閉じた後にも、文章と宛先と日付を保管し、あとから処理する必要があります。「今その場で動く」と「後日きちんと届く」の間に、作るものが増えました。

LINEとの接続で、設定とコードを行き来した

LINE Developers側の設定とアプリ側の認証処理を行き来し、Claude Codeと一緒に原因を探しました。画面上の「ログインできない」をひとまとめにすると、設定を直すのかコードを直すのかが分かりにくかったです。今回は残っているコードを読み返し、処理を小さく分けて整理しています。

カナポスで使った技術と、残っていたコード

今回、当時のGitHubリポジトリを読み返しました。画面は静的HTMLとJavaScriptで、Next.jsやReactのプロジェクトではありません。index.htmlやwrite.htmlを置き、サーバー側はVercelのNode.js APIで動かす構成です。package.jsonの開発コマンドはvercel devでした。

手紙と利用者の情報はSupabaseへ保存し、APIからRESTで読み書きしています。LINEでのログインはLIFF SDK、通知はMessaging APIのpushを使い、定期実行はVercel Cronでした。LIFFで得たアクセストークンをサーバー側で検証し、プロフィールのuserIdを手紙の持ち主に対応させる流れです。フレームワーク名を並べるより、どこで誰を識別し、何を保存するかを見ると構成が読みやすくなります。

write.html
  LIFFの初期化・アクセストークンの取得
    → api/post.js
        api/auth/line.jsでトークンとチャネルIDを検証
        Supabaseのlettersへ本文・宛先・日付を保存

Vercel Cron
  → api/cron/deliver.js
      未配信のlettersを取得
      LINE Messaging APIのpushで開封リンクを通知
      Supabaseのdeliveredをtrueへ更新

認証処理には、LINEのトークン検証結果のclient_idと、環境変数LINE_CHANNEL_IDを照合する箇所がありました。LIFFで発行されたトークンのチャネルと、サーバーが期待しているチャネルがずれると、この段階で認証が止まります。実際に当時どのエラーが出たかのログは残っていないため、原因を一つに決めず、コード上の切り分け地点として読んでいます。

また、配信コードのコメントは「08:00 JST」ですが、vercel.jsonのdeliverは30 22 * * *で、日本時間の翌朝7時30分相当でした。remindは0 11 * * *で20時相当です。コメントに書いた予定と、実行される設定の両方を照合する必要がある、という具体的な見直し点が見つかりました。停止記録ではCronも無効化してあり、今回の調査で実通知は送っていません。

LIFFの戻り先と、サーバー側の認証を分けて追う

LIFFでは、まず登録したLIFF ID、エンドポイントURL、開いているページをそろえます。トークンが取れた後に保存で失敗するなら、api/auth/line.jsのチャネルID照合、有効期限、プロフィール取得を順に追います。LINEログインの画面へ進めることと、手紙の保存APIで本人を識別できることを、一つの成功としてまとめないのがポイントです。

以下は、LIFF SDKに任せずLINE Loginの認可コード方式を自分で組む場合の設計例です。カナポスに独自のCallbackルートやstate照合コードがあった、という説明ではありません。戻り先URLとセッションがどうつながるのかを理解すると、開発用と本番用の設定を切り分けやすくなります。

全体は、ブラウザの入力画面、ログインから戻るサーバーの処理、手紙を保存するDB、定刻に呼ばれる配信処理、LINEのMessaging APIへ分けられます。画面のフレームワークを選ぶ前でも、この境界は紙へ書けます。ログインの成功、保存の成功、送信の成功を別々に追えるので、「LINEが動かない」という大きな問題を小さくできます。

Callback URLは、認証を始めた値とそろえる

LINE DevelopersのLINE Loginチャネルへ、認証後に戻るURLを登録します。開発用をhttp://localhost:3000/api/auth/line/callback、本番用をhttps://アプリのホスト名/api/auth/line/callbackとするなら、スキーム、ホスト、パスまでそれぞれ登録値と合わせます。末尾のスラッシュを付けるかも含め、アプリが作るredirect_uriと見比べてください。

# 開発環境の設定例。値は例示です。
APP_ORIGIN=http://localhost:3000
LINE_REDIRECT_PATH=/api/auth/line/callback

# 本番ではAPP_ORIGINを、自分の公開ホストへ設定する。
# チャネルシークレットはサーバー側の秘密管理へ置く。

本番画面からログインしたのにlocalhostへ戻ろうとするなら、まずAPP_ORIGINの読み込み先を疑います。開発用ファイルの値が本番の環境設定へ残っていないか、認証開始時のURLに入っているredirect_uriが何かを調べます。秘密の値をログへ出さず、URLのスキーム・ホスト・パスだけを見比べると原因へ近づけます。

stateで、出発したログインと帰ってきたログインを結び付ける

stateはログインを始めるときに十分に予測しにくい乱数として作り、そのブラウザのセッションへ短時間保存します。戻ってきたstateと同じかを調べ、使い終わった値は破棄します。固定の文字列を全員で使うのではなく、一回の認証の往復へ対応させるのがポイントです。

ログイン開始:
  state = 暗号学的に安全な乱数()
  セッションへstateと有効期限を保存
  LINEの認可URLへredirect_uri、state、scopeを付けて移動

Callback:
  errorがあれば、認可エラーとして終了
  stateが未発行・期限切れ・不一致なら終了
  保存したstateを消費
  codeをサーバーからトークンへ交換
  IDトークンを公式の方式で検証し、利用者を識別
  サーバー側のログインセッションを作る

state不一致のときにURLだけを直し続けても、セッションCookieが戻っていなければ解決しません。localhostと127.0.0.1を混ぜていないか、HTTPS専用CookieをHTTPの開発環境で使っていないか、複数タブのログインでstateを上書きしていないかを追います。各段階で、認可画面へ進んだか、Callbackへ戻ったか、照合を通ったかだけを記録すると、設定とコードのどちらを読むか決めやすくなります。

LINE Loginの利用者と、送信先をつなぐ

ログインが成功した後は、手紙を誰のものとして保存するかを決めます。ブラウザから届いた利用者IDをそのまま信用せず、検証したログインセッションを使います。また、LINE LoginとMessaging APIのユーザーIDを照合する構成では、同じプロバイダー配下のチャネルかを公式資料で確かめます。メッセージを届ける相手が公式アカウントの送信対象になっているかも、ログインとは別に試したいところです。

毎朝8時は、UTCなら前日の23時

JSTはUTCより9時間進んでいます。日本時間の2026年10月31日8時は、UTCの2026年10月30日23時です。UTC基準のCronなら0 23 * * *が毎朝8時に対応します。時間帯を指定できるサービスならAsia/Tokyoを選んで0 8 * * *と書く方法もあります。両方の補正を同時に行うと、さらに9時間ずれるので、どちらの方式かを最初に決めます。

手紙の日付をDBへ保存するときは、「選んだ日のJST午前8時」を、UTCの配信予定時刻へ変換して記録する形にできます。配信処理はscheduled_atが現在以前のpendingを取り出します。当日と完全一致する日付だけで探すより、少し遅れて起動した処理でも未送信分を拾いやすくなります。遅延時にいつまで送るかは、利用者へ示す配信ルールとして決めます。

Vercel Cronの実行時刻の精度はプランで異なり、Hobbyでは時刻に幅があります。日付をそろえる話と、朝8時ちょうどに処理を始める話を分け、必要な精度に合う実行サービスを選びます。現在の条件はVercelのCron仕様で読めます。

二重送信を減らすために、送信状態をDBへ残す

当時のdeliver.jsは、LINEへの送信が成功してからdeliveredをtrueへ更新する順番でした。LINEには届いたのにDB更新が失敗すると、次の実行でも未配信として取り出されます。また、二つの処理が同時に同じ未配信行を読む可能性もあります。ここを改善するため、次の例ではboolean一つから、取得中・送信済み・失敗が分かる状態管理へ広げています。

次はSQLiteでも試せる最小のテーブル例です。本文、宛先、予定時刻のほかに、状態と再試行キーを持たせます。予定時刻はUnix時刻の秒で保存するため、比較時に文字列の時間帯が混ざりません。実際のサービスでは宛先や本文の保存期間と削除方法も合わせて決めます。

CREATE TABLE deliveries (
  id TEXT PRIMARY KEY,
  line_user_id TEXT NOT NULL,
  body TEXT NOT NULL,
  scheduled_at INTEGER NOT NULL,
  status TEXT NOT NULL DEFAULT 'pending'
    CHECK (status IN ('pending', 'processing', 'sent', 'failed')),
  retry_key TEXT NOT NULL UNIQUE,
  attempts INTEGER NOT NULL DEFAULT 0,
  claimed_at INTEGER,
  sent_at INTEGER,
  last_error TEXT
);

CREATE INDEX deliveries_due
  ON deliveries (status, scheduled_at);

Cronが重なっても同じ行を送らないよう、pendingからprocessingへの変更はDBの短いトランザクションで取り合います。SQLiteなら書き込みの取得方法、別のDBなら行ロックや条件付き更新など、そのDBの方式で一つの処理だけが取得できるようにします。ネットワークへの送信中まで長いトランザクションを開かず、取得を確定してからAPIを呼びます。

予定時刻を過ぎたpendingを、短いトランザクションで確保
  status = processing、claimed_at = 現在、attemptsを加算

確保した行ごとに:
  保存済みretry_keyをX-Line-Retry-Keyとして送信
  成功、または同じキーで既に受理済み:
    status = sent、sent_atを記録
  通信タイムアウトなど再試行できる失敗:
    同じretry_keyを維持し、待ち時間を空けて再試行
  宛先不正など、そのままでは直らない失敗:
    status = failed、秘密を含まない原因を記録

ここで難しいのは、LINEが受理した直後にアプリだけが止まる場合です。DBにsentが残っていないので、単純に再送すると重複する可能性があります。Messaging APIの対応する送信APIでは、同じX-Line-Retry-Keyを使うことで受理済みのリクエストを識別できます。キーは送信前に保存し、再試行で新しく作り直さないようにします。公式仕様の重複検出期間やHTTP応答に合わせて復旧を設計してください。

processingのまま長く止まった行も、監視対象に入れます。取得時刻と送信応答を手掛かりに、同じキーで再試行できる期間か、人が判断すべきかを分けます。sentはAPIが受理したこととして記録し、利用者が読んだこととは区別すると、管理画面の表示も誤解を生みにくくなります。

練習では、対象0件、対象1件、同時に2回起動、送信後に停止、送信前に停止、の五つを試すと状態の遷移が見えてきます。僕がカナポスで残した課題を次へ持ち込むなら、画面を増やすより先に、この小さな配信の往復を確かめたいです。

設定の意味はLINE Loginの公式実装ガイド、送信の再試行はMessaging APIの再試行仕様を参照してください。設計例のSQLと時刻変換は今回の改稿で再検証しています。

Claude Codeで楽になったことと、任せすぎたこと

チャットへ毎回コードを貼る代わりに、プロジェクトのファイルを見て編集してもらえるのは助かりました。認証や保存のように複数ファイルをまたぐ処理でも、構成を説明しながら進められます。

その一方で、一度に頼む範囲が大きいと、変更されたファイルも増えて、どこで動かなくなったか追いにくくなりました。それからは、ログイン、保存、配信のように機能を分け、Gitの差分を見て動作を確かめてから、次へ進むようにしています。

AIの返事が「完了」でも、利用者として最初から操作してみると、つながっていないところが見つかります。便利なツールを使うほど、自分が何を成功とするかを先に決めておく必要がありました。

この経験を、次の個人開発へ持ち込む

カナポスは公開終了しましたが、外部サービスをつなぐ難しさと、確認する単位を小さくする習慣が残りました。最初の作品なら、Money Counterのような入力と計算のツールから試すと、一つの画面で結果を追いやすいと思います。

Claude Codeの使い始めはVS Codeで小さく変更する手順へ。現在公開しているMaskSheetでは、実務の表を読み込んで問題を見つけ、確認画面や変換処理を直しました。その開発記録にも、作った後に気づいたことを残しています。

次に読む記事

Claude Codeで素数ゲームを作る|仕様決めと動作確認では、制限時間や連続正解の演出を決めた、終了作品の制作過程を振り返れます。

タイトルとURLをコピーしました