はじめに
「今日の夕ご飯なに?」——毎日聞かれるこの質問、地味にストレスですよね。
我が家では献立を考えるのは妻の担当ですが、家族から何度も「今日の夕ご飯なに?」と聞かれるのが面倒だということで、LINEでメニューを登録しておけば、Amazon Echoに話しかけるだけで今日の献立を教えてくれるシステムを作りました。
完成イメージ
- LINEで
3/24 カレーライスとメッセージを送信 - 「登録しました」と返信が来る
- Amazon Echoに「アレクサ、ごはんメモ」と話しかける
- 「今日の夕ご飯はカレーライスです。」と教えてくれる
料理を作る人がLINEでサッと登録するだけで、家族全員がAlexaに聞けるようになります。
システム構成
全体のアーキテクチャは以下のとおりです。
使用サービス
| サービス | 用途 |
|---|---|
| LINE Messaging API | メニュー登録のインターフェース |
| Amazon API Gateway | LINE Webhookの受け口 |
| AWS Lambda (Python 3.12) | ビジネスロジック(2つ) |
| Amazon DynamoDB | メニューデータの保存 |
| Alexa Custom Skill (ASK SDK) | 音声インターフェース |
リージョンは ap-northeast-1(東京)を使用しています。
DynamoDB テーブル設計
テーブル名は DinnerMenu で、非常にシンプルな構成です。
| 属性名 | 型 | 説明 |
|---|---|---|
date (PK) |
String | 日付(YYYY-MM-DD 形式) |
menu |
String | 夕ご飯のメニュー |
データ例:
| date | menu |
|---|---|
| 2026-03-24 | カレーライス |
| 2026-03-25 | 肉じゃが |
パーティションキーに日付を使うことで、当日のメニューを1回の get_item で取得できます。同じ日付で再登録すると上書きされるので、メニュー変更も簡単です。
LINE Bot 側の実装
LINE Messaging API の Webhook を受け取って DynamoDB に保存する Lambda です。
対応する入力フォーマット
-
3/24 カレーライス(M/D メニュー形式) -
2026-03-24 カレーライス(YYYY-MM-DD メニュー形式)
コード全体(line_webhook.py)
"""
LINE Webhook Lambda
LINEからのメッセージを受け取り、夕ご飯メニューをDynamoDBに保存する。
メッセージフォーマット例:
3/23 カレーライス
3/24 肉じゃが
2026-03-25 餃子
"""
import json
import os
import re
import datetime
from zoneinfo import ZoneInfo
import hashlib
import hmac
import base64
import boto3
import requests
TABLE_NAME = "DinnerMenu"
LINE_REPLY_URL = "https://api.line.me/v2/bot/message/reply"
dynamodb = boto3.resource("dynamodb")
def verify_signature(body: bytes, signature: str) -> bool:
"""LINEからのリクエストの署名を検証する。"""
channel_secret = os.environ.get("LINE_CHANNEL_SECRET", "")
hash_ = hmac.new(channel_secret.encode("utf-8"), body, hashlib.sha256).digest()
expected = base64.b64encode(hash_).decode("utf-8")
return hmac.compare_digest(expected, signature)
def reply_message(reply_token: str, text: str) -> None:
"""LINEに返信する。"""
access_token = os.environ.get("LINE_CHANNEL_ACCESS_TOKEN", "")
headers = {
"Content-Type": "application/json",
"Authorization": f"Bearer {access_token}",
}
payload = {
"replyToken": reply_token,
"messages": [{"type": "text", "text": text}],
}
requests.post(LINE_REPLY_URL, headers=headers, json=payload, timeout=5)
def parse_message(text: str):
"""
メッセージを解析して (date_str, menu) を返す。
パースに失敗した場合は None を返す。
対応フォーマット:
M/D メニュー → 例: 3/23 カレー
YYYY-MM-DD メニュー → 例: 2026-03-23 カレー
"""
text = text.strip()
year = datetime.datetime.now(ZoneInfo("Asia/Tokyo")).year
# YYYY-MM-DD メニュー
m = re.match(r"(\d{4})-(\d{1,2})-(\d{1,2})\s+(.+)", text)
if m:
y, mo, d, menu = int(m.group(1)), int(m.group(2)), int(m.group(3)), m.group(4).strip()
try:
date_str = datetime.date(y, mo, d).strftime("%Y-%m-%d")
return date_str, menu
except ValueError:
return None
# M/D メニュー
m = re.match(r"(\d{1,2})/(\d{1,2})\s+(.+)", text)
if m:
mo, d, menu = int(m.group(1)), int(m.group(2)), m.group(3).strip()
try:
date_str = datetime.date(year, mo, d).strftime("%Y-%m-%d")
return date_str, menu
except ValueError:
return None
return None
def save_dinner(date_str: str, menu: str) -> None:
"""DynamoDB に夕ご飯を保存する。"""
table = dynamodb.Table(TABLE_NAME)
table.put_item(Item={"date": date_str, "menu": menu})
def lambda_handler(event, context):
body_bytes = event.get("body", "").encode("utf-8")
if isinstance(event.get("body"), bytes):
body_bytes = event["body"]
signature = event.get("headers", {}).get("x-line-signature", "")
if not verify_signature(body_bytes, signature):
return {"statusCode": 400, "body": "Invalid signature"}
body = json.loads(body_bytes)
for evt in body.get("events", []):
if evt.get("type") != "message":
continue
if evt["message"].get("type") != "text":
continue
reply_token = evt["replyToken"]
text = evt["message"]["text"]
parsed = parse_message(text)
if parsed is None:
reply_message(
reply_token,
"フォーマットが正しくありません。\n例: 3/23 カレーライス\n例: 2026-03-23 肉じゃが",
)
continue
date_str, menu = parsed
save_dinner(date_str, menu)
# 表示用の日付(M月D日)
dt = datetime.datetime.strptime(date_str, "%Y-%m-%d")
display_date = f"{dt.month}月{dt.day}日"
reply_message(reply_token, f"{display_date}の夕ご飯を「{menu}」として登録しました。")
return {"statusCode": 200, "body": "OK"}
ポイント解説
署名検証(verify_signature)
LINE Messaging API では、Webhook リクエストが本当に LINE から来たものかを検証するために x-line-signature ヘッダーが付きます。チャネルシークレットを使って HMAC-SHA256 で検証しています。セキュリティ上、この検証は必ず実装しましょう。
メッセージパース(parse_message)
M/D メニュー と YYYY-MM-DD メニュー の2つのフォーマットに対応しています。M/D 形式の場合、年は日本時間の現在年を自動補完します。不正な日付(2月30日など)は ValueError で弾かれ、ユーザーにフォーマットエラーを返します。
環境変数
Lambda の環境変数に以下を設定しています:
-
LINE_CHANNEL_SECRET: LINE チャネルシークレット -
LINE_CHANNEL_ACCESS_TOKEN: LINE チャネルアクセストークン
Alexa スキル側の実装
Alexa から DynamoDB を読み取って今日のメニューを返す Lambda です。
コード全体(lambda_funtion.py)
import datetime
from zoneinfo import ZoneInfo
import boto3
from ask_sdk_core.skill_builder import SkillBuilder
from ask_sdk_core.dispatch_components import AbstractRequestHandler
from ask_sdk_core.utils import is_request_type, is_intent_name
TABLE_NAME = "DinnerMenu"
dynamodb = boto3.resource("dynamodb")
sb = SkillBuilder()
class LaunchRequestHandler(AbstractRequestHandler):
def can_handle(self, handler_input):
return is_request_type("LaunchRequest")(handler_input)
def handle(self, handler_input):
speech_text = "夕ご飯スキルです。「今日の夕ご飯は何」と聞いてみてください。"
return (
handler_input.response_builder
.speak(speech_text)
.ask(speech_text)
.response
)
class DinnerIntentHandler(AbstractRequestHandler):
def can_handle(self, handler_input):
return is_intent_name("DinnerIntent")(handler_input)
def handle(self, handler_input):
today = datetime.datetime.now(ZoneInfo("Asia/Tokyo")).strftime("%Y-%m-%d")
table = dynamodb.Table(TABLE_NAME)
result = table.get_item(Key={"date": today})
menu = result.get("Item", {}).get("menu")
if menu:
speech_text = f"今日の夕ご飯は{menu}です。"
else:
speech_text = "今日の夕ご飯はまだ登録されていません。LINEで登録してください。"
return (
handler_input.response_builder
.speak(speech_text)
.response
)
class CancelAndStopIntentHandler(AbstractRequestHandler):
def can_handle(self, handler_input):
return (
is_intent_name("AMAZON.CancelIntent")(handler_input)
or is_intent_name("AMAZON.StopIntent")(handler_input)
)
def handle(self, handler_input):
return (
handler_input.response_builder
.speak("またね。")
.response
)
class HelpIntentHandler(AbstractRequestHandler):
def can_handle(self, handler_input):
return is_intent_name("AMAZON.HelpIntent")(handler_input)
def handle(self, handler_input):
speech_text = "「今日の夕ご飯は何」と聞いてみてください。"
return (
handler_input.response_builder
.speak(speech_text)
.ask(speech_text)
.response
)
class FallbackIntentHandler(AbstractRequestHandler):
def can_handle(self, handler_input):
return is_intent_name("AMAZON.FallbackIntent")(handler_input)
def handle(self, handler_input):
speech_text = "すみません、うまく聞き取れませんでした。「今日の夕ご飯は何」と聞いてみてください。"
return (
handler_input.response_builder
.speak(speech_text)
.ask(speech_text)
.response
)
class SessionEndedRequestHandler(AbstractRequestHandler):
def can_handle(self, handler_input):
return is_request_type("SessionEndedRequest")(handler_input)
def handle(self, handler_input):
return handler_input.response_builder.response
sb.add_request_handler(LaunchRequestHandler())
sb.add_request_handler(DinnerIntentHandler())
sb.add_request_handler(CancelAndStopIntentHandler())
sb.add_request_handler(HelpIntentHandler())
sb.add_request_handler(FallbackIntentHandler())
sb.add_request_handler(SessionEndedRequestHandler())
lambda_handler = sb.lambda_handler()
ポイント解説
ハンドラーの構成
ASK SDK for Python では、各リクエストタイプやインテントに対応する「ハンドラー」を作り、SkillBuilder に登録するパターンで実装します。
| ハンドラー | 役割 |
|---|---|
LaunchRequestHandler |
スキル起動時の応答 |
DinnerIntentHandler |
「今日の夕ご飯は何」に対する応答(メインロジック) |
CancelAndStopIntentHandler |
「キャンセル」「ストップ」への対応 |
HelpIntentHandler |
「ヘルプ」への対応 |
FallbackIntentHandler |
認識できなかった発話への対応 |
SessionEndedRequestHandler |
セッション終了時の処理 |
DinnerIntentHandler の処理フロー
-
ZoneInfo("Asia/Tokyo")を使って日本時間の今日の日付を取得 - DynamoDB から当日のメニューを
get_itemで取得 - メニューが登録されていれば読み上げ、なければ「LINEで登録してください」と案内
Alexa Developer Console の設定
カスタムインテント: DinnerIntent
Alexa Developer Console で以下のように設定しています。
インテント名: DinnerIntent
サンプル発話(Sample Utterances):
- 今日の夕ご飯は何
- 夕ご飯を教えて
- 今夜は何を食べる
- 夕ごはんは何
- 今日の晩ご飯は何
- 夕飯は何
スロットは使用していません。日付の判定は Lambda 側で datetime を使って行っています。
エンドポイント
Lambda の ARN を指定します。Alexa Developer Console の「エンドポイント」セクションで、デフォルトのリージョンに Lambda 関数の ARN を入力してください。
つまずいたポイント
ここからが本記事のメインです。同じようなシステムを作ろうとしている方の参考になれば幸いです。
1. Alexa の呼び出し名(Invocation Name)問題
これが一番苦労しました。呼び出し名の変遷は以下のとおりです。
| 試した名前 | 結果 |
|---|---|
| ゆうごはん | 既存のスキルが起動してしまう |
| ばんごはん | 同上 |
| うちの晩ごはん | 認識精度が低い |
| ごはんメモ | 安定して認識される |
Alexa のカスタムスキルでは、呼び出し名が既存の公開スキルと競合すると、自分のスキルではなく既存スキルが起動してしまいます。「ゆうごはん」「ばんごはん」のような一般的な単語は特に危険です。
教訓: 呼び出し名は「一般的すぎない、かつ発音しやすい」ものを選びましょう。2語以上の組み合わせが安定します。
2. iPhone の Alexa アプリで Dev スキルが動かない
開発中、iPhone の Alexa アプリでテストしようとしたところ、以下のエラーが出ました。
- 「この機能にはまだ対応していません」
- 「どのスキルを有効にしますか」
色々と設定を見直しましたが、結論としては開発中(Dev)のカスタムスキルは Alexa アプリ(スマホ)では動作しません。テストは必ず Echo デバイス実機で行う必要があります。
Alexa Developer Console の「テスト」タブではシミュレーターが使えますが、実機テストは Echo が必須です。これを知らずにかなりの時間をロスしました。
3. タイムゾーン問題(UTC vs JST)
AWS Lambda のデフォルトタイムゾーンは UTC です。つまり datetime.datetime.now() を素で使うと、日本時間の 23:00〜翌 08:59 の間で日付がズレます。
具体例:
- 日本時間 2026年3月24日 23:30 にAlexaで問い合わせ
- Lambda 上の UTC は 2026年3月24日 14:30 →
2026-03-24(たまたま合う) - 日本時間 2026年3月25日 00:30 にAlexaで問い合わせ
- Lambda 上の UTC は 2026年3月24日 15:30 →
2026-03-24(ズレる!)
解決策:
from zoneinfo import ZoneInfo
# NG: UTCになる
today = datetime.datetime.now().strftime("%Y-%m-%d")
# OK: 日本時間になる
today = datetime.datetime.now(ZoneInfo("Asia/Tokyo")).strftime("%Y-%m-%d")
Python 3.9 以降では標準ライブラリの zoneinfo が使えるので、追加のパッケージは不要です。LINE 側の Lambda でも同様に ZoneInfo("Asia/Tokyo") を使って年の補完を行っています。
4. Lambda に必須ハンドラーが不足していた
最初の実装では LaunchRequestHandler と DinnerIntentHandler だけで動かそうとしていました。しかし、これだとスキルの動作が非常に不安定になります。
Alexa は以下のビルトインインテントを必ず送信してくる可能性があるため、対応するハンドラーがないとエラーになります。
| 必須ハンドラー | 対応するインテント/リクエスト | 役割 |
|---|---|---|
CancelAndStopIntentHandler |
AMAZON.CancelIntent, AMAZON.StopIntent
|
スキルの停止 |
HelpIntentHandler |
AMAZON.HelpIntent |
ヘルプの案内 |
FallbackIntentHandler |
AMAZON.FallbackIntent |
認識不能時の対応 |
SessionEndedRequestHandler |
SessionEndedRequest |
セッション終了のクリーンアップ |
特に SessionEndedRequestHandler は忘れがちですが、これがないとセッション終了時にエラーログが大量に出ます。最低限この4つは必ず実装しましょう。
定型アクション(ルーティン)の設定方法
毎回「アレクサ、ごはんメモを開いて、今日の夕ご飯は何」と言うのは長すぎるので、**Alexa の定型アクション(ルーティン)**でショートカットを設定します。
設定手順
- Alexa アプリを開く
- 「その他」→「定型アクション」を選択
- 「+」をタップして新しい定型アクションを作成
- 実行条件(トリガー): 「音声」を選択し、「夕ご飯」と入力
- アクション: 「カスタム」を選択し、「ごはんメモで今日の夕ご飯は何」と入力
- 保存
これで「アレクサ、夕ご飯」と話しかけるだけで、スキルの起動から質問までを一気にやってくれます。
まとめ
LINE + Alexa + Lambda + DynamoDB という構成で、夕ご飯の献立共有システムを作りました。
- 登録: LINEで日付とメニューを送るだけ
- 問い合わせ: Echoに「アレクサ、夕ご飯」と話しかけるだけ
- コスト: Lambda と DynamoDB の無料利用枠内で収まるので実質無料
今後の改善点
- 複数メニュー対応: 「主菜+副菜+汁物」のようにコース形式で登録できるようにしたい
- 週間メニュー対応: 「今週の献立を教えて」で1週間分を読み上げてくれる機能
- 買い物リスト連携: メニューから食材リストを自動生成してLINEに送る
- 家族への通知: メニュー登録時に家族のLINEグループにも通知する
個人開発の範囲ですが、家族に「便利じゃん」と言ってもらえるものができたので満足です。同じようなものを作りたい方の参考になれば嬉しいです。