LINE Call Reminder Bot は現在シングルユーザー設計 — env.MY_PHONE_NUMBER 固定で自分にしか電話できない。友達に試してもらうには、ユーザーごとの電話番号登録と通話コスト制御が必要。Phase 1 の TDD iteration 6 まで完了(全36テスト通過)の状態から継続する。
Codex (gpt-5.4) によるレビューで Critical 3件・High 3件の指摘を受け、以下を修正:
INSERT OR REPLACE → ON CONFLICT DO UPDATE — REPLACE は行削除→再挿入なので call_count がリセットされる脆弱性ALLOWED_LINE_USER_IDS 環境変数で許可制にstatus='processing' 導入予定として記録-- schema.sql に追加
CREATE TABLE users (
line_user_id TEXT PRIMARY KEY,
phone_number TEXT NOT NULL, -- E.164形式: +819012345678
call_count INTEGER DEFAULT 0, -- 今月の通話回数
call_count_month TEXT, -- リセット判定用: 'YYYY-MM'(JST基準)
created_at INTEGER DEFAULT (unixepoch())
);
# wrangler.toml に追加
ALLOWED_LINE_USER_IDS = "" # カンマ区切りの許可済みLINE User ID
# .dev.vars に追加
ALLOWED_LINE_USER_IDS=Uxxxx,Uyyyy
const FREE_MONTHLY_LIMIT = 10; // β版: 月10回まで無料
ファイル: src/phone.ts(新規), src/phone.test.ts(新規)
| テスト | 入力 | 期待値 |
|---|---|---|
| 090 番号を検出 | isPhoneNumber("09012345678") |
true |
| ハイフン付きを検出 | isPhoneNumber("090-1234-5678") |
true |
| 080/070 も検出 | isPhoneNumber("08012345678") |
true |
| リマインダーは false | isPhoneNumber("明日9時に歯医者") |
false |
| 桁数不足は false | isPhoneNumber("0901234567") |
false |
| E.164形式は false | isPhoneNumber("+819012345678") |
false(ユーザー入力としては非想定) |
| 前後空白は true | isPhoneNumber(" 09012345678 ") |
true(trim して判定) |
| 電話番号+テキストは false | isPhoneNumber("09012345678です") |
false(完全一致のみ) |
| 全角数字は false | isPhoneNumber("09012345678") |
false |
| E.164 に正規化 | normalizePhoneNumber("090-1234-5678") |
"+819012345678" |
| ハイフンなしも正規化 | normalizePhoneNumber("09012345678") |
"+819012345678" |
ファイル: src/index.ts, src/index.test.ts, schema.sql, worker-configuration.d.ts
| テスト | シナリオ | 期待動作 |
|---|---|---|
| 許可ユーザー + 電話番号 → 登録 | ALLOWED に含まれるユーザーが "09012345678" 送信 | users に INSERT + 確認返信 |
| ハイフン付き → 正規化保存 | "090-1234-5678" 送信 | +819012345678 で保存 |
| 確認返信の内容 | — | 「電話番号を登録したよ!」を含む |
| 番号再登録 → 番号のみ更新 | 既登録ユーザーが別番号送信 | phone_number のみ更新、call_count は維持 |
| 未許可ユーザー → 拒否 | ALLOWED に含まれないユーザーからのメッセージ | 「このBotはβ版で招待制だよ」返信 |
実装メモ:
isPhoneNumber() を webhook 冒頭で判定INSERT INTO users (line_user_id, phone_number) VALUES (?, ?) ON CONFLICT(line_user_id) DO UPDATE SET phone_number = excluded.phone_number(call_count/created_at を保持)env.ALLOWED_LINE_USER_IDS をカンマ分割、userId が含まれなければ即返信ファイル: src/index.ts, src/index.test.ts
| テスト | シナリオ | 期待動作 |
|---|---|---|
| 未登録 → 登録促す | users にいないユーザーが「明日9時に歯医者」 | 「電話番号を教えてね」返信、D1保存なし |
| 登録済み → 従来通り | users にいるユーザーが「明日9時に歯医者」 | リマインダー保存 + 確認返信 |
実装メモ:
SELECT phone_number, call_count, call_count_month FROM users WHERE line_user_id = ? を追加既存テストへの影響(詳細):
createMockDB() を拡張: users SELECT の応答をモック可能にするmockDB.prepare.mock.calls[0][0] で「最初の prepare = reminders INSERT」を前提にしているテストがある(4-4, 4-6, 4-7)→ users SELECT が先に来るため、インデックスが変わるcreateCronMockDB() も同様に users SELECT をハンドリングするよう拡張が必要ファイル: src/index.ts, src/cron.test.ts
| テスト | シナリオ | 期待動作 |
|---|---|---|
| users から番号取得 | pending リマインダーの user が登録済み | users.phone_number で makeCall |
| ユーザー未登録 | line_user_id に対応する users がない | status='failed', reason='ユーザー未登録' |
| MY_PHONE_NUMBER 不要 | env に MY_PHONE_NUMBER なし | 正常に動作 |
実装メモ:
handleScheduled のリマインダーループ内で SELECT phone_number FROM users WHERE line_user_id = ?env.MY_PHONE_NUMBER の fail-fast チェックを削除SELECT id, content, call_at, line_user_id FROM reminders ... に変更(line_user_id が必要)移行計画:
INSERT INTO users (line_user_id, phone_number) VALUES ('自分のLINE_USER_ID', '+81自分の番号');
ファイル: src/index.ts, src/cron.test.ts
| テスト | シナリオ | 期待動作 |
|---|---|---|
| 成功 → count +1 | 通話成功後 | UPDATE users SET call_count = call_count + 1, call_count_month = ? |
| 月初リセット | call_count_month が先月 | call_count = 1, call_count_month = 今月 に更新 |
| 同月 → 加算 | call_count_month が今月 | count に +1 のみ |
| call_count_month が null | 初回発信 | call_count = 1, call_count_month = 今月 に設定 |
| JST 月境界 | 2026-03-31 23:59 JST(UTC 14:59) | 3月としてカウント |
| JST 月境界 | 2026-04-01 00:01 JST(UTC 15:01前日) | 4月としてカウント |
実装メモ:
handleScheduled の通話成功ブロックに call_count 更新 SQL を追加new Date().toLocaleDateString('en-CA', { timeZone: 'Asia/Tokyo' }).slice(0, 7) で YYYY-MM を生成call_count_month が null または今月と不一致 → call_count = 1、一致 → call_count = call_count + 1ファイル: src/index.ts, src/index.test.ts
| テスト | シナリオ | 期待動作 |
|---|---|---|
| 上限到達 → 拒否 | call_count=8 + pending 2件 = 10 | 「今月の無料枠を使い切ったよ」返信、D1保存なし |
| 上限未到達 → 許可 | call_count=5 + pending 2件 = 7 | 通常通りリマインダー保存 |
| 先月の count → リセット扱い | call_count=10 だが先月 | completed count を 0 扱いで許可 |
| pending のみで上限到達 | call_count=0 + pending 10件 | 拒否 |
実装メモ:
SELECT COUNT(*) FROM reminders WHERE line_user_id = ? AND status = 'pending' を追加>= FREE_MONTHLY_LIMIT なら拒否call_count_month !== 今月 なら call_count を 0 として扱う(DB は更新しない、cron 側で更新する)| ファイル | 変更内容 |
|---|---|
schema.sql |
users テーブル追加 |
src/index.ts |
アクセス制御 + phone 分岐 + users 確認 + 上限チェック、cron に users 参照 + call_count 更新 |
src/index.test.ts |
mockDB を SQL パターンマッチ方式に刷新、既存テスト 6 件以上の mock 更新 |
src/cron.test.ts |
mockDB 拡張(users SELECT/UPDATE)、MY_PHONE_NUMBER 依存除去 |
wrangler.toml |
ALLOWED_LINE_USER_IDS 環境変数追加 |
worker-configuration.d.ts |
ALLOWED_LINE_USER_IDS: string 追加、MY_PHONE_NUMBER をオプショナルに(最後に wrangler types で再生成) |
| ファイル | 内容 |
|---|---|
src/phone.ts |
isPhoneNumber(), normalizePhoneNumber() |
src/phone.test.ts |
上記のテスト |
status='processing' による原子的 claim(既存の Phase 1 からの問題)VONAGE_FROM_NUMBER を差し替え各イテレーションの RED(テスト作成)は私が担当。GREEN(実装)は以下で Codex に委譲:
~/bin/codex-full exec "テストを通して。変更は最小限に。"
REFACTOR は実装後に私がレビューして判断。
各イテレーション完了時:
pnpm exec tsc --noEmit — 型チェックpnpm test — 全テスト通過全イテレーション完了後:
09012345678 を送信 → 登録確認返信明日9時に歯医者 を送信 → リマインダー登録