| 指摘元 | 指摘 | 対応 |
|---|---|---|
| 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検証 |
| 指摘元 | 指摘 | 対応 |
|---|---|---|
| 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 カラムで対応 |
| 指摘元 | 指摘 | 不採用の理由 |
|---|---|---|
| Codex | lease_until カラムで二重発信を防げ |
PoCで月10回。Cron 1分間隔で発信+更新は数秒。次のCron実行時にはcompleted済み。リスクは極めて低くKISSを優先 |
| Codex | attempt_count/next_retry_at 追加 |
YAGNI。失敗→failed→自分で再登録。PoCにリトライ機構は不要 |
LINEボットに「明日9時に電話して 歯医者」と送ると、指定時間に電話がかかってきて音声で教えてくれるサービス。「リマインくん」の電話版。
| 制約 | 詳細 |
|---|---|
| 個人での番号取得 | 不可(Telnyx, Twilio, Vonage いずれも法人のみ) |
| 050番号 | 法人なら取得可能。物理接続不要でクラウドVoIPと相性がいい |
| ローカル番号(03/06等) | Telnyxは2023年に撤退。Twilioは法人+物理郵便確認が必要 |
| 海外番号→日本携帯 | 「国際電話」表示になる。ただし日本キャリアの一律ブロックは確認されていない |
| 発信者番号なし | ほぼ確実に着信拒否される |
PoC: 米国番号(Telnyx発行の検証済みDID)から自分の携帯に発信。自分だけなら国際電話表示でも問題ない。 サービス化時: 法人で050番号を取得。
| 学び | 詳細 |
|---|---|
| 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オン必須 |
+1 DID を Caller ID に使用(偽番号・非通知は失敗率が上がる)| 項目 | 選択 | 理由 |
|---|---|---|
| ランタイム | 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実績 |
ユーザー → 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 記録
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は自分1人なので、発信先電話番号はレコードに持たず環境変数で管理:
.dev.vars / wrangler secret: MY_PHONE_NUMBER=+8190xxxxxxxxenv.MY_PHONE_NUMBER を参照複数ユーザー対応時にユーザーテーブル + 電話番号登録フローを追加する(セクション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対象)
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);
webhook_event_id UNIQUE — LINE Webhook 再送時の重複INSERT防止(冪等性)phone_number カラムなし — PoCは環境変数 MY_PHONE_NUMBER で固定pending / completed / failed(KISS)telnyx_call_id, called_at, failure_reason)で障害調査可能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)。
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 = "<作成後に自動設定>"
0-A: Telnyx 実機発信確認
scripts/test-call.ts で自分の携帯に電話をかける0-B: chrono-node/ja の日本語解析精度確認
scripts/test-chrono.ts で以下をテスト(Asia/Tokyo, forwardDate: true):明日9時 → 正しく翌日9:00になるか来週の火曜 10時 → 正しく次の火曜10:00になるか3/20 14:30 → 正しく3月20日14:30になるか今日の夕方 → どう解析されるか(曖昧表現)昨日の9時)→ forwardDateで未来に解釈されるか3月20日の二十二時 → ⚠️ chrono-node/ja は漢数字を正しく扱えない(二十二時 が 12時 に化けるバグ確認済み)三月二十日 → 月日の漢数字も非対応(正規表現が [0-90-9] のみ)kanjiToArabic() を parser.ts に実装(約10行)mkdir line-call-reminder && cd $_pnpm init → TypeScript + Vitest + Hono + wrangler セットアップwrangler d1 create reminder-db
wrangler.toml に D1 binding + Cron 設定を追記wrangler d1 execute reminder-db --local --file schema.sql
vitest --run で空テストが通ることを確認(Green の土台)Phase 0 で実機確認済みだが、ここでは caller.ts のユニットテストとモック方針を固める。
Red 2-1: makeCall('+8190xxxx', '歯医者の時間です') → { callId: string } を返すテスト(Telnyx APIをモック)→ 失敗
Green 2-1: caller.ts に makeCall 関数を実装 → テスト通過
Red 2-2: API エラー時に例外を投げるテスト → 失敗 Green 2-2: エラーハンドリング追加 → テスト通過
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: 挙動に応じた処理を実装 → テスト通過
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が呼ばれるテスト → 失敗
vi.fn())とLINE返信モック(vi.fn())のコール順序を記録し、D1が先に呼ばれることを確認
Green 4-5: D1 INSERT 成功後に LINE reply → テスト通過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形式でフォーマット → テスト通過
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 → テスト通過
wrangler deploy でデプロイwrangler secret put MY_PHONE_NUMBER で電話番号設定wrangler secret put TELNYX_API_KEY でAPIキー設定wrangler secret put LINE_CHANNEL_SECRET でLINEシークレット設定wrangler secret put LINE_CHANNEL_ACCESS_TOKEN でLINEトークン設定MY_PHONE_NUMBER に設定する番号| リスク | 影響 | 対策 |
|---|---|---|
| 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で確認 |
| 項目 | 月額 |
|---|---|
| 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円) |
| 状況 | 移行先 |
|---|---|
| 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)からかかります」と案内する |