7
8

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 Notify亡きあとに作る、離れて暮らす家族への熱中症アラートBot(環境省 暑さ指数 × Messaging API × AWS Lambda)

7
Last updated at Posted at 2026-08-04

Summer Messages Across Town.png

「エアコン? まだ大丈夫よ。扇風機あるから」

この夏、この台詞を電話口で聞いた方は、きっと少なくないと思います。言われたほうは「いや、大丈夫じゃないんだって」と思いながらも、それ以上は強く言えずに電話を切る。そして夕方のニュースで最高気温を見て、もう一度電話をかけようか迷う。

数字で見ると、この迷いは決して大げさではありません。総務省消防庁の速報値によると、7月20日〜26日の1週間だけで、熱中症による救急搬送は全国で18,591人。前の週から約7,700人も増えています。そして発生場所で最も多かったのは、屋外でも職場でもなく**「住居」**でした(ウェザーニュース, 2026年7月31日)。

家の中は安全、とは限らないのです。

そこで今回は、離れて暮らす家族のスマホに、暑い日の朝だけ、そっとLINEを届けるBotを作ります。材料は、環境省が公開している暑さ指数(WBGT)の予測データ、LINE Messaging API、そしてAWS Lambda。外部ライブラリは使わず、Pythonの標準ライブラリだけで書きます。

この記事で分かること

  • 気温ではなく「暑さ指数(WBGT)」を使うべき理由
  • 環境省の暑さ指数CSVを読むときにハマる3つのクセ(24時問題など)
  • LINE Notify終了後の通知手段としての Messaging API と、無料枠200通の賢い使い方
  • AWS Lambda + EventBridge Scheduler で「毎朝6時半」に動かす構成
  • 通知が「おせっかい」で終わらないための設計

対象は、Pythonの基本が分かり、AWSのコンソールを触ったことがある方です。家族の顔を思い浮かべながら読んでいただくと、たぶん実装のモチベーションが3割増しになります。


作る前に:まず公式のLINEを登録しよう

いきなり作る話の腰を折るようですが、最初にこれを書いておかないと、自分の約束に反してしまうので書きます。

環境省には、熱中症警戒アラートを配信しているLINE公式アカウントがあります。 友だち追加して地域を設定すれば、アラートが発表されたときに無料で通知が届きます。都道府県は複数登録できるので、離れて暮らす家族の地域も設定できます。

なので、もし「家族に熱中症の注意を届けたい」だけなら、Botを自作する前に、まずは家族と自分のスマホに公式アカウントを登録するのが正解です。車輪の再発明は、エンジニアの楽しみではありますが、家族の安全がかかっているときにやるものではありません。

それでも自作する価値はあるのか。私は、あると思っています。理由は3つです。

  1. 地点を細かく選べる。 公式アラートは「府県予報区」単位で発表されますが、暑さ指数の予測値は全国約840の観測地点ごとに公開されています。実家にいちばん近い地点の値で判断できます。
  2. 家族の言葉で書ける。 「熱中症警戒アラートが発表されました」より、「今日はエアコン朝からつけてね。電気代は気にしないで!」のほうが、たぶん響きます。
  3. 既読の先まで分かる。 「エアコンつけたよ」ボタンを押してもらえば、こちらに報告が届きます。既読がついても、エアコンがついたとは限りませんから。

つまり、自作Botは公式の代わりではなく、公式に「家族の声」と「返事」を足すものです。この位置づけを忘れないようにしながら、作っていきます。


第1章:なぜ「気温」ではなく「暑さ指数」なのか

最初に作りたくなるのは、たぶん「最高気温が35℃を超えたら通知」というBotです。私も最初はそう考えました。でも、熱中症のリスクは気温だけでは決まりません。

暑さ指数(WBGT)は、人の体と外気との熱のやりとりに大きく影響する3つの要素、つまり湿度、日射・輻射などの周辺の熱環境、気温を取り入れた指標です。屋外では、次の式で計算されます。

$$
\mathrm{WBGT} = 0.7 \times \text{湿球温度} + 0.2 \times \text{黒球温度} + 0.1 \times \text{乾球温度}
$$

ふだん私たちが「気温」と呼んでいるのは、最後の乾球温度です。その重みは、なんとわずか1割。いちばん重いのは、湿度の影響を強く受ける湿球温度の7割です。

「今日は気温のわりに、やけにしんどい」。日本の夏にそう感じる日が多いのは、気のせいではなかったわけです。気温だけを見るBotは、7割を見落としていることになります。

暑さ指数の値は、日本生気象学会の「日常生活における熱中症予防指針 Ver.4」(2022年)で、次のように区分されています(環境省熱中症予防情報サイト)。

暑さ指数(WBGT) 区分 目安
31以上 危険 すべての生活活動で熱中症が起こる危険性
28以上31未満 厳重警戒 外出時は炎天下を避け、室内では室温の上昇に注意
25以上28未満 警戒 中等度以上の生活活動で起こる危険性
25未満 注意 激しい運動や重労働で起こる危険性

今回のBotは、この区分をそのまま使い、「厳重警戒」以上の日だけ通知します。

なお、公式の「熱中症警戒アラート」は、府県予報区内のいずれかの地点で日最高の暑さ指数が33に達すると予測された場合に発表されます。Botの閾値(28)は、それより手前で一声かけるための、あくまで家族向けの「おせっかいライン」です。


第2章:環境省の暑さ指数CSVを読む ― 3つのクセ

データの取り方

環境省熱中症予防情報サイトでは、暑さ指数の予測値と実況値を、CSVでダウンロードできるようになっています(電子情報提供サービス)。地点ごとの予測値は、次のURLで取れます。

https://www.wbgt.env.go.jp/prev15WG/dl/yohou_{地点番号}.csv

地点番号は気象庁のアメダスの番号に対応していて、たとえば東京は 44132 です。家族の住む地点の番号は、サイトの地点一覧から探せます。

利用時のお約束
このデータを使うときは、「環境省熱中症予防情報サイト」からの提供情報を利用している旨を明記することになっています。今回のBotでは、通知メッセージの末尾に出典を入れます。家族向けのBotでも、ここは手を抜かないでおきましょう。

また、2026年の情報提供期間は4月22日から10月21日までです。期間外はデータが更新されないので、Botもそれに合わせて止める必要があります(後で出てきます)。

実際のCSVは、こんな形をしています(値は説明用の例です)。

yohou_44132.csv
,,2026080503,2026080506,2026080509,2026080512,2026080515,2026080518,2026080521,2026080524
44132,2026/08/05 05:25, 270, 275, 295, 315, 318, 296, , 275

たった2行。シンプルに見えます。でも、この2行には、素直に読むとつまずくクセが3つ隠れています。

クセ1:「24時」が存在する

1行目のヘッダーは YYYYMMDDHH 形式の日時で、3時間おきに並んでいます。問題は最後の 2026080524。24時です。

これを素直に strptime に渡すと、こうなります。

>>> from datetime import datetime
>>> datetime.strptime("2026080524", "%Y%m%d%H")
ValueError: unconverted data remains: 4

エラーメッセージが「時刻が不正です」ではなく「4が余っています」なのが、地味に意地悪なところです。%H は 24 を受け付けないので、2 だけを読んで時として解釈し、残った 4 の扱いに困っているわけです。初見だと、何が起きているのか一瞬分かりません。

さらに、24時をどの日に属させるか、という問題もあります。2026080524 は、時刻としては8月6日の0時です。でも、予報としては8月5日の一日の終わりの値です。「8月5日の最高値」を求めるなら、これは8月5日に入れたい。

なので、「実際の時刻」と「どの日の予報か」を分けて持つことにしました。

クセ2:値が10倍で入っている

2行目の 318 は、暑さ指数 31.8 のことです。値は10倍の整数で入っています。ここを忘れると、Botは「本日の暑さ指数は318です」という、地球最後の日のような通知を送ることになります。家族が別の意味で倒れかねません。

クセ3:先頭2列はデータではない

2行目の先頭2列は、地点番号と更新日時です。予測値は3列目から始まります。逆に言えば、更新日時が毎回ついてくるのは、とてもありがたいことです。これを使えば「データが古いまま更新されていない」ことを検知できます。

また、値の前には半角スペースが入っていて、欠測などで空欄が来る可能性もあります。念のため、strip() して空なら飛ばすようにしておきます。

パーサーの実装

以上を踏まえたコードが、こちらです。

wbgt.py
import csv
import io
import urllib.request
from collections import defaultdict
from dataclasses import dataclass
from datetime import date, datetime, timedelta

YOHOU_URL = "https://www.wbgt.env.go.jp/prev15WG/dl/yohou_{point}.csv"


@dataclass(frozen=True)
class Forecast:
    at: datetime      # 実際の時刻(24時は翌日0時に変換済み)
    day: date         # どの日の予報として扱うか(24時は「その日」の最後)
    wbgt: float


def parse_slot(slot: str) -> tuple[datetime, date]:
    """'2026080524' のような10桁を (実時刻, 所属する日) に変換する。

    環境省のCSVは「24時」表記を使うので、strptime(%H) に渡すと ValueError になる。
    日付部分と時間部分を分けて、時間は timedelta で足す。
    """
    day = datetime.strptime(slot[:8], "%Y%m%d")
    hour = int(slot[8:10])
    return day + timedelta(hours=hour), day.date()


def parse_yohou_csv(text: str) -> tuple[datetime, list[Forecast]]:
    rows = list(csv.reader(io.StringIO(text)))
    header, values = rows[0], rows[1]
    updated_at = datetime.strptime(values[1].strip(), "%Y/%m/%d %H:%M")
    result = []
    # 先頭2列は「地点番号」「更新日時」なので飛ばす
    for slot, raw in zip(header[2:], values[2:]):
        slot, raw = slot.strip(), raw.strip()
        if not slot or not raw:
            continue  # 欠測などで空欄が来ても落ちないように
        at, day = parse_slot(slot)
        result.append(Forecast(at=at, day=day, wbgt=int(raw) / 10))  # 値は10倍で入っている
    return updated_at, result


def fetch_forecasts(point: str, timeout: float = 10.0) -> tuple[datetime, list[Forecast]]:
    req = urllib.request.Request(YOHOU_URL.format(point=point), headers={"User-Agent": "family-wbgt-bot/1.0"})
    with urllib.request.urlopen(req, timeout=timeout) as res:
        return parse_yohou_csv(res.read().decode("utf-8-sig"))


def daily_max(forecasts: list[Forecast]) -> dict[date, float]:
    peaks: dict[date, float] = defaultdict(float)
    for f in forecasts:
        peaks[f.day] = max(peaks[f.day], f.wbgt)
    return dict(peaks)


# 日本生気象学会「日常生活における熱中症予防指針 Ver.4」(2022) の区分
LEVELS = [
    (31.0, "危険"),
    (28.0, "厳重警戒"),
    (25.0, "警戒"),
    (0.0, "注意"),
]


def level_of(wbgt: float) -> str:
    for threshold, name in LEVELS:
        if wbgt >= threshold:
            return name
    return "注意"

daily_max は、日ごとの最高値を求める関数です。Forecast が「どの日の予報か(day)」を持っているので、24時の値もちゃんと前の日の最高値の候補に入ります。

このパーサーは、実際に環境省のサイトから取得したCSVでも動作を確認しています(Python 3.13)。


第3章:LINE Notifyの後継としての Messaging API

LINE Notifyは、もういない

少し前までなら、この手の個人用通知はLINE Notify一択でした。トークンを1つ発行して、HTTPでPOSTするだけ。あの手軽さは、本当に素晴らしかったです。

ですが、LINE Notifyは2025年3月31日にサービスを終了しました。LINEヤフーからは、代替手段としてMessaging APIの利用が案内されています。

Messaging APIは、LINE公式アカウントからメッセージを送る仕組みです。Notifyに比べると準備は少し増えますが、そのぶんできることも増えます。今回使う「ボタン付きのメッセージ」や「返事を受け取る」ことは、Notifyにはできませんでした。

無料枠200通を、どう使うか

気をつけたいのは料金です。Messaging APIの無料プラン(コミュニケーションプラン)では、月200通まで送れます(Messaging APIの料金)。ここで大事なのが、何が「1通」に数えられるかです。

  • プッシュメッセージ(こちらから送るもの)は、送信先の人数ぶんカウントされる
  • グループに送った場合も、グループの人数ぶんカウントされる
  • 応答メッセージ(ユーザーの操作に返すもの)は、カウントされない

この仕様を踏まえて、今回のBotは次のように設計しました。

場面 送り方 カウント
朝の通知(家族へ) プッシュ 1通
ボタンを押してくれたときの「ありがとう」 応答 0通
「エアコンつけたって」の報告(自分へ) プッシュ 1通

最悪のケースでも、1日2通。31日すべてが「厳重警戒」以上だったとしても、月62通で収まります。家族全員のグループに送る設計にすると人数ぶん増えるので、あえて1対1で送ることにしました。

もう一つ、地味に大事なのが二重送信の防止です。Lambdaは、エラー時に自動で再試行されることがあります。その結果、同じ朝に同じ通知が2通届くと、受け取った側は「何かあったのか」と心配になります。Messaging APIには、リクエストに X-Line-Retry-Key ヘッダーを付けると、同じキーでの再送を二重に配信しない仕組みがあるので、これを使います。キーには「日付+地点」から作ったUUIDを使い、同じ朝なら何度実行しても同じキーになるようにしました。

ひとつ落とし穴があります。受付済みのキーで再送すると、LINEはステータスコード 409 を返します(公式ドキュメント)。これを普通のエラーとして扱うと、「二重送信を防げた」のに「失敗した」と判定され、再試行とエラー通知が延々と続くことになります。リトライキー付きの 409 は、前回の送信がちゃんと届いている証拠なので、成功として扱います。


第4章:Bot本体を書く

全体像

EventBridge Scheduler(毎朝6:30 JST)
        │ {"source": "wbgt.morning"}
        ▼
   AWS Lambda ──► 環境省CSV(暑さ指数の予測値)
        │
        ├─ 厳重警戒以上なら ──► LINE(家族へプッシュ)
        │
        ▲
  関数URL(Webhook) ◄── 家族が「エアコンつけたよ」をタップ
        │
        ├─► LINE(家族へ「ありがとう」を応答)
        └─► LINE(自分へ報告をプッシュ)

Lambda関数は1つだけです。朝の定期実行とWebhookの受信を、同じ関数で受けて振り分けます。小さなBotに、関数を2つも3つも用意する必要はありません。

コード

app.py
import base64
import hashlib
import hmac
import json
import os
import urllib.error
import urllib.request
import uuid
from datetime import datetime, timedelta
from zoneinfo import ZoneInfo

from wbgt import daily_max, fetch_forecasts, level_of

JST = ZoneInfo("Asia/Tokyo")
TOKEN = os.environ["LINE_CHANNEL_ACCESS_TOKEN"]
SECRET = os.environ["LINE_CHANNEL_SECRET"]
PARENT_ID = os.environ["PARENT_USER_ID"]   # 見守られる側
CHILD_ID = os.environ["CHILD_USER_ID"]     # 見守る側(自分)
POINT = os.environ.get("WBGT_POINT", "44132")  # 暑さ指数の地点番号
NOTIFY_FROM = os.environ.get("NOTIFY_FROM", "厳重警戒")  # この区分以上で通知する

ORDER = ["注意", "警戒", "厳重警戒", "危険"]

ADVICE = {
    "厳重警戒": "日中はエアコンをつけて、のどが渇く前にお水を一杯。",
    "危険": "今日は外出を控えて、エアコンは朝からつけっぱなしでお願いします。電気代は気にしないで!",
}


def line_api(path: str, payload: dict, retry_key: str | None = None) -> None:
    headers = {"Content-Type": "application/json", "Authorization": f"Bearer {TOKEN}"}
    if retry_key:
        # 同じキーで再送しても、LINE側で二重送信にならない
        headers["X-Line-Retry-Key"] = retry_key
    req = urllib.request.Request(
        f"https://api.line.me/v2/bot/message/{path}",
        data=json.dumps(payload).encode(),
        headers=headers,
        method="POST",
    )
    try:
        with urllib.request.urlopen(req, timeout=10) as res:
            res.read()
    except urllib.error.HTTPError as e:
        if retry_key and e.code == 409:
            return  # 同じリトライキーで受付済み = 前回の送信は届いている。成功として扱う
        raise


def build_alert(level: str, peak: float) -> dict:
    return {
        "type": "text",
        "text": (
            f"おはよう。今日の暑さ指数は最高{peak:.1f}の見込みで「{level}」レベルです。\n"
            f"{ADVICE[level]}\n\n"
            "(暑さ指数:環境省熱中症予防情報サイト)"
        ),
        "quickReply": {
            "items": [
                {"type": "action", "action": {"type": "postback", "label": "エアコンつけたよ",
                                              "data": "ack=ac", "displayText": "エアコンつけたよ"}},
                {"type": "action", "action": {"type": "postback", "label": "今日は涼しい部屋にいる",
                                              "data": "ack=cool", "displayText": "今日は涼しい部屋にいる"}},
            ]
        },
    }


def run_morning_check(now: datetime) -> str:
    today = now.date()
    updated_at, forecasts = fetch_forecasts(POINT)
    if now.replace(tzinfo=None) - updated_at > timedelta(hours=24):
        raise RuntimeError(f"予測値が古いままです(更新日時: {updated_at})")  # 昨日の予報で通知しない
    peaks = daily_max(forecasts)
    if today not in peaks:
        raise RuntimeError(f"{today} の予測値がCSVにありません")  # 黙らずに落とす
    peak = peaks[today]
    level = level_of(peak)
    if ORDER.index(level) < ORDER.index(NOTIFY_FROM):
        return f"skip: {level} ({peak})"
    # 「日付+地点」から決まるキーにして、Lambdaの再試行でも同じ朝に2通送らない
    retry_key = str(uuid.uuid5(uuid.NAMESPACE_URL, f"wbgt/{POINT}/{today.isoformat()}"))
    line_api("push", {"to": PARENT_ID, "messages": [build_alert(level, peak)]}, retry_key)
    return f"sent: {level} ({peak})"


def verify_signature(body: str, signature: str) -> bool:
    mac = hmac.new(SECRET.encode(), body.encode(), hashlib.sha256).digest()
    return hmac.compare_digest(base64.b64encode(mac).decode(), signature)


def handle_webhook(event: dict) -> dict:
    body = event.get("body") or ""
    if event.get("isBase64Encoded"):
        body = base64.b64decode(body).decode()
    headers = {k.lower(): v for k, v in (event.get("headers") or {}).items()}
    if not verify_signature(body, headers.get("x-line-signature", "")):
        return {"statusCode": 401}

    for ev in json.loads(body).get("events", []):
        user_id = ev.get("source", {}).get("userId")
        if ev["type"] == "follow":
            print(f"follow from userId={user_id}")  # 初回だけ、ログからIDを控える
        elif ev["type"] == "postback" and user_id == PARENT_ID:
            # 返信(reply)は通数にカウントされない。お礼はreplyで返す
            line_api("reply", {"replyToken": ev["replyToken"],
                               "messages": [{"type": "text", "text": "ありがとう、安心しました。"}]})
            # 自分への報告はpushなので1通ぶんカウントされる
            label = {"ack=ac": "エアコンをつけた", "ack=cool": "涼しい部屋にいる"}.get(ev["postback"]["data"], "返信あり")
            line_api("push", {"to": CHILD_ID, "messages": [{"type": "text", "text": f"実家より:{label}とのこと。"}]})
    return {"statusCode": 200}


def lambda_handler(event, context):
    # EventBridge Schedulerからの起動か、関数URL(Webhook)からの起動かで分ける
    if event.get("source") == "wbgt.morning":
        result = run_morning_check(datetime.now(JST))
        print(result)
        return {"result": result}
    return handle_webhook(event)

いくつか補足します。

quickReply について。 通知メッセージの下に、「エアコンつけたよ」「今日は涼しい部屋にいる」の2つのボタンが表示されます。文字を打つのが面倒な人でも、タップ1回で返事ができます。返事のハードルを下げることは、見守る側が思っている以上に大事です。

Webhookの署名検証について。 関数URLはインターネットに公開されるので、LINEから届いたリクエストかどうかを必ず確認します。x-line-signature ヘッダーの値と、チャネルシークレットで計算したHMAC-SHA256を比べ、一致しなければ401を返します。比較には == ではなく hmac.compare_digest を使います。処理時間の差から署名を推測される、タイミング攻撃を避けるためです。

「黙らずに落とす」について。 予測値が古いとき、今日の値がないときは、通知を送らずに例外を投げて終了させています。ここが今回いちばんこだわったところなので、後の章でじっくり書きます。

家族のユーザーIDの調べ方。 Messaging APIでプッシュを送るには、相手のユーザーIDが必要です。家族にBotを友だち追加してもらうと follow イベントが届くので、そのときのログからIDを控えて、環境変数 PARENT_USER_ID に設定します。自分のIDは、LINE Developersコンソールのチャネル基本設定に表示されています。


第5章:AWSにデプロイする

外部ライブラリを使っていないので、wbgt.py と app.py の2ファイルをZIPにしてアップロードするだけです。Lambdaレイヤーも、コンテナイメージも要りません。

1. Lambda関数を作る

  • ランタイム:Python 3.13
  • ハンドラ:app.lambda_handler
  • タイムアウト:30秒(デフォルトの3秒だと、環境省のサイトの応答が遅い日に足りないことがあります)
  • 環境変数:
キー 値
LINE_CHANNEL_ACCESS_TOKEN チャネルアクセストークン
LINE_CHANNEL_SECRET チャネルシークレット
PARENT_USER_ID 家族のユーザーID
CHILD_USER_ID 自分のユーザーID
WBGT_POINT 地点番号(例:44132)
NOTIFY_FROM 通知する最低区分(例:厳重警戒)

個人用の小さなBotなので環境変数に入れていますが、チームで運用するなら、トークン類は AWS Secrets Manager や SSM Parameter Store に置くのがおすすめです。環境変数は、コンソールで関数を開いた人なら誰でも見えてしまいます。

2. 関数URLを有効にしてWebhookに設定する

Lambdaの「関数URL」を、認証タイプ NONE で有効にします。認証なしで大丈夫なのか、と思うかもしれませんが、前の章で書いた署名検証が、その役割を担います。発行されたURLを、LINE DevelopersコンソールのWebhook URLに設定し、「Webhookの利用」をオンにします。

3. EventBridge Schedulerで毎朝起動する

EventBridge Schedulerでスケジュールを作ります。

  • スケジュール:cron(30 6 * * ? *)
  • タイムゾーン:Asia/Tokyo
  • ターゲット:作成したLambda関数
  • 入力(JSON):{"source": "wbgt.morning"}
  • 終了日:2026年10月21日(暑さ指数の情報提供期間の終わり)

EventBridge Schedulerは、タイムゾーンを指定できるのがありがたいところです。UTCで書いて9時間ずらす、という計算をしなくて済みます。朝6時半を cron(30 21 * * ? *) と書いて、「前日の21時半」だと気づかずに首をかしげる、という昔ながらのハマりどころを、最初から避けられます。

終了日を入れておくのも、地味ですが大事です。情報提供期間が終わったあともBotが毎朝動き続けると、データがないことでエラーが出続けて、本当に大事なエラーが埋もれてしまいます。

4. 失敗したら、自分にだけ知らせる

最後に、CloudWatchでLambdaの Errors メトリクスにアラームを設定し、SNS経由で自分のメールに通知が届くようにします。家族には送りません。家族に届くべきなのは「暑いから気をつけて」であって、「Botが壊れました」ではないからです。

料金については、1日1回の実行と、たまのWebhookだけなので、ほとんど誤差の範囲に収まるはずです。


第6章:「便りのないのは良い便り」を、Botに許さない

ここからは、コードの外の話です。でも、今回いちばん書きたかったのは、実はこの章です。

沈黙には2種類ある

「便りのないのは良い便り」ということわざがあります。連絡がないのは、無事に暮らしている証拠だ、という意味です。

ところが、Botの世界では、このことわざは成り立ちません。Botが黙っているとき、そこには2つの可能性があります。

  1. 今日は涼しいので、通知する必要がなかった
  2. Botが壊れていて、通知できなかった

受け取る側からは、この2つの区別がつきません。そして、家族の安全を見守るBotにとって、いちばん怖いのは2つ目です。猛暑の日に、Botが静かに壊れている。家族は「今日は通知が来ないから大丈夫なんだ」と思っている。これは、Botがない状態よりも悪いかもしれません。

Amazonの CTO、ヴェルナー・ヴォゲルスの有名な言葉があります。

Everything fails, all the time.
(あらゆるものは、常に壊れる)

壊れることを前提にするなら、大事なのは壊れたときに黙らないことです。今回のコードで、データが古いときや今日の値がないときに、あえて例外を投げて落としているのは、このためです。落ちればCloudWatchのアラームが鳴り、自分のメールに届きます。「静かに、それっぽく動き続ける」のが、いちばん危ない壊れ方です。

オオカミ少年にしない

逆に、通知が多すぎるのも問題です。毎朝「今日も暑いです」と届けば、3日で誰も読まなくなります。イソップ寓話のオオカミ少年と同じで、本当に危ない日の通知まで読み流されてしまいます。

なので、通知は「厳重警戒」以上の日だけ、1日1通まで。「警戒」レベルの日は、あえて送りません。閾値の NOTIFY_FROM を環境変数にしたのは、家族の体調や住環境に合わせて調整できるようにするためです。実際に運用してみて、通知が多すぎると感じたら 危険 に上げればいいし、心配なら 警戒 に下げればいい。

命令しない、責めない

メッセージの文面にも、少しこだわりました。

「エアコンをつけてください」ではなく、「エアコンは朝からつけっぱなしでお願いします。電気代は気にしないで!」。

エアコンをつけたがらない理由は、人によっていろいろです。電気代がもったいない、体が冷えるのが嫌、そもそも暑さを感じにくい。理由を無視して「つけなさい」と言われると、人は意外と反発するものです。だから、いちばんよくある理由(電気代)を、先回りして取り除いておく。

そして返事のボタンを押してくれたら、必ず「ありがとう、安心しました」と返す。見守りは、監視とは違います。相手が返事をしたくなる仕組みにしておかないと、長くは続きません。

言うまでもないことですが、このBotを家族に使ってもらう前には、何をするBotなのかを説明して、了解をもらってください。 黙って見守りBotを仕掛けるのは、気持ちはどうあれ、相手からすれば気持ちのいいものではありません。

転ばぬ先の杖、のさらに先へ

ベンジャミン・フランクリンは、1735年、フィラデルフィアの新聞への投書で、火災への備えについてこう書きました。

An ounce of prevention is worth a pound of cure.
(1オンスの予防は、1ポンドの治療に値する)

日本語で言えば、「転ばぬ先の杖」「備えあれば憂いなし」です。熱中症は、予防できる病気です。だからこそ、毎朝のたった1通に、意味があると思っています。

ただ、杖は渡すだけでは役に立ちません。使ってもらって初めて、杖になります。「エアコンつけたよ」の返事が届いて初めて、このBotの仕事は完了します。


まとめ

今回作ったBotを、改めて整理しておきます。

項目 内容
データ 環境省熱中症予防情報サイトの暑さ指数(WBGT)予測値CSV
判定 日本生気象学会の指針の区分で「厳重警戒」以上なら通知
通知 LINE Messaging API(プッシュ+クイックリプライ)
返事 Webhookで受け取り、お礼は応答メッセージ(無料)で返す
実行基盤 AWS Lambda(関数URL)+ EventBridge Scheduler(Asia/Tokyo)
監視 失敗はCloudWatchアラーム → 自分のメールへ
費用 LINEは無料枠内(最大でも月62通)、AWSはほぼ誤差

ハマりどころも、もう一度。

  • 環境省CSVの24時は strptime で読めない。しかも「どの日の値か」を分けて考える必要がある
  • 値は10倍で入っている
  • 出典の明記は利用条件
  • Messaging APIのプッシュは人数ぶんカウント、応答は無料
  • Lambdaの再試行での二重送信は X-Line-Retry-Key で防ぐ。受付済みを示す 409 は成功として扱う
  • Botの沈黙は「安全」と「故障」の区別がつかない。壊れたら黙らずに落とす

そして、技術以前の大事なこと。まずは環境省の公式LINEを登録してから。


おわりに

「親孝行したい時に親はなし」ということわざがあります。

この記事を書きながら、何度かこの言葉が頭をよぎりました。毎朝電話をかけるのは難しくても、毎朝6時半にBotを動かすことなら、エンジニアにはできます。コードで親孝行、というと少し大げさですが、コードだからこそ毎日休まず続けられる、ささやかな親孝行もあると思うのです。

この記事のコードは、標準ライブラリだけで200行足らずです。週末の数時間があれば、十分に作れます。もちろん、見守る相手は親でなくても構いません。祖父母でも、一人暮らしを始めた家族でも、夏の屋外で働く友人でも。

明日の朝6時半。この記事を読んだ誰かの家族のスマホが、一度だけ、やさしく鳴りますように。

そして、その数分後に、こう返ってきますように。

エアコンつけたよ


筆者について
生成AI × Web開発のフリーランスエンジニアです。LINE Bot・Slack Botによる業務自動化、RAGチャットボットの構築、Laravel / Next.jsでのWeb開発、AWSでのシステム構築などをお受けしています。「こういう通知の仕組み、うちの業務にも作れないかな」というご相談も歓迎です。
→ Lancersのプロフィール

最後まで読んでいただき、ありがとうございました。「うちではこんな工夫をしている」というアイデアがあれば、ぜひコメントで教えてください。いいね・ストックもとても励みになります。

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

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?