ota2000
9 min read

保育園の連絡システムがコドモンに変わったので LINE 転送とカレンダー同期を作り直した

保育園の連絡システムが10月からコドモンに変わった。前の記事で作った「保育園のメールを LINE グループに転送する仕組み」が動かなくなるので、コドモン向けに作り直した。ついでに、メールが届かない活動記録は LINE へ、行事予定は Google カレンダーへ流すようにした。

全体像

flowchart TD
    A["コドモン"] -->|お知らせメール| B["Gmail(ラベルで振り分け)"]
    B -->|Watch Push 通知| C["Cloud Pub/Sub"]
    C --> D["handler(Cloud Run functions)"]
    S1["Cloud Scheduler<br/>16:00〜20:30 に30分ごと"] --> E["notify-activities"]
    S2["Cloud Scheduler<br/>毎朝 6:00"] --> F["sync-calendar"]
    D -->|添付・資料室| A2["コドモンの保護者向け API"]
    E -->|活動記録・写真| A2
    F -->|予定| A2
    D --> G["Cloud Storage<br/>(添付・写真、署名付き URL)"]
    E --> G
    D --> L["LINE グループ"]
    E --> L
    F --> H["Google カレンダー"]

やっていることは4つ。

種類 きっかけ 届け先
お知らせ(本文) メール LINE
お知らせの添付ファイル、資料室の資料 メール LINE(ファイル)
活動記録(本文と写真) 定期確認 LINE
行事予定 毎朝 Google カレンダー

保護者向けの公開 API は見当たらなかった。添付ファイルや活動記録は、保護者向け Web 版が使っている API にログインして取得している。Web 版の仕様が変わると壊れる前提なので、失敗しても最低限の情報は届くようにして、失敗したらメールで気づけるようにした。

コドモンのお知らせメールを LINE に転送する

メールの転送部分は前の仕組みをそのまま使った。Gmail のフィルタで「受信トレイをスキップしてラベルを付ける」設定にしているので、Gmail の watch は INBOX ではなくそのラベルを監視している。

文字化けと改行

コドモンのメールは Content-Type で ISO-2022-JP を宣言しているのに、Gmail API の messages.get が返す本文は UTF-8 に変換済みだった。宣言どおりに ISO-2022-JP として扱うと文字化けするので、宣言された文字コードから順にエラーなくデコードできるものを探すようにした。

# 宣言された charset → よく使われる日本語の charset の順に試す(抜粋)
candidates = [CHARSET_ALIASES.get(declared, declared)] if declared else []
# ISO-2022-JP は 7bit なので UTF-8 としてもエラーにならない。エスケープシーケンスがあれば先に試す
if b"\x1b$B" in raw or b"\x1b$@" in raw:
    candidates.append("iso2022_jp_ext")
candidates += ["utf-8", "cp932", "euc_jp", "iso2022_jp"]
for charset in candidates:
    try:
        return raw.decode(charset)
    except (LookupError, UnicodeDecodeError):
        continue

もう1つ、text/plain のパートは段落の間の改行が消えていた。HTML のパートは <p> で段落が分かれているので、HTML からテキストに変換したものを本文に使っている。末尾の「保護者アプリ上でもお知らせを確認できます」から始まる案内は、区切り線(-----)以降に「保護者アプリ」という文字があれば取り除く。

添付ファイルをコドモンの API から取ってくる

コドモンの添付ファイルはメールに付かない。メールの末尾に「このお知らせにはファイルが添付されています」と書かれるだけで、アプリを開かないと見られない。

そこで、メールにこの一文があれば Web 版と同じ API にログインし、お知らせの一覧(タイムライン)からタイトルが一致するものを探して、添付ファイルをダウンロードするようにした。ダウンロードは「ワンタイムトークンを発行し、それを付けてファイルの URL を開くと、短時間だけ有効な S3 の署名付き URL にリダイレクトされる」という流れになっている。

取得したファイルは Cloud Storage に保存して7日間有効な署名付き URL を発行し、PDF などは LINE のボタン、画像は画像メッセージとして送る。

元のファイル名で保存されるようにする

LINE の URI アクションは1000文字までなので、日本語のファイル名をそのままオブジェクト名にすると署名付き URL が長くなりすぎる。オブジェクト名は短くして、元のファイル名は Content-Disposition に filename*(RFC 6266 / RFC 5987)で入れた。開いたときに元のファイル名で保存される。

encoded = quote(filename, safe="")
blob.content_disposition = f"inline; filename=\"{fallback}\"; filename*=UTF-8''{encoded}"

資料室の資料は掲載日時で探す

園だよりなどは「資料室」という別の機能に載る。メールには「資料室に掲載しております」と書かれたお知らせが届くだけで、添付の一文もない。しかもメールの件名(例:「10月のおたより」)と資料のタイトル(例:「園だより・給食だより・ほけんだより」)が一致しない。

タイトルで探せないので、本文に「資料室」とあるメールでは掲載日時で選ぶことにした。受信する前の6時間以内に掲載された資料のうち、最も新しいものと、その30分前までに一緒に掲載されたものを送る。

def select_handouts(handouts, received_at):
    candidates = [
        h for h in handouts
        if received_at - HANDOUT_LOOKBACK <= h.published_at <= received_at + CLOCK_SKEW
    ]
    if not candidates:
        return []
    newest = max(h.published_at for h in candidates)
    return [h for h in candidates if h.published_at >= newest - HANDOUT_BATCH_WINDOW]

資料室の API は資料の詳細に S3 の署名付き URL がそのまま入っているので、ワンタイムトークンは不要だった。

メールが来ない活動記録を定期的に取りに行く

日々の様子を写真付きで伝える「活動記録」は、メール通知がない。そこで Cloud Scheduler で 16:00〜20:30 の間、30分ごとに関数を呼ぶようにした。関数はタイムラインのうち活動記録(timeline_kind が activities)を取ってきて、LINE へ送る。

  • 送った記録は Cloud Storage に処理済みマーカー(processed/activity/<記録ID>)を作り、二重に送らない。メールの重複防止と同じく if_generation_match=0 で「なければ作る」を使っている
  • LINE の1回の送信は吹き出し5つまでなので、本文と写真4枚までを1回で送る。5枚以上ある日は「ほか N 枚はコドモンで確認」とリンクを付ける
  • 写真を取れなかったり LINE が画像を受け付けなかったりしたら、本文だけを送る

写真は CloudFront の署名付き URL で配信されていて、Web 版は width=500 を付けた縮小版を表示している。署名のポリシーは width=*&height=* を対象にしていたので、幅を変えれば大きい画像が取れると思ったが、500 以外の幅はすべて 400 Bad Request だった。画像サーバー側で受け付ける幅が決まっているようなので、Web 版と同じ 500px のまま送っている。

行事予定を Google カレンダーに同期する

コドモンのカレンダーにある行事予定(リトミック、英語、誕生日会など)は、毎朝 6:00 に Google カレンダーの専用カレンダーへ同期している。

  • Google カレンダーで専用のカレンダーを作り、Cloud Run functions のサービスアカウントに「予定の変更」の権限で共有した。関数はサービスアカウントの認証情報(ADC)で書き込むので、OAuth のトークンや鍵は増えない
  • 同期した予定には拡張プロパティ(source=codmon)で目印を付け、それだけを追加・更新・削除する。手で追加した予定には触れない
  • 予定の ID は codmon<コドモンの ID> にしている。Google カレンダーの ID は英小文字の a〜v と数字しか使えないが、コドモンの ID は数字なのでそのまま使える
  • 休日(毎週日曜、祝日、年末年始の休園など)は同期しない
def to_google_event(event: CodmonEvent) -> dict:
    body = {
        "id": event_id(event.id),
        "summary": event.title,
        "extendedProperties": {"private": {"source": "codmon", "codmon_id": event.id}},
        # ...
    }
    if event.all_day:
        start = date.fromisoformat(event.start[:10])
        end = max(date.fromisoformat(event.end[:10]), start)
        # 終日の予定の終了日は「含まない」ので翌日を指定する
        body["start"] = {"date": start.isoformat()}
        body["end"] = {"date": (end + timedelta(days=1)).isoformat()}
    return body

コドモンの API が不具合で予定を0件で返すと、「全部削除された」と判断して同期済みの予定をすべて消してしまう。毎週日曜の「休日」が必ず入るので、0件になるのは異常だ。0件のときは何もせずにエラーを出す安全装置を入れた。

運用まわりもついでに整えた

Terraform Cloud をキーなしにして plan と apply で権限を分けた

Terraform Cloud(リモート実行)から GCP へは、サービスアカウントキーを使っていた。これを Dynamic Provider Credentials(Workload Identity Federation)に切り替えて、キーは削除した。

さらに plan と apply で使うサービスアカウントを分けた。PR の speculative plan は読み取り専用のサービスアカウントで動き、owner 権限のサービスアカウントは apply フェーズの実行だけが使える。

resource "google_service_account_iam_member" "tfc_workload_identity" {
  service_account_id = google_service_account.tfc.name
  role               = "roles/iam.workloadIdentityUser"
  member             = "principalSet://iam.googleapis.com/${google_iam_workload_identity_pool.tfc.name}/attribute.terraform_run_phase/apply"
}

ワークスペースの環境変数は TFC_GCP_PLAN_SERVICE_ACCOUNT_EMAIL と TFC_GCP_APPLY_SERVICE_ACCOUNT_EMAIL を設定する。

失敗したらメールで知らせる

活動記録は失敗しても LINE には何も届かないので、止まっていても気づけない。Cloud Monitoring のログベースのアラートで、関数が ERROR ログを出したらメールが届くようにした。30分ごとに失敗し続けても大量に届かないよう、通知は1時間に1通までにしている。

condition_matched_log {
  filter = <<-EOT
    resource.type="cloud_run_revision"
    resource.labels.service_name=~"^gmail-line-notify-"
    severity>=ERROR
  EOT
}

uv と Dependabot

依存関係は uv で管理し、Cloud Run functions のビルドに使う requirements.txt はリポジトリに置かず、デプロイ時に CI で uv export して作るようにした。更新は Dependabot に任せていて、公開直後の版を避けるため cooldown で公開から7日経った版だけを対象にしている。

- package-ecosystem: uv
  directory: /
  schedule:
    interval: weekly
  cooldown:
    default-days: 7
  groups:
    python:
      patterns: ["*"]
      update-types: [minor, patch]

ハマったポイント

  • ログインが 404 になる:最初は素直にログイン API を呼んだら 404 が返ってきた。Web 版と同じ順番・同じ形でリクエストしないと通らなかった。開発者ツールで Web 版の通信を見比べながら合わせた
  • ISO-2022-JP の宣言を信じると文字化けする:Gmail API は本文を UTF-8 に変換して返すことがある
  • 写真の幅は変えられない:署名のポリシー上は通りそうでも、画像サーバー側で弾かれた
  • メールの件名と資料のタイトルが一致しない:タイトルではなく掲載日時で探すことにした

使ってみて

お知らせはこれまでどおり数秒で LINE に届き、PDF の園だよりもワンタップで開ける。夕方には活動記録が写真付きで届くので、アプリを開かなくても今日の様子が分かるようになった。行事予定は自分の予定と並んで Google カレンダーに表示されるので、保育園の行事とぶつからないか確認しやすい。

非公式な API を使っているので、Web 版が変わればいつか壊れる。そのときはアラートのメールが届くし、LINE には少なくとも本文とコドモンへのリンクが届くので、そこまで困ることはないはず。