5
5

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

Cloud Run Functions × Slack Bot × Yahoo!天気で「今日の天気」を自動投稿するBotを作成してみた

5
Last updated at Posted at 2026-03-15

1. はじめに

本システム開発は、Pythonスクール 「BizCodeX」 の「アウトプット④」の一環として取り組みです。

今回、Slackで「今日の天気は」と送るだけで、天気を自動返信してくれるBotを作りました。
さらに毎朝決まった時間に天気を自動投稿する機能も備えており、イベント駆動とスケジュール駆動の両方に対応したサーバーレスの構成になっています。

本記事を通じて、以下の技術をまとめて学べる構成を意識しました。

  • Slack Bolt(Python)での Bot 開発
  • Yahoo! ジオコーダ API・気象情報 API の活用
  • Yahoo! 天気サイトの HTML スクレイピング
  • Google Cloud のサーバーレス構成(Functions / Scheduler / Secret Manager)

これらは単独でも使いどころの多い技術ですが、このBotはそれらを組み合わせて実用的なクラウドBotとして動かせる点が特徴です。

Google Cloud Run Functions 上でBotを動かす方法から、Cloud Scheduler による定期実行の設定まで、順を追って紹介します。

2. 背景

「今日の天気は?」、「傘いる?」、「今日は暑いの?」、「外に洗濯物干せる?」
朝の食卓で天気に関してこんなやり取り、一度はしたことがあるのではないでしょうか。
天気が気になるのに、テレビの天気予報は見たいときに表示されないことが多いものです。
例えば、

  • 自分の地域が表示されるまで、画面をじっと見続けなければならない
  • 気になる番組を見ていたら、家族がリモコンの d ボタンを押して画面が切り替わった

……といった経験、きっと一度はあると思います。
「それなら、毎朝起きた時間に、自分の地域の天気が Slack に自動で届いていたら楽だな」
そんな小さな不便を解消したくて、今回の Bot を作りました。

3. 完成イメージ

Slack天気Botの完成イメージです。

(1) Slackで天気を問い合わせる

Slackで以下のように投稿すると、

今日の天気は

携帯への出力03.png

設定した場所の天気、最高気温、最低気温、風速、指数情報などを返します。
さらに、たとえば Slack から

大阪市の今日の天気は

と入力すれば、知りたい場所の天気情報をすぐに得ることも可能です。

(2) 毎朝6:00にSlackに自動投稿する

Cloud Schedulerから定期実行し、指定したSlackチャンネルに設定した都市の毎朝の天気を自動投稿します。

4. システム構成

アーキテクチャは次の通りです。
今回、draw.io を使って作成してみました

アプトプット④_last.drawio2.png

この構成では、次の 2 つの入口があります。

  • Slack → Cloud Run functions A
    Slack の Events API から送られてきたイベントを受け取る

  • Cloud Scheduler → Cloud Run functions B
    毎朝 6:00 に定期実行して、指定した Slack チャンネルへ天気を投稿する。

5.使用した技術

今まで「BizCodeX」学んだ技術をできる限り盛り込みました。

技術 用途
Python Slack天気Bot全体の実装
Slack Bolt Slackメッセージの受信とBot応答処理
Cloud Functions Slackイベント用・定期実行用エンドポイントの実行
Cloud Scheduler 毎朝6:00の自動実行
Secret Manager APIキーやSlack認証情報の安全な管理
Yahoo!ジオコーダAPI 地名・住所から緯度経度と GovernmentCode を取得
Yahoo!気象情報API 緯度経度から WeatherAreaCode を取得
Yahoo!天気・災害サイト 天気予報と指数情報の取得
BeautifulSoup Yahoo!天気サイトのHTML解析
pandas 取得した天気データの加工・整形

6. ファイル構成

├── main.py              # Cloud Functions エントリポイント(A・B両方)
├── requirements.txt     # ライブラリ管理
├── secret_manager.py    # Secret Manager からの認証情報取得
├── slack_app.py         # Slack Bolt アプリ・メッセージハンドラ
├── weather_service.py   # Yahoo! API呼び出し+スクレイピング
└── weather_setting.py   # URL・定数・絵文字マップ

7. コード解説

7.1 エントリポイント (main.py)

Cloud Run functions では、functions-framework を使って HTTP 関数を定義できます。
今回の実装では、用途の異なる 2 つの HTTP エンドポイントを用意しました。

(A) Slackイベント処理 ( weather_slack_events )
Slack でメッセージが投稿されると、Slack の Events API からこの関数の URL に HTTP リクエストが送られます。このリクエストを SlackRequestHandler に渡すことで、Slack Bolt が署名検証・イベント解析・ハンドラ呼び出しを処理してくれます。

def weather_slack_events(request):
    return handler.handle(request)

(B) Cloud Scheduler定期実行 ( weather_slack_scheduled )
毎朝 6 時に Cloud Scheduler がこの関数の URL を呼び出します。呼び出されたら、あらかじめ設定したデフォルト都市(DEFAULT_CITY)の天気を取得し、Slack チャンネルへ投稿します。

def weather_slack_scheduled(request):
import functions_framework
from slack_bolt.adapter.flask import SlackRequestHandler

from slack_app import app, build_slack_message
from weather_service import get_weather_data
from weather_setting import DEFAULT_CITY


# Cloud Functions (Flask) 用のハンドラ
handler = SlackRequestHandler(app)

@functions_framework.http
def weather_slack_events(request):
    """(A)Slack からのイベントを受け取るエントリポイント """
    return handler.handle(request)

@functions_framework.http
def weather_slack_scheduled(request):
    """
    (B)Cloud Scheduler から定期実行するエントリポイント
    設定した市町村(DEFAULT_CITY) の天気を取得して、チャンネルに投稿する
    """
    target_city = DEFAULT_CITY

    # 天気データ取得
    data = get_weather_data(target_city)
    if not data:
        return (f"住所から天気情報が取得できませんでした: {target_city}")

    # Slack 投稿用のテキストを生成
    text = build_slack_message(data)

    # Slack へ投稿
    app.client.chat_postMessage(channel="#all-slack-app", text=text)
    
    return ("OK")

7.2 シークレット管理 (secret_manager.py)

API キーや Slack のトークンをコードに直接書くのは危険です。
万が一コードが流出した場合、認証情報も漏れてしまう可能性があります。
そこで Google Cloud の Secret Manager を使い、認証情報を安全に管理します。

今回の実装では、起動時に Secret Manager にアクセスし、必要な認証情報をまとめて取得しています。

import json
from google.cloud import secretmanager_v1
from weather_setting import SECRET_NAME

# 認証情報の取得 (Secret Manager)
client = secretmanager_v1.SecretManagerServiceClient()
response = client.access_secret_version(request={"name": SECRET_NAME})
payload = response.payload.data.decode("UTF-8")
payload_dict = json.loads(payload)

BOT_TOKEN = payload_dict["BOT_TOKEN"]
SIGNING_SECRET = payload_dict["SIGNING_SECRET"]
YAHOO_API_KEY = payload_dict["YAHOO_API_KEY"]

SECRET_NAMEは weather_setting.py で管理しています。

SECRET_NAME = "projects/{プロジェクトID}/secrets/{シークレット名}/versions/latest"

7.3 メッセージハンドラ (slack_app.py)

Slack Bolt の @app.message デコレータを使うと、「今日の天気は」を含むメッセージに反応できます。また、App(token=BOT_TOKEN, signing_secret=SIGNING_SECRET) と設定することで、Slack Bolt が Slack から送られてきたリクエストの署名検証を行います。

「XXX市の今日の天気は」のような入力に対応するポイントは次の通りです。

  • メッセージテキストから「今日の天気は」を除いた残りを都市名として使用
  • 末尾の「の」を除去して「XXX市の今日の天気は」→「XXX市」と変換
  • 都市名が残らない場合はデフォルト都市(DEFAULT_CITY)を使用
import pandas as pd
from slack_bolt import App

from secret_manager import BOT_TOKEN, SIGNING_SECRET
from weather_service import get_weather_data
from weather_setting import DEFAULT_CITY

# -------------------------------------------------
# Slack Bolt アプリの準備
# -------------------------------------------------
app = App(token=BOT_TOKEN, signing_secret=SIGNING_SECRET)

# -------------------------------------------------
# 「今日の天気は」メッセージに反応するハンドラ
# -------------------------------------------------
@app.message("今日の天気は")
def handle_weather_request(message, say, client):
    """
     Slackからのメッセージに対して、
     「今日の天気は」(デフォルトの市町村)
     「XXX市の今日の天気は」
    という2つのパターンでメッセージが来た時の返信
    """

    # Slack チャンネルIDとユーザーID
    channel_id = message["channel"]
    user_id = message["user"]

    # メッセージテキストから場所を抽出
    text = message.get("text", "")
    # 先頭のキーワードを削除
    text = text.replace("今日の天気は", "").strip()

    if text == "":
        target_city = DEFAULT_CITY
    else:
        #「XXX市の」など末尾の「の」を削る
        target_city = text.replace("", "").strip()

    # 天気データを取得
    data = get_weather_data(target_city)

    # 住所から情報が取れなかったとき
    if not data:
        client.chat_postMessage(
            channel=channel_id,
            text=(
                "住所から天気情報が取れませんでした。\n"
                "誤字か、存在しない住所かもしれません。\n"
                f"指定:{target_city}\n"
                f"<@{user_id}>"
            )
        )
        return

    # Slack 投稿用テキストを組み立て
    msg_text = build_slack_message(data)

    # Slack に投稿
    client.chat_postMessage(channel=channel_id, text=msg_text)

# -------------------------------------------------
# Slack 送信用メッセージ文字列を作成する関数
# -------------------------------------------------
def build_slack_message(data):
    """
    get_weather_data() が作成した data から、
    Slack 送信用メッセージ文字列を作成
    data: {
        "date_text": str,
        "city_address": str,
        "weather_df": pandas.DataFrame,
        "index_df": pandas.DataFrame,
    }
    """

    date_text = data["date_text"]
    city_address = data["city_address"]
    df_weather = data["weather_df"]
    df_index = data["index_df"]

    # 最高/最低気温・最大風速を計算
    max_temp_idx = pd.to_numeric(df_weather["気温"]).idxmax()
    max_temp = df_weather.loc[max_temp_idx, "気温"]
    max_temp_time = df_weather.loc[max_temp_idx, "時刻"]

    min_temp_idx = pd.to_numeric(df_weather["気温"]).idxmin()
    min_temp = df_weather.loc[min_temp_idx, "気温"]
    min_temp_time = df_weather.loc[min_temp_idx, "時刻"]

    max_wind_idx = pd.to_numeric(df_weather["風速"]).idxmax()
    max_wind = df_weather.loc[max_wind_idx, "風速"]
    max_wind_time = df_weather.loc[max_wind_idx, "時刻"]

    # -------------------------------------------------
    # メッセージ本文の組み立て
    # -------------------------------------------------
    lines = []

    # ヘッダ部
    lines.append(f"日付 : {date_text}")
    lines.append(f"場所 : {city_address}")
    lines.append("----------------------------------------------")
    lines.append("今日の天気 ☀️☁️(Yahoo! 天気)")
    lines.append("----------------------------------------------")
    lines.append(f" 最高気温 : {max_temp:>2} ℃ ({max_temp_time:>2})")
    lines.append(f" 最低気温 : {min_temp:>2} ℃ ({min_temp_time:>2})")
    lines.append(f" 最大風速 : {max_wind:>2} m/s ({max_wind_time:>2})")
    lines.append("----------------------------------------------")
    lines.append("時刻| 天気|  気温|  湿度| 降水量|   風速|")
    lines.append("----------------------------------------------")

    # 「天気」列の幅を揃えるために、最大文字数を取得
    w_len = df_weather["天気"].str.len().max()

    # 時系列の天気テーブル
    for _, row in df_weather.iterrows():
        # 「天気」列を全角スペースで右側埋め
        weather_txt = row["天気"].ljust(w_len, " ")
        lines.append(
            f"{row['時刻']}|"
            f"{weather_txt:>3}|"
            f"{row['気温']:>3}℃|"
            f"{row['湿度']:>3}%|"
            f"{row['降水量']:>5}mm|"
            f"{row['風速']:>3}m/s| "
        )

    # 指数情報テーブル
    lines.append("----------------------------------------------")
    lines.append("今日の指数情報 📊(Yahoo! 天気)")
    lines.append("----------------------------------------------")

    # 「項目」列の幅を揃える
    n_len = df_index["項目"].str.len().max()

    for _, row in df_index.iterrows():
        name_txt = row["項目"].ljust(n_len, " ")
        emoji = row.get("絵文字", "") if pd.notna(row.get("絵文字", "")) else ""
        lines.append(
            f"{emoji} {name_txt} "
            f"{row['バー']} :{row['指数']:<3}"
        )
        lines.append(f" ( {row['アドバイス']} )")

    # 行をまとめて1つのテキストに
    return "\n".join(lines)

7.4 天気データと指数情報を取得 (weather_service.py)

Yahoo!天気・災害 のサイト URL は、次のような構成になっています。

https://weather.yahoo.co.jp/weather/jp/都道府県コード/地域コード/市区町村コード.html
 例:名古屋市中村区の Yahoo!天気・災害サイト
   https://weather.yahoo.co.jp/weather/jp/23/5110/23105.html

この URL を組み立てるために、コード内では次の 3 ステップで情報を取得しています。

Step 1: Geocoder API
住所から、緯度経度と GovernmentCode を取得します。この GovernmentCode を city_code として使い、先頭 2 桁を都道府県コード(pref_code)として取り出します。

Step 2: 気象情報 API
緯度経度から WeatherAreaCode を取得します。これは Yahoo!天気サイトの URL に含まれる地域コードに対応します。

Step 3: Yahoo!天気・災害サイト
都道府県コード + 地域コード + 市区町村コード を使って URL を組み立て、Beautiful Soup で 今日の天気データ指数情報 を取得します。

import re
import requests
import pandas as pd
from bs4 import BeautifulSoup
from weather_setting import URL_GEOCODER, URL_RAINFALL, URL_WEATHER, OUTPUT, EMOJI_MAP
from secret_manager import YAHOO_API_KEY
from datetime import datetime


def get_weather_data(target_city):
    """指定された住所の天気・指数情報をYahoo!から取得して整形する"""
    
    # ジオコーダAPIで座標と地域コードを取得
    params = {"appid": YAHOO_API_KEY, "output": OUTPUT, "query": target_city}
    response = requests.get(URL_GEOCODER, params=params)
    response.raise_for_status()
    geocoder_data = response.json()
    features = geocoder_data.get("Feature")

    if not features:
        return None

    city_address = features[0]["Name"]
    city_lon_lat = features[0]["Geometry"]["Coordinates"]
    city_code = features[0]["Property"]["GovernmentCode"]
    pref_code = city_code[:2]

    # 気象情報APIで地域コード( WeatherAreaCode )を取得
    url = URL_RAINFALL.format(YAHOO_API_KEY, city_lon_lat, OUTPUT)
    weather_data = requests.get(url).json()
    weather_area_code = weather_data['Feature'][0]['Property']['WeatherAreaCode']

    # Yahoo!天気サイトのスクレイピング
    soup = get_weather_soup(pref_code, weather_area_code, city_code)
    
    # 今日の天気テーブル解析
    df_weather = get_weather_table(soup)
    # 指数情報解析
    df_index = get_index_info(soup)
        
    # 日付文字列を作成
    date_text = datetime.now().strftime("%Y年%m月%d日")

    return {
        "date_text": date_text,
        "city_address": city_address,
        "weather_df": df_weather,
        "index_df": df_index
    }


def get_weather_soup(pref_code, area_code, city_code):
    """都道府県コードを考慮してHTMLを取得"""

    # Yahoo!天気サイトの北海道だけ都道府県コードが特殊なため(1a, 1b...)
    if pref_code == "01":
        for suffix in ["a", "b", "c", "d"]:
            try:
                url = URL_WEATHER.format(f"1{suffix}", int(area_code), int(city_code))
                resp = requests.get(url)
                resp.raise_for_status()
                return BeautifulSoup(resp.text, 'html.parser')
            except: continue
    
    url = URL_WEATHER.format(int(pref_code), int(area_code), int(city_code))
    resp = requests.get(url)
    return BeautifulSoup(resp.text, 'html.parser')


def get_weather_table(soup):
    """時系列の天気情報をDataFrame化"""
    table = soup.find(id="yjw_pinpoint_today")
    rows = table.find_all('tr')
    
    data = []
    # 各列(時刻、天気、気温、湿度、風向&風速)をzipでリスト化
    for t, w, temp, h, p, wind in zip(rows[0].find_all('td')[1:], rows[1].find_all('td')[1:], 
                                     rows[2].find_all('td')[1:], rows[3].find_all('td')[1:], 
                                     rows[4].find_all('td')[1:], rows[5].find_all('td')[1:]):
        # 時刻 (例: "9時" → "09時")
        time_text = t.get_text(strip=True)
        time = re.search(r"(\d+)", time_text).group(1)
        time_val = f"{int(time):02d}"
        # 風向&風速を分離 (例: "北北西2" → 風向=北北西 / 風速=2)
        wind_text = wind.get_text(strip=True)
        wind_match = re.compile(r"(\D+)(\d+)").search(wind_text)
        
        data.append({
            "時刻": time_val,
            "天気": w.get_text(strip=True),
            "気温": temp.get_text(strip=True),
            "湿度": h.get_text(strip=True),
            "降水量": f"{float(p.get_text(strip=True)):>4.1f}",
            "風速": wind_match.group(2),
            "風向": wind_match.group(1)
        })
    return pd.DataFrame(data)


def get_index_info(soup):
    """洗濯・傘などの指数情報を取得"""
    index_area = soup.find("div", class_="tabView_content is-active")
    
    data = []
    for item in index_area.find_all("dl", class_="indexList_item"):
        title = item.find("dt").get_text(strip=True)
        val_text = item.find("p", class_="index_value").get_text(strip=True)
        # 洗濯指数30 → 正規表現で指数のみ取得
        idx_num = re.compile(r"(\D+)(\d+)").search(val_text).group(2)
        # 指数のインジケーター作成
        indicator = "" * int(int(idx_num) * 0.1) + "" * (10 - int(int(idx_num) * 0.1))
        
        data.append({
            "項目": title,
            "指数": idx_num,
            "バー": indicator,
            "アドバイス": item.find("p", class_="index_text").get_text(strip=True),
            "絵文字": EMOJI_MAP.get(title, "")
        })
    return pd.DataFrame(data)

7.5 設定ファイル (weather_setting.py)

全体で使う定数を整理する。URLやデフォルト値をここに集約し、変更が必要な場合のファイルを1つにする。

# 外部サービスのURL設定
URL_GEOCODER = "https://map.yahooapis.jp/geocode/V1/geoCoder"
URL_RAINFALL = "https://map.yahooapis.jp/weather/V1/place?appid={}&coordinates={}&output={}"
URL_WEATHER = "https://weather.yahoo.co.jp/weather/jp/{}/{}/{}.html"

# アプリケーションの動作設定
OUTPUT = "json"
DEFAULT_CITY = "愛知県名古屋市中村区"

# Secret Managerのパス
SECRET_NAME = "projects/{プロジェクトID}/secrets/{シークレット名}/versions/latest"

# 表示用絵文字
EMOJI_MAP = {
    "洗濯": "🧺",
    "": "☂️ ",
    "紫外線": "🌞",
    "重ね着": "🧥",
    "乾燥": "💨",
    "風邪注意": "😷",
}

7.6 ライブラリ管理(requirements.txt)

Google Cloud Functionsでは、デプロイ時に requirements.txt を含めることで、必要なライブラリが自動的にインストールされます。本プロジェクトでは以下の主要ライブラリを使用しています。

beautifulsoup4==4.14.3 ; python_version >= "3.11" and python_version < "4"
functions-framework==3.10.0 ; python_version >= "3.11" and python_version < "4"
google-cloud-secret-manager==2.26.0 ; python_version >= "3.11" and python_version < "4"
pandas==3.0.0 ; python_version >= "3.11" and python_version < "4"
requests==2.32.5 ; python_version >= "3.11" and python_version < "4"
slack-bolt==1.27.0 ; python_version >= "3.11" and python_version < "4"

特に、GCF環境ではランタイムのバージョン(今回は Python 3.11)に合わせた依存関係の定義が、安定稼働の鍵となります。

8. Google Cloud 及び Slack の設定手順

Cloud Run functions のデプロイ以外の設定は、主に GCP コンソール(GUI)上で実施しました。
以下に、Google Cloud と Slack の設定手順をまとめます。

Step 1: プロジェクトの作成

  1. Google Cloud にアクセス
  2. 画面上部のプロジェクト選択 → 「新しいプロジェクト」をクリック
  3. プロジェクト名を入力して「作成」

Step 2: サービスアカウントの作成

今回の構成では、役割の異なるサービスアカウントを意識すると分かりやすいです。

  • 関数実行用サービスアカウント
    Cloud Run functions が実行時に使用する
    → Secret Manager からシークレットを取得するために使う

  • Scheduler 呼び出し用サービスアカウント
    Cloud Scheduler が HTTP ターゲットを呼び出すときに使用する
    → 定期実行用関数を認証付きで呼び出すために使う

まずは関数実行用のサービスアカウントを作成します。

  1. コンソール左メニュー → 「IAM と管理」→「サービスアカウント」
  2. 「サービスアカウントを作成」をクリック
  3. 任意の名前を入力して「作成して続行」
  4. 以下のロールを付与して「完了」
ロール 用途
Secret Manager のシークレット アクセサー Secret Manager からシークレットを取得
Cloud Functions 起動元 Cloud Scheduler からの呼び出しを許可

Step 3: Secret Manager にシークレットを登録

APIキーやトークンをコードに直接書かず、Secret Manager で一元管理する。

  1. コンソール左メニュー →「セキュリティ」→「Secret Manager」
  2. 「シークレットを作成」をクリック
  3. 名前に 「任意の名前」 を入力
  4. APIキーやSlackのトークンを JSON ファイルとして用意し、「シークレットの値」-「参照」で読込む
{
  "BOT_TOKEN": "xoxb-xxxxxxxxxxxx",
  "SIGNING_SECRET": "xxxxxxxxxxxxxxxx",
  "YAHOO_API_KEY": "xxxxxxxxxxxxxxxx"
}

Step 4: Cloud Functions のデプロイ

gcloud CLI を使ってデプロイする。

(A) Slackイベント処理関数

gcloud functions deploy weather-slack-events `
  --gen2 `
  --region=asia-northeast1 `
  --runtime=python313 `
  --source=src `
  --entry-point=weather_slack_events `
  --trigger-http `
  --service-account=[サービスアカウント名]@[プロジェクトID].iam.gserviceaccount.com 
  --allow-unauthenticated  
  • --allow-unauthenticated について

この関数は、Slack の Events API から送られてくる HTTP リクエストを受け取るための入口です。Slack は Google Cloud の IAM 認証を使ってアクセスする仕組みには対応していないため、Slack から呼び出される関数は外部公開が必要になります。そのため、Slack のイベント処理では --allow-unauthenticated を付けてデプロイします。

ただし、これは「誰でもアクセスできる URL になる」という意味でもあるため、アプリ側での検証が重要です。

今回の実装では、Slack Bolt に signing_secret を設定しており、Slack から送られた正当なリクエストかどうかを署名検証できます。

安全に公開するためのポイントは次の 3 つです。

  • --allow-unauthenticated を付けて Slack から到達できるようにする
  • Slack Bolt に signing_secret を設定し、署名検証を有効にする
  • SIGNING_SECRET を Secret Manager で安全に管理する

この 3 点が揃って初めて、安全性を意識した公開構成になります。

(B) 定期実行用関数

gcloud functions deploy weather-slack-scheduled `
  --gen2 `
  --region=asia-northeast1 `
  --runtime=python313 `
  --source=src `
  --entry-point=weather_slack_scheduled `
  --trigger-http `
  --service-account=[サービスアカウント名]@[プロジェクトID].iam.gserviceaccount.com  

Step 5: Cloud Scheduler の設定

毎朝6時に定期実行用関数を自動実行するジョブを作成する。

  1. コンソール左メニュー →「Cloud Scheduler」
  2. 「ジョブを作成」をクリック
  3. cron式の内容を入力して「作成」
項目
名前 weather-slack-scheduler
リージョン asia-northeast1
頻度 0 6 * * *(毎朝6時)
タイムゾーン 日本標準時(JST)
ターゲットタイプ HTTP
URL (B) 関数の URL
認証ヘッダー OIDC トークンを追加
サービスアカウント Scheduler 呼び出し用サービスアカウント

Cloud Scheduler から認証付きで HTTP 関数を呼び出す場合は、OIDC トークンを付与して実行します。

  • cronの書式:0 6 * * *
フィールド 意味
0 0分
6 6時
* 毎日
* 毎月
曜日 * 曜日指定なし

Step 6: Slack App の設定

  1. Slack API にアクセスし、アプリの設定画面を開く
  2. Slack Bot 超入門を参考に、Slackの設定をする
  3. Socket Mode を無効化にする
  4. Event Subscriptions を有効にし、Request URL に Cloud Functions A の URL を入力する

9. ハマったポイント

① エントリポイントは役割ごとに分けた方がわかりやすい

今回の実装では、Slackイベント処理用の weather_slack_events と、Cloud Schedulerによる定期実行用の weather_slack_scheduled を用意しました。
最初は1つの関数にまとめようとして試行錯誤していましたが、「呼び出し元が異なるなら、エントリポイントも分ける」と考えるようになってから、コードの役割が明確になり、構成もかなりシンプルになりました。

結果として、

  • Slackからのイベントを受け取る処理
  • 定期実行で天気を投稿する処理

を分けて考えられるようになり、全体の見通しが良くなりました。

② デプロイ後、Slack のメッセージ投稿がすぐ返らず原因の切り分けに苦労した

VS Code 上でローカル実行していたときは、Slack に比較的すぐ結果を返せていました。しかし、Cloud Run functions にデプロイして実行すると、Bot の投稿メッセージが表示されるまでに 1 分半ほどかかることがありました。

当初は「デプロイに失敗したのでは」と思い込み、原因調査に時間を使ってしまいました。
ただ、時間がかかった理由はそれだけではありません。複数のエラーや設定不備が同時に発生しており、問題を 1 つずつ切り分けて確認できていなかったことも、大きな要因でした。

この経験から、

  • ローカルで動くこと
  • クラウド上で正しく動くこと

は別問題として考える必要があること、そして、「ログを確認しながら段階的に原因を切り分けることの大切さ」を学びました。

Allow unauthenticated の確認プロンプトを見逃し、Slackからのリクエストを受け付けなかった

Slackイベント受信用の関数を初めて gcloud CLI でデプロイした際、VS Codeのターミナルでコマンドを実行すると、少し待ってから次の確認プロンプトが表示されました。

Allow unauthenticated invocations of new function [weather_slack_events]? (y/N)?

このプロンプトが表示される前に Enter キーを押してしまうと、デフォルト値の N が選択され、「未認証アクセスが拒否される設定」になります。その結果、SlackからのHTTPリクエストを受け付けることができず、Botが反応しない状態になってしまいました。私はこの点に気づくまでに時間がかかってしまいました。
そこで今回の記事では、デプロイコマンドに「--allow-unauthenticated」を明示的に追加し、あわせて Allow unauthenticated について記載しました。

10. 今後の発展アイデア

曇りの日に外へ洗濯物を干したとき、「もしかしたら雨が降るのでは?」と気になることがあります。 今回のシステムでは天気予報を取得していますが、さらに発展させることで 雨の予兆をSlackで通知するBot を作ることも可能です。

今回使用した Yahoo!気象情報 API では、

  • 現在の降水強度の実測値
  • 1時間先までの降水強度の予測値

を取得することができます。

この機能を活用すると、次のような仕組みを作ることができます。

  1. Cloud Scheduler を使って「10分ごとに降水強度を取得」
  2. 1時間以内に雨が降る可能性がある場合
  3. Slackに「まもなく雨が降る可能性があります」と通知

このようにすると、

  • 洗濯物の取り込み
  • 外出前の雨対策

今回作成した天気Botをベースに、雨通知ロジックを追加することで、さらに実用的なSlack Botへ発展させることができます。

(参考図)
ローカルで作成した「雨通知」コードの出力図です。雨降っていない図ですが、地図をクリックして雨雲の様子を確認することができます
雨_2.png

11. まとめ

今回は、Cloud Run functions × Slack Bot × Yahoo!天気API を組み合わせて、Slack で使える天気Botを作成しました。

システムのポイントをまとめると以下の通りです。

  • Slack Bolt + Cloud Run functions でサーバーレスな Slack Bot を構築できる
  • Secret Manager を使うことで認証情報をコードに含めず、安全に管理できる
  • Cloud Scheduler と組み合わせることで定期実行を実現できる
  • Yahoo! API とスクレイピングを組み合わせることで天気情報を取得できる

Google Cloud の各サービスは最初は設定が多く感じますが、一度構成を作ってしまえばメンテナンスしやすくなります。
Slack Bot の題材として天気Botは、API・スクレイピング・認証・定期実行をまとめて学べる良いテーマだと思います。

12. 学習を通して得たこと

① GCPは「設定を1つずつ確認する」ことが大切

Cloud Functions を BizCodeX スクールの教材で初めて学びました。最初は IAM、Service Account、Secret Manager など設定項目の多さに圧倒され、設定ミスによるエラーが何度も発生しました。

そのとき BizCodeX のほしさんから

「もう一度、設定を1つずつ確認しながら進めてください」

というアドバイスがあり、「設定を丁寧に確認しながら進めることの大切さ」を実感しました。

新しい技術を学ぶときは、

  • 設定したつもりでも設定されていない
  • 権限が不足している
  • サービスアカウントが違う

といった 小さな設定漏れが原因になることが多いと学びました。

② アーキテクチャ図を中心に整理すると理解が進む

教材を学習する際、最初は教材の順に自分なりのメモを作成していました。しかし実際に自分の課題を実装するときには、そのメモはあまり役に立ちませんでした。

理由は、教材は「学びやすい順序」で構成されているため、実際の自分の課題の構成とは必ずしも一致していないためです。そのため途中から、「アーキテクチャ図を中心に整理する方法」が良かったと気付きました。
具体的には、

  • アーキテクチャ図をベースにする
  • アーキテクチャ図に各サービスの設定内容を書く
  • どの章でどの設定を行ったかをアーキテクチャ図にメモする

課題作業の時間が空いてしまうと、設定方法/箇所/理由を忘れてしまい、自分のメモを見直したり、教材動画を行ったり来たりと、時間がかってしまいました。

③ エラーは必ず出る前提でログを確認する

初心者がクラウドサービスを使う場合、一度で動くことはほとんどなく、必ず複数のエラーが発生します。

今回の開発でも、

  • IAM権限エラー
  • Secret Managerのアクセス権限
  • Cloud Schedulerの呼び出し設定

など、さまざまなエラーを経験しました。今回のトライ&エラーを通して、

  • ログを確認する
  • 原因を1つずつ切り分ける
  • 修正して再デプロイする

ということを「習慣化」することができました。

13. 参考

5
5
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
5
5

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?