はじめに
我が家では、奥さんがご飯を作ってくれているのですが、何が食べたいかよく聞いてきます。
献立を考えるのって意外と面倒なんですよね。
料理を作ってくれる人と比べたら圧倒的に手間じゃないのになんでですかね?
ということで、献立メニューをAIが考えてくれるLINE BOTを作ってこの作業を自動化することにしました。
機能概要
ユーザーがLINEで「鶏むねとキャベツで何か作って」のように話しかけると、BOTがAI(Gemini)とやりとりをして献立を返します。
ユースケースとしては以下の通りです。
- 「今日の晩ごはんは?」→ すぐ献立を返信
- 「鶏むねとキャベツ余ってる」→ Geminiがレシピ候補を生成
実際に使っている様子が下のようになります。
献立や料理などのワードが入っていれば、API呼び出しのトリガーとなってAIが考えたメニューが返ってきます。
ちなみに、LINE BOTのメッセージが猫風な口調になっているのは、妻の家の猫の"まーちゃん"が喋っているイメージで作りました笑
自分がとても溺愛しているので、別な記事でも投稿したことがあります。
技術スタックと選定理由
- Vercel API Routes: サーバーレス関数として高速・軽量に動作し、LINE Webhookの応答時間制限をクリア。
- Gemini: 日替わりメニューの生成にLLMを利用し、メッセージの内容に応じて献立テンプレートに従って応答。
- Upstash QStash: Webhook直後の重い処理を非同期化し、再試行や検証を実施。
リポジトリ構成は次のとおり。
kondate-bot/
├─ api/ # Vercel Functions
│ ├─ index.ts # Honoエントリ(署名検証/Webhook処理)
│ ├─ webhook.ts # LINE Webhook(AI献立のQStash投入)
│ ├─ cron/daily.ts # 毎日の献立通知用Cronジョブ
│ └─ tasks/ai-menu.ts # QStashが呼び出すAI献立生成タスク
├─ src/
│ ├─ services/ # ドメインサービス(line/menu/ai-menu)
│ ├─ data/menus.json # 献立データ
│ └─ types/ # 型定義
├─ test-webhook-request.json
├─ local-server.js # distを使うローカル検証用
└─ tsconfig.json / vercel.json
動作の流れ
全体の処理の流れは以下の通りです。
実装ポイント
1) Webhook エンドポイントと署名検証(api/webhook.ts)
-
api/webhook.tsはVercel API RoutesとしてメインのWebhookエンドポイントを提供します。 -
vercel.jsonの設定により/webhook→/api/webhookにリダイレクトされます。 - LINEからのWebhookリクエストを受け取り、署名を検証。妥当でなければ即エラーを返します。
- 実際の処理は非同期で行い、LINEプラットフォームには即座に
200 OKを返すことでタイムアウトを防ぎます。
// api/webhook.ts の抜粋
export default async function handler(req: VercelRequest, res: VercelResponse) {
try {
// === 生のボディとヘッダーの取得 ===
const bodyText = await getRawBody(req);
const signature = req.headers['x-line-signature'] as string;
// === 署名検証 ===
if (!signature) {
return res.status(400).json({ error: 'Missing signature' });
}
const isValid = lineService.validateSignature(bodyText, signature);
if (!isValid) {
return res.status(401).json({ error: 'Invalid signature' });
}
// === 非同期でイベント処理(QStash publish完了まで待機) ===
const events: WebhookEvent[] = JSON.parse(bodyText).events || [];
try {
await Promise.allSettled(
events.map((event) => handleWebhookEvent(event, publicBaseUrl))
);
} catch (err) {
console.error('Event handling error:', err);
}
// === レスポンスを返す ===
return res.status(200).json({ status: 'success', processedEvents: events.length });
} catch (error) {
console.error('Webhook error:', error);
return res.status(200).json({ status: 'error' }); // LINEの仕様でエラー時も200
}
}
2) vercel.json設定とルーティング
-
vercel.jsonのrewrites設定により、/webhookへのアクセスを/api/webhookにリダイレクト。 - LINE Developers ConsoleのWebhook URLは
https://your-domain.vercel.app/webhookに設定。 - 実際の処理は
api/webhook.tsで行われる。
// vercel.json の抜粋
{
"rewrites": [
{
"source": "/webhook",
"destination": "/api/webhook"
}
]
}
3) イベント処理とAI献立判定(handleWebhookEvent)
- イベント種別・テキストをパースしてサービス層に委譲。
- AI献立が必要なら即座に「考え中」を返信→QStashへ投入。
// AI献立の判定と即時返信→キュー投入(抜粋)
if (aiMenuService && aiMenuService.isMenuRequest(text)) {
const thinkingMessage = '献立を考えてるから...ちょっと待っててね🐈';
await lineService.replyText(replyToken, thinkingMessage);
await enqueueAIMenuTask(userId, text, publicBaseUrl);
return; // Webhookは即終了
}
4) メニュー生成(src/services/ai-menu.ts)
- Geminiはモデルと生成設定を指定し、応答テキストを安全に抽出。
// Gemini呼び出し(抜粋)
const modelId = process.env.GEMINI_MODEL || 'gemini-2.5-flash';
const model = anyAI.getGenerativeModel({
model: modelId,
generationConfig: {
temperature: 0.8,
topK: 40,
topP: 0.9,
maxOutputTokens: 1024,
}
});
const result = await model.generateContent({
contents: [{ role: 'user', parts: [{ text: this.buildPrompt(userMessage) }] }],
});
const text = result.response.text();
return this.formatMenuResponse(text);
5) 非同期実行(api/webhook.ts)
- WebhookはQStashへJSONをpublish。
- Consumerは署名検証後、Geminiで生成→LINEへプッシュ。
// キュー投入(api/webhook.ts 抜粋)
const qstash = new QStashClient({ token: process.env.QSTASH_TOKEN! });
await qstash.publishJSON({
url: `${publicBaseUrl}/api/tasks/ai-menu`,
body: { userId, userMessage, timestamp: Date.now(), requestId: Math.random().toString(36).slice(7) },
retries: 3,
contentBasedDeduplication: false,
});
// api/tasks/ai-menu.ts 抜粋
const receiver = new Receiver({ currentSigningKey, nextSigningKey });
const valid = await receiver.verify({ signature, body: bodyText, url: fullUrl });
if (!valid) return res.status(401).json({ error: 'Invalid Upstash-Signature' });
const { userId, userMessage } = JSON.parse(bodyText);
const resultText = await new AIMenuService().generateMenu(userMessage);
await new LineService(lineConfig).sendAIMenuToUser(userId, resultText);
おわりに
生成AIを使って「毎日の献立」を考える日常の課題をgeminiを使って解決してみました。
ちなみに今回、初めてAIを組み込んだツールを作ってみました。
こんな感じで、AIを利用したツールとかこれからも作っていきたいなと思います。
ちなみに、この記事もAIツールを使って大枠を書いてもらっています。
一発で全部書いてもらえなかったので、複数のAIツール(Claude Code、Codex CLI、Gemini CLI)を組み合わせたり、手直ししたりしました。
こんなのも含めてAI系のトライも投稿できたらと思います。
