0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

【完全無料】CloudflareにてLinebotでリマインダーbotを作った

0
Posted at

はじめに

LINEにリマインダー機能がついていると便利だなぁと思い、完全無料で動くリマインダーBotを作りました。

LINE BotはWebhookエンドポイントが1つしかないため、メッセージ内のテキスト情報だけで処理を振り分ける必要があります。
ただ、ルールベースでメッセージ制御をしようとすると、厳格なコマンドフォーマットに縛られ、人間らしい自然な言葉での依頼ができなくなってしまいます。
そのため、今回はAI(LLM)を挟んで自然言語の意味解析を行い、意図に応じた適切な処理へルーティングする設計にしました。

従来のリマインダーBot開発で課題だった 「GASによる通知間隔のアバウトさ」「ルールベースの日時解析(言葉の揺らぎに弱い点)」 を、Cloudflare Workers + D1 で解決しています。


成果物 (デモ)

linebot_reminder2.gif


インフラ構成図

本プロジェクトのインフラ構成です。Cloudflareの無料枠内で完結するよう設計しています。

image.png

コンポーネントの役割

  1. Cloudflare Workers (Hono): Webhookの受け口と全体のルーティングを担当。
  2. OpenRouter (Gemini 2.5 Flash Lite): ユーザーの自然言語を解析し、プログラムが処理しやすい JSON に変換します。高速で安価なモデルを採用しています。
  3. Cloudflare D1: エッジで動くSQLite。タスクを保存します。
  4. 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を作ってみてください!

0
0
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?