はじめに
LINEにリマインダー機能がついていると便利だなぁと思い、完全無料で動くリマインダーBotを作りました。
LINE BotはWebhookエンドポイントが1つしかないため、メッセージ内のテキスト情報だけで処理を振り分ける必要があります。
ただ、ルールベースでメッセージ制御をしようとすると、厳格なコマンドフォーマットに縛られ、人間らしい自然な言葉での依頼ができなくなってしまいます。
そのため、今回はAI(LLM)を挟んで自然言語の意味解析を行い、意図に応じた適切な処理へルーティングする設計にしました。
従来のリマインダーBot開発で課題だった 「GASによる通知間隔のアバウトさ」 と 「ルールベースの日時解析(言葉の揺らぎに弱い点)」 を、Cloudflare Workers + D1 で解決しています。
成果物 (デモ)
インフラ構成図
本プロジェクトのインフラ構成です。Cloudflareの無料枠内で完結するよう設計しています。
コンポーネントの役割
- Cloudflare Workers (Hono): Webhookの受け口と全体のルーティングを担当。
- OpenRouter (Gemini 2.5 Flash Lite): ユーザーの自然言語を解析し、プログラムが処理しやすい JSON に変換します。高速で安価なモデルを採用しています。
- Cloudflare D1: エッジで動くSQLite。タスクを保存します。
- Cron Triggers: 毎分0秒に起動し、通知時間になったタスクを拾い上げてPush通知します。
アプリケーションアーキテクチャ
処理の流れは大きく分けて 「タスク登録時のWebhookフロー」 と 「定時通知時のCronフロー」 の2つです。
1. メッセージ受信時のデータフロー (Webhook Flow)
ここでのポイントは、正規表現やルールベースの解析を一切行わず、AIに丸投げしている点です。
2. 定期通知のデータフロー (Cron Flow)
Cloudflare Workers の Cron Triggers を使い、毎分起動させてD1をポーリングします。
肝となるAI連携部分 (プロンプト設計)
本システムの心臓部であるAIへのプロンプトです。現在時刻(日本時間)をプロンプトに埋め込むことで、「明日」「1時間後」といった相対的な時間表現をAIに正しく計算させています。また、response_format: { type: "json_object" } を指定し、確実にJSONで返答させています。
ソースコード一式 (完全版)
ディレクトリ構成は以下の通りです。
コードは可読性を高めるため、ルーティング(index.ts)、AI層(ai.ts)、ビジネスロジック(webhook.ts, cron.ts)に綺麗に分離しています。
.
├── package-lock.json
├── package.json
├── schema.sql
├── src
│ ├── controllers
│ │ ├── cron.ts
│ │ └── webhook.ts
│ ├── index.ts
│ ├── services
│ │ ├── ai.ts
│ │ ├── line.ts
│ │ └── reminder.ts
│ ├── types.ts
│ └── utils
│ ├── crypto.ts
│ └── date.ts
├── tsconfig.json
└── wrangler.toml
4 directories, 14 files
ソースコードを展開する (全ファイル略さず掲載)
wrangler.toml
name = "line-reminder-bot"
main = "src/index.ts"
compatibility_date = "2026-07-01"
# 1分おきの Cron トリガーを設定
[triggers]
crons = ["* * * * *"]
# Cloudflare D1 バインディング
[[d1_databases]]
binding = "DB"
database_name = "line_reminders_db"
database_id = "34a1a0f2-2578-4762-aefe-9f043dca1096"
schema.sql
-- タスク管理テーブル
CREATE TABLE IF NOT EXISTS reminders (
id INTEGER PRIMARY KEY AUTOINCREMENT,
user_id TEXT NOT NULL, -- LINE User ID (例: U1234567890abcdef...)
message TEXT NOT NULL, -- リマインドするタスク内容
remind_at INTEGER NOT NULL, -- 通知予定日時 (UNIX Timestamp 秒)
status TEXT DEFAULT 'pending', -- ステータス: 'pending' (未通知) | 'sent' (通知済) | 'canceled' (キャンセル)
created_at INTEGER DEFAULT (unixepoch())
);
-- スケジューラーによるポーリング検索最適化用インデックス
CREATE INDEX IF NOT EXISTS idx_reminders_polling ON reminders (status, remind_at);
package.json
{
"name": "line-reminder-bot",
"version": "1.0.0",
"description": "LINE リマインダー Bot powered by Cloudflare Workers + Hono + D1",
"private": true,
"scripts": {
"dev": "wrangler dev",
"deploy": "wrangler deploy",
"db:create": "wrangler d1 create line_reminders_db",
"db:migrate:local": "wrangler d1 execute line_reminders_db --local --file=./schema.sql",
"db:migrate:remote": "wrangler d1 execute line_reminders_db --remote --file=./schema.sql",
"type-check": "tsc --noEmit"
},
"dependencies": {
"hono": "^4.7.11"
},
"devDependencies": {
"@cloudflare/workers-types": "^5.20260714.1",
"typescript": "^5.8.3",
"wrangler": "^4.22.0"
}
}
tsconfig.json
{
"compilerOptions": {
"target": "ESNext",
"module": "ESNext",
"moduleResolution": "Bundler",
"lib": ["ESNext", "WebWorker"],
"strict": true,
"noEmit": true,
"skipLibCheck": true,
"types": ["@cloudflare/workers-types"]
},
"include": ["src/**/*.ts"],
"exclude": ["node_modules"]
}
src/index.ts
import { Hono } from "hono";
import type { Bindings, LineWebhookPayload } from "./types";
import { verifyLineSignature } from "./utils/crypto";
import { handleEvent } from "./controllers/webhook";
import { runScheduledJob } from "./controllers/cron";
// ─────────────────────────────────────────────
// Hono アプリケーション初期化
// ─────────────────────────────────────────────
const app = new Hono<{ Bindings: Bindings }>();
// ─────────────────────────────────────────────
// POST /webhook LINE Webhook エンドポイント
// ─────────────────────────────────────────────
app.post("/webhook", async (c) => {
const { DB, LINE_CHANNEL_ACCESS_TOKEN, LINE_CHANNEL_SECRET, OPENROUTER_API_KEY } = c.env;
const rawBody = await c.req.text();
console.log(`[webhook] Received body length: ${rawBody.length}`);
const signature = c.req.header("X-Line-Signature") ?? "";
const isValid = await verifyLineSignature(LINE_CHANNEL_SECRET, rawBody, signature);
if (!isValid) {
console.warn("[webhook] Invalid signature detected");
return c.json({ error: "Unauthorized" }, 401);
}
let payload: LineWebhookPayload;
try {
payload = JSON.parse(rawBody) as LineWebhookPayload;
} catch {
return c.json({ error: "Bad Request: invalid JSON" }, 400);
}
// 各イベントを並行処理
await Promise.all(
payload.events.map((event) => handleEvent(event, DB, LINE_CHANNEL_ACCESS_TOKEN, OPENROUTER_API_KEY))
);
// LINE Webhook には必ず 200 OK を返す
return c.json({ status: "ok" });
});
// ─────────────────────────────────────────────
// GET / ヘルスチェックエンドポイント
// ─────────────────────────────────────────────
app.get("/", (c) => {
return c.json({
status: "healthy",
service: "LINE リマインダー Bot (AI powered)",
aiModel: "google/gemini-2.5-flash-lite",
timestamp: new Date().toISOString(),
});
});
// ─────────────────────────────────────────────
// Cloudflare Workers エクスポート
// ─────────────────────────────────────────────
export default {
/** HTTP リクエストハンドラー (Hono) */
fetch: app.fetch,
/** Cron Triggers ハンドラー (毎分実行) */
async scheduled(_event: ScheduledEvent, env: Bindings, ctx: ExecutionContext): Promise<void> {
ctx.waitUntil(runScheduledJob(env));
},
};
src/types.ts
/**
* Cloudflare Workers のバインディング型定義
*/
export interface Bindings {
/** Cloudflare D1 データベース */
DB: D1Database;
/** LINE Channel Access Token */
LINE_CHANNEL_ACCESS_TOKEN: string;
/** LINE Channel Secret (署名検証用) */
LINE_CHANNEL_SECRET: string;
/** OpenRouter API キー (AI中継レイヤー用) */
OPENROUTER_API_KEY: string;
}
/**
* D1 `reminders` テーブルのレコード型
*/
export interface Reminder {
id: number;
user_id: string;
message: string;
/** 通知予定日時 (UNIX タイムスタンプ 秒) */
remind_at: number;
/** 'pending' | 'sent' | 'canceled' */
status: ReminderStatus;
created_at: number;
}
export type ReminderStatus = "pending" | "sent" | "canceled";
// ─────────────────────────────────────────────
// LINE Webhook ペイロード型定義
// ─────────────────────────────────────────────
export interface LineWebhookPayload {
destination: string;
events: LineEvent[];
}
export type LineEvent = LineMessageEvent | LineFollowEvent | LineUnfollowEvent;
export interface LineBaseEvent {
type: string;
mode: "active" | "standby" | "chat";
timestamp: number;
source: LineSource;
webhookEventId: string;
deliveryContext: {
isRedelivery: boolean;
};
}
export interface LineMessageEvent extends LineBaseEvent {
type: "message";
replyToken: string;
message: LineTextMessage | LineUnsupportedMessage;
}
export interface LineFollowEvent extends LineBaseEvent {
type: "follow";
replyToken: string;
}
export interface LineUnfollowEvent extends LineBaseEvent {
type: "unfollow";
}
export type LineSource =
| { type: "user"; userId: string }
| { type: "group"; groupId: string; userId?: string }
| { type: "room"; roomId: string; userId?: string };
export interface LineTextMessage {
type: "text";
id: string;
text: string;
quoteToken: string;
}
export interface LineUnsupportedMessage {
type: Exclude<string, "text">;
id: string;
}
src/utils/crypto.ts
/**
* LINE Webhook の署名検証ユーティリティ
*
* Cloudflare Workers の Web Crypto API (SubtleCrypto) を使用。
* Node.js の `crypto` モジュールは Workers 環境では使用できないため、
* グローバルの `crypto.subtle` を利用する。
*/
/**
* HMAC-SHA256 で署名を検証する
*
* @param channelSecret LINE Channel Secret
* @param body リクエストボディ (raw string)
* @param signature ヘッダー `X-Line-Signature` の値 (Base64)
* @returns 検証成功なら true
*/
export async function verifyLineSignature(
channelSecret: string,
body: string,
signature: string
): Promise<boolean> {
try {
// Channel Secret を CryptoKey にインポート
const encoder = new TextEncoder();
const keyData = encoder.encode(channelSecret);
const cryptoKey = await crypto.subtle.importKey(
"raw",
keyData,
{ name: "HMAC", hash: "SHA-256" },
false,
["sign"]
);
// ボディを HMAC-SHA256 で署名
const bodyData = encoder.encode(body);
const signatureBuffer = await crypto.subtle.sign("HMAC", cryptoKey, bodyData);
// ArrayBuffer を Base64 に変換
const computedSignature = arrayBufferToBase64(signatureBuffer);
// 定数時間比較(タイミング攻撃対策)
return timingSafeEqual(computedSignature, signature);
} catch (err) {
console.error("[crypto] Signature verification error:", err);
return false;
}
}
/**
* ArrayBuffer を Base64 文字列に変換する
*/
function arrayBufferToBase64(buffer: ArrayBuffer): string {
const bytes = new Uint8Array(buffer);
let binary = "";
for (const byte of bytes) {
binary += String.fromCharCode(byte);
}
return btoa(binary);
}
/**
* タイミング攻撃を防ぐための定数時間文字列比較
*/
function timingSafeEqual(a: string, b: string): boolean {
if (a.length !== b.length) return false;
let result = 0;
for (let i = 0; i < a.length; i++) {
result |= a.charCodeAt(i) ^ b.charCodeAt(i);
}
return result === 0;
}
src/utils/date.ts
/**
* 日時を日本語フォーマットの文字列に変換する
*
* 例: 2026/07/20 15:00
*/
export function formatDateJa(date: Date): string {
// Cloudflare Workers はデフォルト UTC なので、9時間足して JST にする
const jstDate = new Date(date.getTime() + 9 * 60 * 60 * 1000);
const year = jstDate.getUTCFullYear();
const month = String(jstDate.getUTCMonth() + 1).padStart(2, "0");
const day = String(jstDate.getUTCDate()).padStart(2, "0");
const hours = String(jstDate.getUTCHours()).padStart(2, "0");
const minutes = String(jstDate.getUTCMinutes()).padStart(2, "0");
return `${year}/${month}/${day} ${hours}:${minutes}`;
}
src/services/ai.ts
/**
* AI 中継レイヤー (OpenRouter / Gemini Flash Lite)
*
* ユーザーの自由な日本語メッセージを解析し、Botコントローラーが
* 実行できる構造化された Intent + パラメータに変換する。
*
* モデル: google/gemini-2.0-flash-lite-001 (最安値帯)
*/
// ─────────────────────────────────────────────
// 型定義
// ─────────────────────────────────────────────
/** AIが返す Intent の種別 */
export type AIIntent =
| "register" // リマインダー新規登録
| "list" // 一覧表示
| "cancel" // キャンセル
| "reschedule" // 日時変更
| "unknown"; // 判断不能
/** AIの解析結果 */
export interface AIResponse {
/** Intent 種別 */
intent: AIIntent;
/** タスク内容 (intent=register の時に使用) */
taskMessage?: string;
/** リマインド予定日時 ISO8601 (intent=register の時に使用) */
remindAt?: string;
/** 操作対象のインデックス 1-indexed (intent=cancel/reschedule の時に使用) */
targetIndex?: number;
/** 新しいリマインド日時 ISO8601 (intent=reschedule の時に使用) */
newRemindAt?: string;
/** 不明理由 (intent=unknown の時に使用) */
unknownReason?: string;
}
// ─────────────────────────────────────────────
// OpenRouter API 定数
// ─────────────────────────────────────────────
const OPENROUTER_API_URL = "https://openrouter.ai/api/v1/chat/completions";
const OPENROUTER_MODEL = "google/gemini-2.5-flash-lite";
// ─────────────────────────────────────────────
// システムプロンプト生成
// ─────────────────────────────────────────────
/**
* AIに送るシステムプロンプトを生成する
*
* 現在時刻をプロンプトに埋め込むことで「明日」「1時間後」などの
* 相対的な時間表現を正確に解析できるようにする。
*
* @param nowISO 現在時刻の ISO8601 文字列 (Asia/Tokyo)
*/
function buildSystemPrompt(nowISO: string): string {
return `あなたはLINEリマインダーBotのAIアシスタントです。
ユーザーのメッセージを解析し、以下のJSON形式のみで返答してください。余分なテキストや説明、マークダウン記法は一切不要です。JSONのみを返してください。
現在時刻: ${nowISO} (Asia/Tokyo タイムゾーン)
## Intentの分類ルール
- "register": リマインダーの新規登録。「〇〇を覚えておいて」「〇時に〇〇」「〇〇を忘れないように」など
- "list": 登録済みリマインダーの一覧表示。「一覧」「リスト」「何があった」「確認」など
- "cancel": リマインダーのキャンセル/削除。「消して」「キャンセル」「削除」など。N番目の指定があればtargetIndexに入れる
- "reschedule": リマインダーの日時変更。「変更」「ずらして」「遅らせて」など。N番目の指定があればtargetIndexに入れる
- "unknown": 上記に当てはまらない場合
## 日時の解析ルール
- すべての日時はISO8601形式(例: "2026-07-21T09:00:00+09:00")で返す
- 「明日」→ 現在時刻の翌日 09:00:00 を基準とする(特定の時刻の言及がない場合)
- 「朝」→ 08:00、「午前中」→ 10:00、「昼」→ 12:00、「夕方」→ 17:00、「夜」→ 20:00、「寝る前」→ 22:00
- 「〇分後」「〇時間後」は現在時刻から計算する
- 「来週」→ 現在時刻の7日後の同時刻
## 出力フォーマット例
リマインダー登録:
{"intent":"register","taskMessage":"牛乳を買う","remindAt":"2026-07-21T15:00:00+09:00"}
一覧表示:
{"intent":"list"}
キャンセル (N番目):
{"intent":"cancel","targetIndex":2}
日時変更:
{"intent":"reschedule","targetIndex":1,"newRemindAt":"2026-07-22T17:00:00+09:00"}
不明:
{"intent":"unknown","unknownReason":"意図が判断できませんでした"}`;
}
// ─────────────────────────────────────────────
// メイン関数
// ─────────────────────────────────────────────
/**
* ユーザーメッセージをAIで解析し、構造化された Intent を返す
*
* @param userText LINE から受信したテキストメッセージ
* @param apiKey OpenRouter API キー
* @returns AIResponse (解析失敗時は intent="unknown" を返す)
*/
export async function analyzeWithAI(
userText: string,
apiKey: string
): Promise<AIResponse> {
// 現在時刻を Asia/Tokyo で取得
const nowISO = new Date().toLocaleString("sv-SE", { timeZone: "Asia/Tokyo" })
.replace(" ", "T") + "+09:00";
const systemPrompt = buildSystemPrompt(nowISO);
let rawResponse: string;
try {
const response = await fetch(OPENROUTER_API_URL, {
method: "POST",
headers: {
"Content-Type": "application/json",
Authorization: `Bearer ${apiKey}`,
"HTTP-Referer": "https://line-reminder-bot.workers.dev",
"X-Title": "LINE Reminder Bot",
},
body: JSON.stringify({
model: OPENROUTER_MODEL,
messages: [
{
role: "system",
content: systemPrompt,
},
{
role: "user",
content: userText,
},
],
// JSONのみを確実に返させる
response_format: { type: "json_object" },
temperature: 0.1,
max_tokens: 256,
}),
});
if (!response.ok) {
const errorText = await response.text();
console.error(`[AI] OpenRouter API error: ${response.status} - ${errorText}`);
return { intent: "unknown", unknownReason: `AI APIエラー: ${response.status}` };
}
const data = await response.json() as OpenRouterResponse;
rawResponse = data.choices?.[0]?.message?.content ?? "";
console.log(`[AI] Raw response: ${rawResponse}`);
} catch (err) {
console.error("[AI] Network error calling OpenRouter:", err);
return { intent: "unknown", unknownReason: "AI通信エラー" };
}
// JSONパース & バリデーション
return parseAIResponse(rawResponse);
}
// ─────────────────────────────────────────────
// パース & バリデーション
// ─────────────────────────────────────────────
/**
* AIのレスポンス文字列をパースしてAIResponseに変換する
*
* パースに失敗した場合は intent="unknown" を返す(例外は投げない)
*/
function parseAIResponse(raw: string): AIResponse {
try {
// AIがマークダウンのコードブロックで囲んでくる場合を考慮して除去
const cleaned = raw
.replace(/^```json\s*/i, "")
.replace(/^```\s*/i, "")
.replace(/```\s*$/i, "")
.trim();
const parsed = JSON.parse(cleaned) as Partial<AIResponse>;
// intent は必須
const validIntents: AIIntent[] = ["register", "list", "cancel", "reschedule", "unknown"];
if (!parsed.intent || !validIntents.includes(parsed.intent)) {
console.warn("[AI] Invalid intent in response:", parsed);
return { intent: "unknown", unknownReason: "AIレスポンスのintentが不正" };
}
// register の場合: taskMessage と remindAt が必要
if (parsed.intent === "register") {
if (!parsed.taskMessage || !parsed.remindAt) {
console.warn("[AI] register intent missing taskMessage or remindAt:", parsed);
return { intent: "unknown", unknownReason: "タスク名または日時が解析できませんでした" };
}
// remindAt が有効な日時かチェック
const date = new Date(parsed.remindAt);
if (isNaN(date.getTime())) {
console.warn("[AI] register intent has invalid remindAt:", parsed.remindAt);
return { intent: "unknown", unknownReason: "日時の形式が不正です" };
}
}
// reschedule の場合: newRemindAt が必要
if (parsed.intent === "reschedule" && parsed.newRemindAt) {
const date = new Date(parsed.newRemindAt);
if (isNaN(date.getTime())) {
console.warn("[AI] reschedule intent has invalid newRemindAt:", parsed.newRemindAt);
return { intent: "unknown", unknownReason: "新しい日時の形式が不正です" };
}
}
return {
intent: parsed.intent,
taskMessage: parsed.taskMessage,
remindAt: parsed.remindAt,
targetIndex: parsed.targetIndex,
newRemindAt: parsed.newRemindAt,
unknownReason: parsed.unknownReason,
};
} catch (err) {
console.error("[AI] JSON parse error:", err, "raw:", raw);
return { intent: "unknown", unknownReason: "AIレスポンスのパースに失敗しました" };
}
}
// ─────────────────────────────────────────────
// OpenRouter APIレスポンス型
// ─────────────────────────────────────────────
interface OpenRouterResponse {
choices?: Array<{
message?: {
content?: string;
};
}>;
}
src/services/line.ts
/**
* LINE Messaging API クライアント
*
* Reply API と Push API の 2 種類の送信方法を提供する。
* - Reply API: Webhook イベント受信時に使用 (replyToken が必要)
* - Push API: Cron スケジューラーからの通知時に使用 (userId が必要)
*/
const LINE_API_BASE = "https://api.line.me/v2/bot/message";
/**
* LINE Reply API でメッセージを送信する
*
* @param replyToken Webhook イベントの replyToken
* @param messages 送信するメッセージの配列 (最大 5 件)
* @param accessToken LINE Channel Access Token
*/
export async function replyMessage(
replyToken: string,
messages: LineTextMessageObject[],
accessToken: string
): Promise<void> {
const response = await fetch(`${LINE_API_BASE}/reply`, {
method: "POST",
headers: {
"Content-Type": "application/json",
Authorization: `Bearer ${accessToken}`,
},
body: JSON.stringify({ replyToken, messages }),
});
if (!response.ok) {
const errorBody = await response.text();
throw new Error(
`LINE Reply API error: ${response.status} ${response.statusText} - ${errorBody}`
);
}
}
/**
* LINE Push API でメッセージを送信する
*
* @param to 送信先 LINE ユーザー ID
* @param messages 送信するメッセージの配列 (最大 5 件)
* @param accessToken LINE Channel Access Token
*/
export async function pushMessage(
to: string,
messages: LineTextMessageObject[],
accessToken: string
): Promise<void> {
const response = await fetch(`${LINE_API_BASE}/push`, {
method: "POST",
headers: {
"Content-Type": "application/json",
Authorization: `Bearer ${accessToken}`,
},
body: JSON.stringify({ to, messages }),
});
if (!response.ok) {
const errorBody = await response.text();
throw new Error(
`LINE Push API error: ${response.status} ${response.statusText} - ${errorBody}`
);
}
}
/**
* LINE Text Message オブジェクトを生成するヘルパー
*/
export function textMessage(text: string): LineTextMessageObject {
return { type: "text", text };
}
/**
* LINE テキストメッセージオブジェクト型
*/
export interface LineTextMessageObject {
type: "text";
text: string;
}
src/services/reminder.ts
import type { Reminder, ReminderStatus } from "../types";
/**
* D1 データベース操作クエリ集
*
* reminders テーブルへの CRUD 操作を提供する。
*/
/**
* リマインダーを D1 に INSERT する
*
* @param db D1Database バインディング
* @param userId LINE ユーザー ID
* @param message タスク内容
* @param remindAt 通知予定日時 (Date オブジェクト)
* @returns 挿入されたレコードの ID
*/
export async function insertReminder(
db: D1Database,
userId: string,
message: string,
remindAt: Date
): Promise<number> {
const remindAtUnix = Math.floor(remindAt.getTime() / 1000);
const result = await db
.prepare(
`INSERT INTO reminders (user_id, message, remind_at, status)
VALUES (?, ?, ?, 'pending')`
)
.bind(userId, message, remindAtUnix)
.run();
if (!result.success) {
throw new Error(`D1 INSERT failed: ${JSON.stringify(result)}`);
}
// D1 の meta.last_row_id で挿入された ID を取得
return result.meta.last_row_id as number;
}
/**
* 通知対象の pending リマインダーを取得する
*
* `status = 'pending'` かつ `remind_at <= 現在時刻` のレコードを返す。
* Cron スケジューラーから毎分呼び出される。
*
* @param db D1Database バインディング
* @param nowUnix 現在時刻の UNIX タイムスタンプ (秒)
* @returns 通知対象のリマインダー配列
*/
export async function fetchDueReminders(
db: D1Database,
nowUnix: number
): Promise<Reminder[]> {
const result = await db
.prepare(
`SELECT id, user_id, message, remind_at, status, created_at
FROM reminders
WHERE status = 'pending' AND remind_at <= ?
ORDER BY remind_at ASC`
)
.bind(nowUnix)
.all<Reminder>();
return result.results;
}
/**
* リマインダーのステータスを更新する
*
* @param db D1Database バインディング
* @param id 更新対象のリマインダー ID
* @param status 新しいステータス
*/
export async function updateReminderStatus(
db: D1Database,
id: number,
status: ReminderStatus
): Promise<void> {
const result = await db
.prepare(`UPDATE reminders SET status = ? WHERE id = ?`)
.bind(status, id)
.run();
if (!result.success) {
throw new Error(`D1 UPDATE failed for id=${id}: ${JSON.stringify(result)}`);
}
}
/**
* 特定ユーザーの pending リマインダー一覧を取得する
*
* remind_at 昇順でソートして返す。コマンドの「N番目」と連番が一致する。
*
* @param db D1Database バインディング
* @param userId LINE ユーザー ID
* @param limit 取得上限件数 (デフォルト 10)
* @returns pending リマインダーの配列
*/
export async function fetchPendingReminders(
db: D1Database,
userId: string,
limit = 10
): Promise<Reminder[]> {
const result = await db
.prepare(
`SELECT id, user_id, message, remind_at, status, created_at
FROM reminders
WHERE status = 'pending' AND user_id = ?
ORDER BY remind_at ASC
LIMIT ?`
)
.bind(userId, limit)
.all<Reminder>();
return result.results;
}
/**
* リマインダーのタスク本文を更新する
*
* @param db D1Database バインディング
* @param id 更新対象のリマインダー ID
* @param message 新しいタスク内容
*/
export async function updateReminderMessage(
db: D1Database,
id: number,
message: string
): Promise<void> {
const result = await db
.prepare(`UPDATE reminders SET message = ? WHERE id = ?`)
.bind(message, id)
.run();
if (!result.success) {
throw new Error(`D1 UPDATE message failed for id=${id}: ${JSON.stringify(result)}`);
}
}
/**
* リマインダーのリマインド日時を更新する
*
* @param db D1Database バインディング
* @param id 更新対象のリマインダー ID
* @param remindAt 新しい通知予定日時 (Date オブジェクト)
*/
export async function updateReminderTime(
db: D1Database,
id: number,
remindAt: Date
): Promise<void> {
const remindAtUnix = Math.floor(remindAt.getTime() / 1000);
const result = await db
.prepare(`UPDATE reminders SET remind_at = ? WHERE id = ?`)
.bind(remindAtUnix, id)
.run();
if (!result.success) {
throw new Error(`D1 UPDATE remind_at failed for id=${id}: ${JSON.stringify(result)}`);
}
}
src/controllers/webhook.ts
import type { LineMessageEvent, LineTextMessage, LineWebhookPayload } from "../types";
import { analyzeWithAI } from "../services/ai";
import { replyMessage, textMessage } from "../services/line";
import { insertReminder, fetchPendingReminders, updateReminderStatus, updateReminderTime } from "../services/reminder";
import { formatDateJa } from "../utils/date";
/** 数値の絵文字連番 (1〜10) */
const NUMBER_EMOJIS = ["1️⃣", "2️⃣", "3️⃣", "4️⃣", "5️⃣", "6️⃣", "7️⃣", "8️⃣", "9️⃣", "🔟"];
/**
* LINE イベントを処理するハンドラー
* ユーザーメッセージを AI で解析し、対応するコントローラーを実行する。
*/
export async function handleEvent(
event: LineWebhookPayload["events"][number],
db: D1Database,
accessToken: string,
openrouterApiKey: string
): Promise<void> {
// テキストメッセージイベントのみ処理
if (event.type !== "message") return;
const messageEvent = event as LineMessageEvent;
if (messageEvent.message.type !== "text") return;
const textMsg = messageEvent.message as LineTextMessage;
const userId = messageEvent.source.type === "user" ? messageEvent.source.userId : undefined;
if (!userId) {
console.warn("[handleEvent] Could not determine userId from source:", messageEvent.source);
return;
}
const userText = textMsg.text.trim();
const replyToken = messageEvent.replyToken;
console.log(`[handleEvent] userId=${userId}, text="${userText}"`);
// ─────────────────────────────────────────────
// AI 解析フェーズ: 自然言語 → 構造化 Intent
// ─────────────────────────────────────────────
console.log(`[handleEvent] Calling AI analysis...`);
const aiResult = await analyzeWithAI(userText, openrouterApiKey);
console.log(`[handleEvent] AI result:`, JSON.stringify(aiResult));
// ─────────────────────────────────────────────
// Intent に応じたビジネスロジック実行
// ─────────────────────────────────────────────
switch (aiResult.intent) {
case "register":
await handleRegisterIntent(db, userId, aiResult.taskMessage!, aiResult.remindAt!, replyToken, accessToken);
break;
case "list":
await handleListCommand(db, userId, replyToken, accessToken);
break;
case "cancel":
await handleCancelCommand(db, userId, aiResult.targetIndex ?? 1, replyToken, accessToken);
break;
case "reschedule":
await handleRescheduleIntent(db, userId, aiResult.targetIndex ?? 1, aiResult.newRemindAt!, replyToken, accessToken);
break;
case "unknown":
default:
await handleUnknownIntent(aiResult.unknownReason, replyToken, accessToken);
break;
}
}
// ─────────────────────────────────────────────
// サブハンドラー群
// ─────────────────────────────────────────────
async function handleRegisterIntent(
db: D1Database,
userId: string,
taskMessage: string,
remindAtISO: string,
replyToken: string,
accessToken: string
): Promise<void> {
const remindAt = new Date(remindAtISO);
if (remindAt <= new Date()) {
await replyMessage(
replyToken,
[textMessage("⏰ 指定された日時はすでに過去だよ!\n未来の日時で再度送ってみてね😊")],
accessToken
);
return;
}
try {
await insertReminder(db, userId, taskMessage, remindAt);
const formattedDate = formatDateJa(remindAt);
await replyMessage(
replyToken,
[textMessage(`了解!\n📅 ${formattedDate}\n📝 ${taskMessage}\nをお知らせするね!\n\n📋 「一覧」でリマインダー一覧を確認できるよ!`)],
accessToken
);
} catch (err) {
console.error("[handleRegisterIntent] DB insert error:", err);
await replyMessage(
replyToken,
[textMessage("⚠️ 保存中にエラーが発生しました。もう一度試してみてね。")],
accessToken
);
}
}
async function handleListCommand(
db: D1Database,
userId: string,
replyToken: string,
accessToken: string
): Promise<void> {
try {
const reminders = await fetchPendingReminders(db, userId);
if (reminders.length === 0) {
await replyMessage(
replyToken,
[textMessage("📭 現在、登録中のリマインダーはないよ!\n\n「いつ + 何を」の形で送ると登録できるよ😊")],
accessToken
);
return;
}
const lines: string[] = ["📋 あなたのリマインダー一覧\n"];
for (let i = 0; i < reminders.length; i++) {
const r = reminders[i];
const emoji = NUMBER_EMOJIS[i] ?? `${i + 1}.`;
const date = formatDateJa(new Date(r.remind_at * 1000));
lines.push(`${emoji} ${r.message}\n 📅 ${date}\n`);
}
lines.push("「キャンセル N」で削除、「変更 N 日時」で変更できるよ✏️");
await replyMessage(replyToken, [textMessage(lines.join("\n"))], accessToken);
} catch (err) {
console.error("[handleListCommand] DB error:", err);
await replyMessage(
replyToken,
[textMessage("⚠️ 一覧の取得中にエラーが発生したよ。もう一度試してみてね。")],
accessToken
);
}
}
async function handleCancelCommand(
db: D1Database,
userId: string,
index: number,
replyToken: string,
accessToken: string
): Promise<void> {
try {
const reminders = await fetchPendingReminders(db, userId);
const target = reminders[index - 1];
if (!target) {
await replyMessage(
replyToken,
[textMessage(`❓ ${index}番のリマインダーは見つからなかったよ。\n「一覧」で番号を確認してね!`)],
accessToken
);
return;
}
await updateReminderStatus(db, target.id, "canceled");
const date = formatDateJa(new Date(target.remind_at * 1000));
await replyMessage(
replyToken,
[textMessage(`✅ キャンセルしたよ!\n\n📝 ${target.message}\n📅 ${date}\n\nタスクを削除したよ🗑️`)],
accessToken
);
} catch (err) {
console.error("[handleCancelCommand] error:", err);
await replyMessage(
replyToken,
[textMessage("⚠️ キャンセル処理中にエラーが発生したよ。もう一度試してみてね。")],
accessToken
);
}
}
async function handleRescheduleIntent(
db: D1Database,
userId: string,
index: number,
newRemindAtISO: string,
replyToken: string,
accessToken: string
): Promise<void> {
try {
const reminders = await fetchPendingReminders(db, userId);
const target = reminders[index - 1];
if (!target) {
await replyMessage(
replyToken,
[textMessage(`❓ ${index}番のリマインダーは見つからなかったよ。\n「一覧」で番号を確認してね!`)],
accessToken
);
return;
}
const newRemindAt = new Date(newRemindAtISO);
if (isNaN(newRemindAt.getTime())) {
await replyMessage(
replyToken,
[textMessage("⏰ 新しい日時が読み取れなかったよ。\n例:「1番を明日の17時に変更して」")],
accessToken
);
return;
}
await updateReminderTime(db, target.id, newRemindAt);
const oldDate = formatDateJa(new Date(target.remind_at * 1000));
const newDate = formatDateJa(newRemindAt);
await replyMessage(
replyToken,
[textMessage(`✏️ 変更したよ!\n\n📝 ${target.message}\n📅 ${oldDate} → ${newDate}\n\n新しい日時でお知らせするね!`)],
accessToken
);
} catch (err) {
console.error("[handleRescheduleIntent] error:", err);
await replyMessage(
replyToken,
[textMessage("⚠️ 変更処理中にエラーが発生したよ。もう一度試してみてね。")],
accessToken
);
}
}
async function handleUnknownIntent(
unknownReason: string | undefined,
replyToken: string,
accessToken: string
): Promise<void> {
await replyMessage(
replyToken,
[
textMessage(
`🤔 ${unknownReason ?? "うまく解釈できなかったよ"}\n\n` +
`💡 こんな感じで送ってみてね!\n` +
`・「明日の15時に牛乳を買う」\n` +
`・「1時間後に薬を飲む」\n` +
`・「一覧」でリマインダー確認\n` +
`・「キャンセル 2」で削除`
),
],
accessToken
);
}
src/controllers/cron.ts
import type { Bindings } from "../types";
import { fetchDueReminders, updateReminderStatus } from "../services/reminder";
import { pushMessage, textMessage } from "../services/line";
/**
* スケジューラーのメインロジック
* 毎分実行され、通知時間になった pending リマインダーをユーザーに Push 通知する
*/
export async function runScheduledJob(env: Bindings): Promise<void> {
const { DB, LINE_CHANNEL_ACCESS_TOKEN } = env;
const nowUnix = Math.floor(Date.now() / 1000);
console.log(`[scheduler] Running at ${new Date().toISOString()}, nowUnix=${nowUnix}`);
let dueReminders;
try {
dueReminders = await fetchDueReminders(DB, nowUnix);
} catch (err) {
console.error("[scheduler] Failed to fetch due reminders:", err);
return;
}
if (dueReminders.length === 0) {
console.log("[scheduler] No due reminders found.");
return;
}
console.log(`[scheduler] Found ${dueReminders.length} due reminder(s).`);
for (const reminder of dueReminders) {
try {
await pushMessage(
reminder.user_id,
[textMessage(`⏰ 【リマインド】\n${reminder.message}`)],
LINE_CHANNEL_ACCESS_TOKEN
);
await updateReminderStatus(DB, reminder.id, "sent");
console.log(`[scheduler] Sent reminder id=${reminder.id} to userId=${reminder.user_id}`);
} catch (err) {
console.error(`[scheduler] Failed to process reminder id=${reminder.id}:`, err);
}
}
console.log("[scheduler] Job completed.");
}
おわりに
従来の「プログラムによる文字列解析」を「LLMによる意味解析」に置き換えるだけで、チャットボットのユーザー体験(UX)は飛躍的に向上します。
特に Cloudflare Workers との相性は抜群で、「エッジでサクッと動いて、AIが賢く裁いてくれる」という体験は、これからの個人開発のスタンダードになっていくと感じました。
みなさんもぜひ、自分だけのパーソナルアシスタントBotを作ってみてください!

