← 一覧に戻る

LINE 電話リマインダー Bot 設計プラン

2026年3月13日 22:45 更新
MD から自動変換されたページです。内容について質問があれば右下の ? ボタンからどうぞ。

ステータス: v5 — T-Wada 9.5 / Codex 8.2 の最終指摘反映済み → 最終レビュー中

変更履歴

v5 で対応した指摘

指摘元 指摘 対応
T-Wada Phase 4-5 の順序検証手段が未明示 vi.fn() のコール順で検証と明記
T-Wada Cron 複数pending時の処理方針が未定義 直列(forループ)と明記
Codex Webhook再送で二重INSERT webhook_event_id UNIQUE制約追加
Codex events: [] と空ボディは別ケース テストケースを分離(Phase 4-3a/4-3b)
Codex 返信文に絶対日時を含めるべき 2026-03-14 10:00 JST 形式で返信
Codex MY_PHONE_NUMBER 未設定時のfail-fast Cronハンドラー冒頭で必須env検証

v4 で対応した指摘

指摘元 指摘 対応
T-Wada phone_number カラムの解決ロジックが未定義 カラム削除。環境変数 MY_PHONE_NUMBER に変更(PoCは自分だけ)
T-Wada Phase 4 に「LINE返信がwaitUntilの外で呼ばれること」のテスト不在 waitUntil自体を廃止し同期処理に統一したため解消
Codex waitUntil D1保存は「静かに取りこぼす」リスク。同期に戻すべき 同期保存に変更。D1 INSERT → LINE返信 の順。保存失敗時は返信しない
Codex Cron二重発信リスク(排他実行の保証なし) リスク表に追記。PoCでは許容(月10回、発信+更新は数秒で完了)。スケール時にlease_until対策
Codex タイムゾーン未定義 Asia/Tokyo 固定を明記。chrono-nodeに forwardDate: true 設定
Codex 運用調査用カラムが不足 telnyx_call_id, called_at, failure_reason を追加
Codex failure_reason に失敗種別を残すべき failure_reason カラムで対応

v4 で採用しなかった指摘(根拠付き)

指摘元 指摘 不採用の理由
Codex lease_until カラムで二重発信を防げ PoCで月10回。Cron 1分間隔で発信+更新は数秒。次のCron実行時にはcompleted済み。リスクは極めて低くKISSを優先
Codex attempt_count/next_retry_at 追加 YAGNI。失敗→failed→自分で再登録。PoCにリトライ機構は不要

1. プロジェクト概要

LINEボットに「明日9時に電話して 歯医者」と送ると、指定時間に電話がかかってきて音声で教えてくれるサービス。「リマインくん」の電話版。

ゴール(PoC)


2. 調査結果サマリー

2-A. 最重要の制約: 日本の電話番号規制

制約 詳細
個人での番号取得 不可(Telnyx, Twilio, Vonage いずれも法人のみ)
050番号 法人なら取得可能。物理接続不要でクラウドVoIPと相性がいい
ローカル番号(03/06等) Telnyxは2023年に撤退。Twilioは法人+物理郵便確認が必要
海外番号→日本携帯 「国際電話」表示になる。ただし日本キャリアの一律ブロックは確認されていない
発信者番号なし ほぼ確実に着信拒否される

PoC: 米国番号(Telnyx発行の検証済みDID)から自分の携帯に発信。自分だけなら国際電話表示でも問題ない。 サービス化時: 法人で050番号を取得。

2-B. chibabot から得た LINE Bot の知見

学び 詳細
5秒タイムアウト LINE Webhookは5秒以内に200 OK必須。LINEの推奨は2秒以内
非同期パターン chibabot: Queue、chiba-bot3: waitUntil。今回は同期で収まるため不要
署名検証 x-line-signature の HMAC-SHA256 検証が必須
ユーザー識別 LINE User ID(U + 32文字hex)が自然な識別子。別途認証不要
技術スタック Cloudflare Workers + Hono + TypeScript + pnpm が実績あり
テスト chiba-bot3 で Vitest 実績あり。署名検証テストパターン確立済み
設定の罠 Developers Console と OA Manager 両方でWebhookオン必須

2-C. Voice API: Telnyx を選択

2-D. 確定した技術スタック

項目 選択 理由
ランタイム Cloudflare Workers chibabot実績、$0、サーバーレス
フレームワーク Hono chibabot実績、Workers最適化済み
言語 TypeScript LINE SDK公式対応
パッケージマネージャー pnpm chibabot実績
スケジューラー Cron Triggers(毎分) Workers内蔵、外部依存ゼロ
DB Cloudflare D1(SQLite) Workers内蔵、chibabot実績、$0
Voice API Telnyx 最安、Twilio互換
パーサー chrono-node/ja + ルールベース 日本語日時解析ライブラリ。Workers互換確認済み(gzip後約15KB)
タイムゾーン Asia/Tokyo 固定 chrono-nodeの解析時に明示指定
テスト Vitest chiba-bot3実績

3. アーキテクチャ設計

全体フロー

ユーザー → LINE Bot に「明日9時に電話 歯医者」と送信
    ↓
[POST /webhook]
    ↓ 署名検証
    ↓ メッセージ解析(chrono-node/ja、Asia/Tokyo、forwardDate: true)
    ↓ D1にリマインダー保存(同期)
    ↓ LINE返信「3/20 10:00 に『歯医者』で電話するね」
    ↓ 200 OK
    ↓
    ... 時間経過 ...
    ↓
[Cron Trigger] 毎分実行 → D1から「今実行すべきリマインダー」を検索
    ↓ call_at <= now() AND status = 'pending'
    ↓
[Telnyx Voice API] 電話発信 + TTS で内容読み上げ
    ↓
[D1] status を 'completed' + called_at + telnyx_call_id 記録
     or 'failed' + failure_reason 記録

Webhook の処理順序(同期)

1. 署名検証(失敗 → 401)
2. ボディチェック:
   - ボディなし(空) → 即200 OK
   - events が空配列(LINE疎通確認) → 即200 OK
3. chrono-node/ja でメッセージ解析(Asia/Tokyo, forwardDate: true)
4. D1にリマインダーINSERT(webhook_event_id を含む。UNIQUE制約で再送時の重複を防止)
5. D1保存成功 → LINE返信API呼び出し(絶対日時を含む: 「2026-03-14 10:00 JST に『歯医者』で電話するね」)
6. 200 OK

D1保存を同期にすることで「返信したのに未登録」の矛盾を防ぐ。 D1 INSERTは数ms、LINE返信APIは数百ms。合計で2秒以内に十分収まる。 LINE返信に絶対日時を含めることで、ユーザーがchrono-nodeの解析結果を確認できる。

電話番号の解決(PoC)

PoCは自分1人なので、発信先電話番号はレコードに持たず環境変数で管理:

複数ユーザー対応時にユーザーテーブル + 電話番号登録フローを追加する(セクション8参照)。

ファイル構成(最小)

line-call-reminder/
├── src/
│   ├── index.ts          # Hono アプリ + Webhook + Cronハンドラー
│   ├── caller.ts         # Telnyx API 呼び出し(1関数)
│   ├── caller.test.ts    # 電話発信テスト(モック)
│   ├── parser.ts         # chrono-node/ja でメッセージ → 日時・内容を抽出
│   └── parser.test.ts    # パーサーテスト
├── scripts/
│   ├── test-call.ts      # Phase 0-A: Telnyx実機発信確認
│   └── test-chrono.ts    # Phase 0-B: chrono-node/ja 精度確認
├── schema.sql            # D1 テーブル定義
├── wrangler.toml         # Workers + D1 + Cron 設定
├── package.json
├── vitest.config.ts
├── tsconfig.json
└── .dev.vars             # 環境変数(.gitignore対象)

D1 テーブル

CREATE TABLE reminders (
  id INTEGER PRIMARY KEY AUTOINCREMENT,
  webhook_event_id TEXT UNIQUE,       -- LINE webhookEventId(再送時の重複INSERT防止)
  line_user_id TEXT NOT NULL,
  content TEXT NOT NULL,
  call_at INTEGER NOT NULL,           -- Unix timestamp(実行予定時刻、Asia/Tokyo基準)
  status TEXT DEFAULT 'pending',      -- pending / completed / failed
  telnyx_call_id TEXT,                -- Telnyx API の応答ID(デバッグ用)
  called_at INTEGER,                  -- 実際に電話した時刻(デバッグ用)
  failure_reason TEXT,                -- 失敗理由(デバッグ用)
  created_at INTEGER DEFAULT (unixepoch())
);

CREATE INDEX idx_reminders_pending ON reminders(status, call_at);

Cron Trigger のロジック

0. env.MY_PHONE_NUMBER が未設定 → エラーログ出力して即終了(fail-fast)
1. call_at <= 現在時刻 AND status = 'pending' のレコードを取得
2. 各レコードを直列(forループ)で処理:
   a. Telnyx API で env.MY_PHONE_NUMBER に電話発信
   b. 成功 → status='completed', called_at=now(), telnyx_call_id=応答ID
   c. 失敗 → status='failed', failure_reason=エラー内容

直列処理の理由: PoCで月10回、同時に複数pendingが存在する可能性は低い。並列化(Promise.all)は不要(YAGNI)。

wrangler.toml(例)

name = "line-call-reminder"
main = "src/index.ts"
compatibility_date = "2024-01-01"

[triggers]
crons = ["* * * * *"]  # 毎分実行

[[d1_databases]]
binding = "DB"
database_name = "reminder-db"
database_id = "<作成後に自動設定>"

4. TDD 実装計画(Red-Green-Refactor 最小単位)

Phase 0: 最大リスクの排除(コードを書く前に実験で確認)

0-A: Telnyx 実機発信確認

0-B: chrono-node/ja の日本語解析精度確認

Phase 1: プロジェクトセットアップ

Phase 2: 電話発信(Telnyx)— 不安が最も大きい外部依存から

Phase 0 で実機確認済みだが、ここでは caller.ts のユニットテストとモック方針を固める。

Red 2-1: makeCall('+8190xxxx', '歯医者の時間です'){ callId: string } を返すテスト(Telnyx APIをモック)→ 失敗 Green 2-1: caller.tsmakeCall 関数を実装 → テスト通過

Red 2-2: API エラー時に例外を投げるテスト → 失敗 Green 2-2: エラーハンドリング追加 → テスト通過

Phase 3: メッセージパーサー(chrono-node/ja、純粋関数)

Red 3-1: 「明日9時に電話 歯医者」→ { datetime: Date, content: '歯医者' } を返すテスト → 失敗 Green 3-1: import * as chrono from "chrono-node/ja" で日時抽出(Asia/Tokyo, forwardDate: true)+ 残りテキストを内容として返す → テスト通過

Red 3-2: 「3/20 14:30 ミーティング」→ 日付直接指定のテスト → 失敗 Green 3-2: chrono-node が対応するか確認しながら実装 → テスト通過

Red 3-3: 「来週の火曜 10時 歯医者」→ 相対日付のテスト → 失敗

Red 3-3b: 漢数字入力「3月20日の二十二時に電話 歯医者」→ kanjiToArabic 前処理を経て正しく22:00として解析されるテスト → 失敗 Green 3-3b: kanjiToArabic() 関数を実装し parseMessage 内で呼び出し → テスト通過

Red 3-4: 不正な入力(「こんにちは」)→ null を返すテスト → 失敗 Green 3-4: chrono-node の結果が空の場合 null を返す → テスト通過

Red 3-5: 過去日時(「昨日の9時」)→ forwardDate で未来に解釈されるか、それともnullか → テストで挙動を確認 Green 3-5: 挙動に応じた処理を実装 → テスト通過

Phase 4: Webhook Handler(LINE署名検証 + D1同期保存 + 冪等性)

Red 4-1: 正しい署名付きリクエスト → 200 OK のテスト → 失敗 Green 4-1: Hono ルート + 署名検証(HMAC-SHA256自前生成)→ テスト通過

Red 4-2: 不正な署名 → 401 のテスト → 失敗 Green 4-2: 署名検証失敗時のエラーハンドリング → テスト通過

Red 4-3a: 空ボディ(ボディなし)→ 200 OK のテスト → 失敗 Green 4-3a: 空ボディ判定追加 → テスト通過

Red 4-3b: events が空配列({ events: [] }、LINE疎通確認)→ 200 OK のテスト → 失敗 Green 4-3b: events.length === 0 の分岐追加 → テスト通過

Red 4-4: 正しいメッセージ → D1にリマインダーが保存される(webhook_event_id 含む)テスト → 失敗 Green 4-4: Webhookハンドラー内で同期D1 INSERT(webhook_event_id付き)→ テスト通過

Red 4-5: D1保存成功後にLINE返信APIが呼ばれるテスト → 失敗

Red 4-6: 同じ webhook_event_id で2回目のリクエスト → D1のUNIQUE制約で重複INSERTが防止されるテスト → 失敗 Green 4-6: INSERT OR IGNORE(または UNIQUE制約エラーのハンドリング)→ テスト通過

Red 4-7: 返信文に絶対日時(2026-03-14 10:00 JST形式)が含まれることを確認するテスト → 失敗 Green 4-7: 返信テキスト生成にchrono-nodeの解析結果をJST形式でフォーマット → テスト通過

Phase 5: Cron Handler(毎分実行 → 電話発信)

Red 5-0: MY_PHONE_NUMBER 未設定時 → エラーログ出力して処理スキップ(fail-fast)のテスト → 失敗 Green 5-0: Cronハンドラー冒頭で必須env検証 → テスト通過

Red 5-1: 実行時刻を過ぎた pending リマインダーがあるとき → makeCall が呼ばれるテスト → 失敗 Green 5-1: Cron ハンドラーで D1 クエリ + makeCall(env.MY_PHONE_NUMBER, content) → テスト通過

Red 5-2: call_at が60秒後(まだ実行時刻でない)のレコード → makeCall が呼ばれないテスト → 失敗 Green 5-2: call_at <= now() の条件が正しく機能 → テスト通過

Red 5-3: makeCall 成功時 → status='completed' + called_at + telnyx_call_id が記録されるテスト → 失敗 Green 5-3: 成功パスのD1 UPDATE → テスト通過

Red 5-4: makeCall 失敗時 → status='failed' + failure_reason が記録されるテスト → 失敗 Green 5-4: try-catch + failure_reason を含むD1 UPDATE → テスト通過

Phase 6: 実機テスト


5. 未決事項(ユーザー確認待ち)

  1. 法人の有無: 050番号取得に必要。PoCは米国番号で進める
  2. 自分の電話番号: MY_PHONE_NUMBER に設定する番号

6. リスクと対策

リスク 影響 対策
Telnyx→日本携帯が着信しない PoC失敗 Phase 0-A で即確認。失敗→Twilioフリートライアル
chrono-node/ja の日本語精度不足 日時誤認識 Phase 0-B で事前検証。不足→自作正規表現(対応形式限定)
Cron 1分精度では不足 最大59秒の遅延 PoCでは許容。必要なら Durable Objects Alarms に移行
Telnyx アカウント審査で弾かれる 開発停止 Twilio フリートライアル($15)をバックアップ
Cron二重実行で同じレコードに二重発信 2回電話がかかる PoCでは許容(月10回、発信+更新は数秒で完了し次のCronまでに済む)。スケール時にlease_until対策
D1保存失敗 リマインダー未登録 同期処理のためLINE返信されない→ユーザーが異常に気づける。Workers Logsで確認

7. コスト見積もり(PoC段階)

項目 月額
Cloudflare Workers (無料枠) $0
Cloudflare D1 (無料枠) $0
Telnyx 通話料(自分だけ、月10回×1分) $0.5〜1.85(⚠️ Phase 0-A で実測更新)
LINE Messaging API (無料枠 200通/月) $0
合計 $0.5〜1/月(約100円)

8. スケール時の移行パス(今は実装しない、判断基準のみ記録)

状況 移行先
1分精度が不足 Durable Objects Alarms(秒単位)
Cron二重発信が問題に lease_until カラム追加で排他制御
月数百件以上 D1のまま(500万読み取り/月の無料枠内)
リトライが必要 attempt_count + next_retry_at カラム追加
複数ユーザー対応 ユーザーテーブル追加 + 電話番号登録フロー + phone_number カラム復活
Voice API 差し替え caller.ts を差し替えるだけ。2社目が入る時に CallProvider インターフェース抽出
日本番号が必要 法人で050番号取得(Telnyx or Twilio)
応答速度が2秒超 D1保存を waitUntil() に逃がす or Queue 非同期化
米国番号への不審感 初回メッセージで「電話は米国番号(+1-xxx)からかかります」と案内する
📝 質問モード — テキストを選択してね
✓ 質問を送信しました