保育園の連絡システムがコドモンに変わったので 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 には少なくとも本文とコドモンへのリンクが届くので、そこまで困ることはないはず。