0
1

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

LINEで夕ご飯を登録して「アレクサ、夕ご飯」で献立を教えてもらうシステムを作った

0
Last updated at Posted at 2026-03-24

はじめに

「今日の夕ご飯なに?」——毎日聞かれるこの質問、地味にストレスですよね。

我が家では献立を考えるのは妻の担当ですが、家族から何度も「今日の夕ご飯なに?」と聞かれるのが面倒だということで、LINEでメニューを登録しておけば、Amazon Echoに話しかけるだけで今日の献立を教えてくれるシステムを作りました。

完成イメージ

  1. LINEで 3/24 カレーライス とメッセージを送信
  2. 「登録しました」と返信が来る
  3. Amazon Echoに「アレクサ、ごはんメモ」と話しかける
  4. 「今日の夕ご飯はカレーライスです。」と教えてくれる

料理を作る人が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 の処理フロー

  1. ZoneInfo("Asia/Tokyo") を使って日本時間の今日の日付を取得
  2. DynamoDB から当日のメニューを get_item で取得
  3. メニューが登録されていれば読み上げ、なければ「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 の定型アクション(ルーティン)**でショートカットを設定します。

設定手順

  1. Alexa アプリを開く
  2. 「その他」→「定型アクション」を選択
  3. 「+」をタップして新しい定型アクションを作成
  4. 実行条件(トリガー): 「音声」を選択し、「夕ご飯」と入力
  5. アクション: 「カスタム」を選択し、「ごはんメモで今日の夕ご飯は何」と入力
  6. 保存

これで「アレクサ、夕ご飯」と話しかけるだけで、スキルの起動から質問までを一気にやってくれます。

まとめ

LINE + Alexa + Lambda + DynamoDB という構成で、夕ご飯の献立共有システムを作りました。

  • 登録: LINEで日付とメニューを送るだけ
  • 問い合わせ: Echoに「アレクサ、夕ご飯」と話しかけるだけ
  • コスト: Lambda と DynamoDB の無料利用枠内で収まるので実質無料

今後の改善点

  • 複数メニュー対応: 「主菜+副菜+汁物」のようにコース形式で登録できるようにしたい
  • 週間メニュー対応: 「今週の献立を教えて」で1週間分を読み上げてくれる機能
  • 買い物リスト連携: メニューから食材リストを自動生成してLINEに送る
  • 家族への通知: メニュー登録時に家族のLINEグループにも通知する

個人開発の範囲ですが、家族に「便利じゃん」と言ってもらえるものができたので満足です。同じようなものを作りたい方の参考になれば嬉しいです。

0
1
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
1

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?