Intelligent Technology's Technical Blog

株式会社インテリジェントテクノロジーの技術情報ブログです。

今日の天気と予定を毎朝Slackに届けるbotをAWS + Claudeで作った話


こんにちは、2025年度入社の山田です。

「今日の天気どうだっけ」「今日は何時から打ち合わせだっけ」と毎朝確認するのって、地味に面倒ですよね。 そこで、毎朝7時に天気・Googleカレンダーの予定をまとめて個人のSlackに届けるbotをAWSとClaudeで作ってみました。

※ 記事中で登場するAWSサービスやAPIの詳細な説明は省略します。各公式ドキュメントをご参照ください。



完成イメージ

毎朝7時に個人のSlackのチャンネルにこんなメッセージが届くことを目標にしました:

morning-botがSlackに送信したメッセージの例

天気と予定を踏まえたコメントをClaudeが自動生成してくれるといった感じです。


システム構成

EventBridgeで毎朝7時に定期実行し、処理の本体はLambdaが担います。LambdaからOpen-MeteoやGoogle Calendar APIを呼び出して情報を集め、ClaudeにSlack通知メッセージを生成させ、最後にSlackのIncoming Webhookで通知します。ClaudeはAWSのAmazon Bedrockを通じて利用しています。

EventBridge(毎朝7時)
    ↓
Lambda
    ├── Open-Meteo API(天気取得・無料・APIキー不要)
    ├── Google Calendar API(予定取得)
    ├── Secrets Manager(Google認証情報)
    └── Amazon Bedrock / Claude Haiku 4.5(メッセージ生成)
    ↓
Slack Incoming Webhook

すべてサーバーレスで行うため、常時起動するサーバーは不要です。


コード概要

天気の取得:get_weather

Open-Meteo API(無料・APIキー不要)を使って、指定した緯度・経度の当日の天気予報を取得します。

def get_weather():
    url = (
        f"https://api.open-meteo.com/v1/forecast"
        f"?latitude={LATITUDE}&longitude={LONGITUDE}"
        f"&daily=weathercode,temperature_2m_max,temperature_2m_min"
        f"&timezone=Asia%2FTokyo&forecast_days=1"
    )
    with urllib.request.urlopen(url) as res:
        data = json.loads(res.read())
    daily = data["daily"]
    return {
        "max_temp": daily["temperature_2m_max"][0],
        "min_temp": daily["temperature_2m_min"][0],
        "weathercode": daily["weathercode"][0],
    }

Googleカレンダーの予定取得:get_today_events

Secrets Managerからサービスアカウントの認証情報を取り出し、Google Calendar APIで当日の予定一覧を取得します。

def get_google_credentials():
    client = boto3.client("secretsmanager", region_name="ap-northeast-1")
    secret = client.get_secret_value(SecretId=SECRET_NAME)
    info = json.loads(secret["SecretString"])
    return service_account.Credentials.from_service_account_info(
        info,
        scopes=["https://www.googleapis.com/auth/calendar.readonly"]
    )

def get_today_events():
    creds = get_google_credentials()
    service = build("calendar", "v3", credentials=creds)
    jst = timezone(timedelta(hours=9))
    now = datetime.now(jst)
    time_min = now.replace(hour=0, minute=0, second=0).isoformat()
    time_max = now.replace(hour=23, minute=59, second=59).isoformat()
    events = service.events().list(
        calendarId=CALENDAR_ID,
        timeMin=time_min,
        timeMax=time_max,
        singleEvents=True,
        orderBy="startTime"
    ).execute()
    items = events.get("items", [])
    if not items:
        return "今日の予定はありません。"
    lines = []
    for e in items:
        start = e["start"].get("dateTime", e["start"].get("date", ""))
        if "T" in start:
            time_str = datetime.fromisoformat(start).strftime("%H:%M")
        else:
            time_str = "終日"
        lines.append(f"・{time_str} {e.get('summary', '(タイトルなし)')}")
    return "\n".join(lines)

Claudeによるメッセージ生成:summarize_with_bedrock

ClaudeはAmazon BedrockのAPIで利用しています。天気と予定の情報をプロンプトに渡して、朝のSlack通知メッセージを生成させます。

def summarize_with_bedrock(weather, events):
    client = boto3.client("bedrock-runtime", region_name="ap-northeast-1")
    prompt = f"""
今日の情報をもとに、朝のSlack通知メッセージを日本語で作成してください。

【天気】
- 最高気温: {weather['max_temp']}℃
- 最低気温: {weather['min_temp']}℃
- 天気コード: {weather['weathercode']}(0=快晴、1-3=晴れ〜曇り、61-67=雨、71-77=雪)

【今日の予定】
{events}

予定と天気を踏まえた一言コメントも添えて、150文字以内でまとめてください。
"""
    body = json.dumps({
        "anthropic_version": "bedrock-2023-05-31",
        "max_tokens": 300,
        "messages": [{"role": "user", "content": prompt}]
    })
    response = client.invoke_model(
        modelId="jp.anthropic.claude-haiku-4-5-20251001-v1:0",
        body=body
    )
    result = json.loads(response["body"].read())
    return result["content"][0]["text"]

Lambda全体のコード

最終的なLambdaのコードはこちらです。

import json
import os
import urllib.request
from datetime import datetime, timezone, timedelta

import boto3
from google.oauth2 import service_account
from googleapiclient.discovery import build

SLACK_WEBHOOK_URL = os.environ["SLACK_WEBHOOK_URL"]
# 以下の座標はサンプルとして川崎市を設定している
LATITUDE = float(os.environ.get("LATITUDE", "35.5"))
LONGITUDE = float(os.environ.get("LONGITUDE", "139.7"))
CALENDAR_ID = os.environ["CALENDAR_ID"]
SECRET_NAME = os.environ["SECRET_NAME"]

def get_google_credentials():
    client = boto3.client("secretsmanager", region_name="ap-northeast-1")
    secret = client.get_secret_value(SecretId=SECRET_NAME)
    info = json.loads(secret["SecretString"])
    return service_account.Credentials.from_service_account_info(
        info,
        scopes=["https://www.googleapis.com/auth/calendar.readonly"]
    )

def get_today_events():
    creds = get_google_credentials()
    service = build("calendar", "v3", credentials=creds)
    jst = timezone(timedelta(hours=9))
    now = datetime.now(jst)
    time_min = now.replace(hour=0, minute=0, second=0).isoformat()
    time_max = now.replace(hour=23, minute=59, second=59).isoformat()
    events = service.events().list(
        calendarId=CALENDAR_ID,
        timeMin=time_min,
        timeMax=time_max,
        singleEvents=True,
        orderBy="startTime"
    ).execute()
    items = events.get("items", [])
    if not items:
        return "今日の予定はありません。"
    lines = []
    for e in items:
        start = e["start"].get("dateTime", e["start"].get("date", ""))
        if "T" in start:
            time_str = datetime.fromisoformat(start).strftime("%H:%M")
        else:
            time_str = "終日"
        lines.append(f"・{time_str} {e.get('summary', '(タイトルなし)')}")
    return "\n".join(lines)

def get_weather():
    url = (
        f"https://api.open-meteo.com/v1/forecast"
        f"?latitude={LATITUDE}&longitude={LONGITUDE}"
        f"&daily=weathercode,temperature_2m_max,temperature_2m_min"
        f"&timezone=Asia%2FTokyo&forecast_days=1"
    )
    with urllib.request.urlopen(url) as res:
        data = json.loads(res.read())
    daily = data["daily"]
    return {
        "max_temp": daily["temperature_2m_max"][0],
        "min_temp": daily["temperature_2m_min"][0],
        "weathercode": daily["weathercode"][0],
    }

def summarize_with_bedrock(weather, events):
    client = boto3.client("bedrock-runtime", region_name="ap-northeast-1")
    prompt = f"""
今日の情報をもとに、朝のSlack通知メッセージを日本語で作成してください。

【天気】
- 最高気温: {weather['max_temp']}℃
- 最低気温: {weather['min_temp']}℃
- 天気コード: {weather['weathercode']}(0=快晴、1-3=晴れ〜曇り、61-67=雨、71-77=雪)

【今日の予定】
{events}

予定と天気を踏まえた一言コメントも添えて、150文字以内でまとめてください。
"""
    body = json.dumps({
        "anthropic_version": "bedrock-2023-05-31",
        "max_tokens": 300,
        "messages": [{"role": "user", "content": prompt}]
    })
    response = client.invoke_model(
        modelId="jp.anthropic.claude-haiku-4-5-20251001-v1:0",
        body=body
    )
    result = json.loads(response["body"].read())
    return result["content"][0]["text"]

# 指定したSlackのUrLに送信する
def send_to_slack(message):
    payload = json.dumps({"text": message}).encode("utf-8")
    req = urllib.request.Request(
        SLACK_WEBHOOK_URL,
        data=payload,
        headers={"Content-Type": "application/json"}
    )
    urllib.request.urlopen(req)

def lambda_handler(event, context):
    weather = get_weather()
    events = get_today_events()
    message = summarize_with_bedrock(weather, events)
    send_to_slack(message)
    return {"statusCode": 200, "body": "OK"}

セットアップ手順

まずはGoogle Calendar APIの準備から始めましょう。認証情報が揃ったらコードをパッケージ化してLambdaにデプロイし、最後にEventBridgeで毎朝自動実行させれば完成です。(文量の都合上、Slackの設定を割愛しています。詳細は公式ドキュメントをご覧ください。)

1. Google Calendar APIの設定

はじめに、Google Cloud側でカレンダーへのアクセス権限を用意します。

  1. Google Cloud ConsoleでGoogle Calendar APIを有効化 Google Cloud ConsoleのAPIライブラリでGoogle Calendar APIを選択する

  2. サービスアカウントを作成してJSONキーをダウンロード サービスアカウントの秘密鍵をJSON形式で作成するダイアログ

  3. 対象のGoogleカレンダーの「設定と共有」→ サービスアカウントのメールアドレスを追加(閲覧権限)

Googleカレンダーの共有設定でサービスアカウントを追加する

  1. JSONキーの中身をAWS Secrets Managerに保存 Secrets Managerでサービスアカウントキー(JSON)を登録する

Secrets Managerには、AWSコンソールで「新しいシークレットを保存する」→「その他のシークレットの種類」を選択し、JSONの中身をそのまま貼り付けて保存します。保存後に表示されるシークレット名をメモしておきましょう。(画像のJSONキーはサンプルとして作成したもので既に削除済みです)

2. デプロイパッケージの作成

次に、LambdaにアップロードするZIPファイルを作成します。Google Calendar APIの呼び出しに使っている google-api-python-client をLambdaに載せようとしたところ、少し注意が必要でした。このライブラリはRust製バイナリを含むため、Macで直接インストールしたものはLambdaで動かないのです。Dockerで明示的にLinux向けにビルドする必要があります。

docker run --rm \
  -v $(pwd):/var/task \
  --entrypoint pip \
  public.ecr.aws/lambda/python:3.14 \
  install google-auth google-auth-httplib2 google-api-python-client \
  --platform manylinux_2_28_x86_64 \
  --implementation cp \
  --only-binary=:all: \
  -t /var/task/package/

cp lambda_function.py package/
cd package && zip -r ../lambda-package.zip . && cd ..

3. Lambdaの作成とデプロイ

ZIPが用意できたら、AWSコンソールでLambda関数を作成してアップロードします。

設定値は以下のとおりです。

  • ランタイム: Python 3.14 LambdaでPython 3.14のランタイムを選択して関数を作成する

  • タイムアウト: 30秒(デフォルト3秒だとBedrockの応答が間に合わない)

Lambdaの基本設定でタイムアウトを30秒に設定する

  • IAMロール: AmazonBedrockFullAccess / SecretsManagerReadWrite をアタッチ IAMロールにAmazonBedrockFullAccessとSecretsManagerReadWriteをアタッチした状態

環境変数は「設定」→「環境変数」から設定します。

Lambdaの環境変数に5つのキーを設定した画面

キー
SLACK_WEBHOOK_URL Slack Incoming Webhook URL
CALENDAR_ID GoogleカレンダーのカレンダーID
SECRET_NAME Secrets Managerのシークレット名
LATITUDE 緯度
LONGITUDE 経度

このzipをコードとして直接アップロードします(レイヤーは不要)。 ※レイヤーとしてアップロードを試したが、なぜかうまくいかなかったので代替として上記の方法でアップロードを行った。

4. EventBridgeで定期実行を設定

最後に、毎朝7時に自動実行されるよう EventBridge のスケジュールを設定します。

LambdaにEventBridgeトリガーを追加してcronスケジュールを設定する

EventBridgeはUTC基準のため、日本時間7時はUTC前日22時になります。

cron(0 22 * * ? *)

これで設定完了です。翌朝7時に通知が届けば成功です!


実際に使ってみた

実際に当日の天気と予定を簡潔にチャットしてくれています。

服装のポイントも教えてくれるのが地味に助かりますね。


トラブルシューティング

① タイムアウト

デフォルト3秒ではBedrockの応答が間に合わずタイムアウトする。30秒に変更で解決。

② Bedrockのモデルエラー

Invocation of model ID ... with on-demand throughput isn't supported.
Retry your request with the ID or ARN of an inference profile

新しいモデルはモデルIDの直接指定ではなく推論プロファイル経由でしか呼び出せない。使えるIDは以下で確認できる:

aws bedrock list-inference-profiles --region ap-northeast-1 | grep haiku

東京リージョンのHaiku 4.5は jp.anthropic.claude-haiku-4-5-20251001-v1:0 が正しい。

③ 実行バイナリ互換性エラー

/opt/python/cryptography/hazmat/bindings/_rust.abi3.so: invalid ELF header

Mac(ARM64)でビルドしたバイナリをそのままLambda (Linux x86_64)へデプロイすると、OS及びCPUアーキテクチャの違いによりバイナリ互換性エラーが発生する。--platform manylinux_2_28_x86_64 を指定してLinux/x86_64向けのパッケージを取得し、さらにLinuxコンテナ(Docker)内でビルドすることで、Lambdaで実行可能なバイナリを生成できる。ネイティブバイナリを含むライブラリをLambdaで利用する場合は、このようにLambdaの実行環境に合わせてビルドする必要がある。


まとめ

毎朝7時に天気と予定がまとまって個人のSlackに届くのは思った以上に便利でした。しかも、サーバーレス構成のおかげで月額コストは ほぼ無料 で実現できています。

まだまだ追加できる機能や連携できるAWSのサービスはあると思うので継続的にブラッシュアップを行っていきたいです。