Google Calendar for Team Events for Slack を GAS で自作してみる ~定時通知編~
IT技術
はじめに
こんにちは!株式会社ライトコードの福岡本社でモバイルエンジニアやってる こー です!
2021.11.05YOUは何しにライトコードへ?〜こーくん編〜プロジェクト内で安心感を与えられる存在になりたい!今回は、弊社のエンジニアである高さんにフィーチャー!技術力に定評のあ...
今回は Google Calendar for Team Events for Slack を GAS で自作した話をしていきます。
Google Calendar for Team Events for Slack って何?
Google Calendar for Team Events for Slack とは、Google ワークスペースにおけるチームの共有カレンダーのイベントを自身の Slack ワークスペースに 通知してくれるアプリケーションです。
以下のようなことができます。
- 予定の作成を通知
- 当日の予定一覧を通知 etc...
どうして自作しようと思ったの?
弊社には、「みんなの休暇予定」という休暇予定用の共有カレンダーがあります。
このカレンダーへの休暇予定の作成や当日の休暇予定一覧を、Google Calendar for Team Events を利用して弊社 Slack ワークスペースの勤怠報告チャンネルに通知する、という運用をしていました。
カレンダーに登録して、勤怠報告チャンネルで周知して...という二度手間やそれに伴うヒューマンエラーを解消する目的ですね。
ですが、そんな中 Slack 公式から悲しいお知らせがありました。
要約すると
- Google Calendar for Team Events は廃止予定だよ
- いつ廃止するかは決まってないけど、廃止するときはまたアナウンスするね
- 廃止まで引き続き使えるけど、もう新規導入はできないよ
- Google カレンダーアプリを代わりに使ってね
「あ、Google カレンダーアプリで代用できるんだー」と思ったのですが、なんと
予定の通知機能がありませんでした。
つまり、通知機能を主に利用していた人にとって代替として使うことはできない、ということですね(泣)
それだったら、「もう自分で作ってしまうか〜」と思い立ったのが自作のきっかけですね。
GAS って何?なんで使おうと思ったの?
GAS とは Google Apps Script の略で、Google が提供しているサーバーレスアプリケーションを作ることのできるサービスです。
開発言語は JavaScript がベースであり、Web開発をされている方に親しみやすいのはもちろん、普段プログラミング言語に触れている方には比較的取っ掛かりやすいのが特徴です。
Google が提供しているということで、Google の各種サービス(ドライブ、スプレッドシート、カレンダー、etc...)との連携が簡単にできるのも魅力ですね。
- サクッと導入できる
- Google のサービスとの連携が簡単
- GAS が好き
ということで、GAS を採用しました。(笑)
何を作ったの?
実際には
- 当日の予定一覧を通知(定時通知)
- 予定の作成を通知(即時通知)
の2つを作りましたが、2つとも説明すると長くなってしまうので、本記事では1の定時通知だけ取り上げます。
(2については後日また記事を出そうかと思います)
作ったもの
それでは実際に作ったものを追いながら、解説していこうと思います。
以下の順番で作っていきます。
- 当日の終日予定の抽出
- 通知メッセージの組み立て
- Slack への通知
- 「平日の午前8時」に通知を行うための仕組みを作成
- GAS アプリケーションのデプロイ
- GUI 上でのトリガーの設定
Slack 通知の構成
- メッセージに「予定数」
- Attachment のタイトルに「予定へのリンク付きの予定名」、テキストに「予定の日付」
Attachment は Slack メッセージをよりリッチにする機能で、様々な装飾ができるので Slack の公式App で通知メッセージによく利用されていますね。
今回は Attachmentの「タイトル」と「テキスト」のみを利用するシンプルな通知なので詳しい説明は割愛します。
詳細を知りたい方は 公式ドキュメント をご参照くださいませ!
メインコード
これが通知処理を行うメインのコードです。
メソッド分けしている部分は、解説の際に詳しく見ていきます。
1function notifyTodayHolidayEvents() {
2 // 1. 当日の予定を抽出
3 const calendarId = PropertiesService.getScriptProperties().getProperty("CALENDAR_ID")
4 const calendar = CalendarApp.getCalendarById(calendarId)
5
6 const date = new Date()
7 const events = calendar.getEventsForDay(date).filter(
8 (event) => event.isAllDayEvent()
9 )
10
11 // 2. 通知メッセージの組み立て
12 let message = ""
13 switch (events.length) {
14 case 0:
15 message = "There is no event today"
16 break
17 case 1:
18 message = "There is *1* event today"
19 break
20 default:
21 message = `There are *${events.length}* events today`
22 }
23
24 const attachments = makeAttachmentsFromCalendarEvents_(events, calendarId)
25
26 // 3. Slackへの通知
27 postSlack_(message, attachments)
28}
当日の終日予定を抽出
1 // 1. 当日の予定を抽出
2 const calendarId = PropertiesService.getScriptProperties().getProperty("CALENDAR_ID")
3 const calendar = CalendarApp.getCalendarById(calendarId)
4
5 const date = new Date()
6 const events = calendar.getEventsForDay(date).filter(
7 (event) => event.isAllDayEvent()
8 )
Calendar ライブラリの追加
当日の予定を GAS のライブラリである CalendarApp を用いて取得するため、このライブラリをプロジェクトに追加します。
コードエディタ画面左の [サービス] -> [+] で [Google Calendar API] を選択し [追加] をクリックすることで追加できます。
カレンダーIDの取得
カレンダーの取得にはカレンダーIDが必要になるのですが、これはカレンダーの [設定] -> [カレンダーの設定] -> [カレンダーID] で確認することができます。
カレンダーIDの保存
さて、このカレンダーIDですが、
1const calendarId = PropertiesService.getScriptProperties().getProperty("CALENDAR_ID")
と見慣れないコードで参照していますね。
これは GAS のスクリプトプロパティという機能を利用しています。
スクリプトプロパティとは?
スクリプトプロパティは、GAS 上で利用する環境変数のようなもので、key-value 形式でデータを保管することができます。
主にAPIキー・ID・パスワードなど、セキュリティの観点でコード上にベタ書きすると好ましくない秘匿情報を保存するために利用されます。
GitHubなどで管理する際にこれらの秘匿情報をオープンにしてしまうのを防ぐこともでき、作った GAS アプリケーションを安全に公開することができるようになります。
今回のカレンダーIDは、「会社の共有カレンダー」という秘匿情報に当たるため、スクリプトプロパティに保存します。
スクリプトプロパティへの保存
スクリプトプロパティへの保存は、GAS プロジェクト左の [設定]アイコン -> スクリプトプロパティで行うことができます。
上記画像のように、CALENDAR_ID というプロパティに先程取得したカレンダーIDをセットします。
これで冒頭でお見せした、
1const calendarId = PropertiesService.getScriptProperties().getProperty("CALENDAR_ID")
のように書くことで、スクリプトプロパティの値をコード上で参照することができるようになります。
カレンダーオブジェクトの取得
取得したカレンダーID から、
1const calendar = CalendarApp.getCalendarById(calendarId)
Calendar ライブラリを利用することで、カレンダーオブジェクトを簡単に取得することができます。
このように、冒頭で述べた通り Google サービスとの連携が簡単にできるんですね!
そのため、Google サービス関連で何かアプリケーションを作る時は、この手軽さからまず GAS を使いたくなります(笑)
カレンダーオブジェクトから当日の終日予定のみを抽出
弊社では休暇予定は終日で登録する運用なので、「通知当日」の「終日予定」のみを抽出します。
1const date = new Date()
2const events = calendar.getEventsForDay(date).filter(
3 (event) => event.isAllDayEvent()
4)
カレンダーオブジェクトには getEventsForDay() という指定日の予定を取得するメソッドがあります。
このメソッドは通知当日に実行される想定なので、new Date() で当日の Date オブジェクトを生成、これを getEventsForDay() メソッドの引数に渡し、当日の予定(=Eventオブジェクト)郡を取得します。
そして取得される Event オブジェクトには、 isAllDayEvent() という「予定が終日かどうか」を判定してくれるメソッドがあるので、filter メソッドを使い「終日の予定」の予定を抽出できます。
通知メッセージの組み立て
1 // 2. 通知メッセージの組み立て
2 let message = ""
3 switch (events.length) {
4 case 0:
5 message = "There is no event today"
6 break
7 case 1:
8 message = "There is *1* event today"
9 break
10 default:
11 message = `There are *${events.length}* events today`
12 }
13
14 const attachments = makeAttachmentsFromCalendarEvents_(events, calendarId)
予定数の通知メッセージ
1 let message = ""
2 switch (events.length) {
3 case 0:
4 message = "There is no event today"
5 break
6 case 1:
7 message = "There is *1* event today"
8 break
9 default:
10 message = `There are *${events.length}* events today`
11 }
Event オブジェクトの配列である変数 events に先程抽出した予定郡を格納したので、この要素数(=length)がそのまま予定数に当たります。
今回は英語表記している都合上、単数形と複数形で文章を変える必要があるので switch 分岐でメッセージを構成するようにしています。
予定内容を表示する Attachment を構成
1const attachments = makeAttachmentsFromCalendarEvents_(events, calendarId)
予定内容の表示を司る Attachment オブジェクト郡はこの makeAttachmentsFromCalendarEvents_() というメソッドで Event オブジェクトの配列から取得しています。
このメソッドを解説する前に、まず取得される Attachment クラスについて軽く解説します。
1class Attachment {
2 /*
3 * https://api.slack.com/reference/messaging/attachments
4 * @param {String} title 一番上に表示する文字列
5 * @param {String} titleLink titleに付与するリンク
6 * @param {String} text titleの下に表示する文字列
7 * @param {String} color Attachmentの縦線の色(#36a64eで固定)
8 */
9 constructor(title, titleLink, text) {
10 this.title = title
11 this.title_link = titleLink
12 this.text = text
13 this.color = "#36a64f"
14 }
15}
説明はコードのコメントに譲りますが、それぞれのプロパティは通知における以下の要素に対応しています。
Attachment に添えられる線の色である color は、今回は #36a64f という色で固定しています。
今回の例は予定が1つの場合ですが、予定が複数ある場合はこの Attachment を配列で post することで、予定の数だけ Attachment を連結させた通知になります。
それでは、makeAttachmentsFromCalendarEvents_() メソッドを見ていきましょう。
1function makeAttachmentsFromCalendarEvents_(events, calendarId) {
2 return events.map(function(event) {
3 // title: 予定タイトル
4 const title = event.getTitle()
5
6 // title_link: 予定へのリンクを作成
7 const splitEventId = event.getId().split('@')
8 const calendarIdForEid = calendarId.split('@')[0] + "@g"
9 const eid = Utilities.base64Encode(splitEventId[0] + " " + calendarIdForEid).replace("=","")
10 const titleLink = `https://calendar.google.com/calendar/event?eid=${eid}`
11
12 // text: 予定の日付(ex. 12/31(日) ~ 1/5(木))
13 const startTime = event.getStartTime()
14 const endTime = event.getEndTime()
15 endTime.setDate(endTime.getDate() - 1) // 終日だと予定終了時間が翌日0:00となり1日多くなってしまうため、1日引いて日付を戻す
16 let text = Utilities.formatDate(startTime, "GMT+0900", "M/d") + `(${LocalizedDay[startTime.getDay()]})`
17 if (startTime.getDate() != endTime.getDate()) {
18 text += Utilities.formatDate(endTime, "GMT+0900", "〜 M/d") + `(${LocalizedDay[endTime.getDay()]})`
19 }
20
21 return new Attachment(title, titleLink, text)
22 })
23}
このメソッドでは、Event オブジェクトの配列を map メソッドを用いて Attachment オブジェクトの配列に変換して返しています。
処理はおおまかに以下のことをやっています。
- title: event.getTitle() をセット。
- titleLink: event.getId() をカレンダー用のリンクに変換してセット。
- text: 予定の開始日と終了日を、それぞれ event.getStartTime() / event.getEndTime() から取得してセット。
以下で詳しく見ていきます。
1. title: event.getTitle() をセット
1 // title: 予定タイトル
2 const title = event.getTitle()
これはそのままですね。
getTitle() で予定のタイトルが取得できるので、そのまま title に設定します。
2. titleLink: event.getId() をカレンダー用のリンクに変換してセット
1 // title_link: 予定へのリンクを作成
2 const splitEventId = event.getId().split('@')
3 const calendarIdForEid = calendarId.split('@')[0] + "@g"
4 const eid = Utilities.base64Encode(splitEventId[0] + " " + calendarIdForEid).replace("=","")
5 const titleLink = `https://calendar.google.com/calendar/event?eid=${eid}`
予定へのリンク作成は少し手間がかかりますが、正直本題とは外れるので説明は最小限に抑えます。
軽くやっていることの流れを説明します。
- カレンダーID 中の @ より前の文字列を取り出し、それに「@g」を付け足す。
- 予定のID 中の @ より前の文字列を取り出す。
- 「1の文字列 + 半角スペース + 2の文字列」から「=」を取り除いた文字列を base64形式でエンコードする。
- https://calendar.google.com/calendar/event?eid={3の文字列} が 予定へのリンクとなるので、これを titleLink に設定する。
3. text: 予定の開始日と終了日を、それぞれ event.getStartTime() / event.getEndTime() から取得してセット
1 // text: 予定の日付(ex. 12/31(日) ~ 1/5(木))
2 const startTime = event.getStartTime()
3 const endTime = event.getEndTime()
4 endTime.setDate(endTime.getDate() - 1) // 終日だと予定終了時間が翌日0:00となり1日多くなってしまうため、1日引いて日付を戻す
5 let text = Utilities.formatDate(startTime, "GMT+0900", "M/d") + `(${LocalizedDay[startTime.getDay()]})`
6 if (startTime.getDate() != endTime.getDate()) {
7 text += Utilities.formatDate(endTime, "GMT+0900", "〜 M/d") + `(${LocalizedDay[endTime.getDay()]})`
8 }
9
10 return new Attachment(title, titleLink, text)
ここでは、予定の開始日と終了日から「開始日(曜日) ~ 終了日(曜日)」という文字列を作っていきます。
ここで注意すべきことは、予定の終了日は 予定の翌日 になっていることです。
例えば、 1月1日の終日予定は、event.getEndTime() で「1/2 0:00」が返ってきます。
そのため、1日引いた日付を終了日として反映させるようにしています。
曜日については、getDay() で取得できますが、各曜日に対応した数値が取得されるので、以下の LocalizedDay というオブジェクトを作り、楽に曜日の文字列へ変換できるようにしています。
1const LocalizedDay = {
2 0: "日",
3 1: "月",
4 2: "火",
5 3: "水",
6 4: "木",
7 5: "金",
8 6: "土"
9}
10
11Object.freeze(LocalizedDay)
Object.freeze() は対象のオブジェクトを凍結するメソッドです。
これにより、このオブジェクトへのプロパティの追加や削除・変更ができなくなり、意図しないコードの変更を禁止することができます。
getDay() と曜日の対応は不変なので、 Object.freeze() で凍結してこの対応が意図せず崩れることを防いでいます。
あとは、1~3で作った文字列を、それぞれ Attachment の title / titleLink / text に設定し、それを予定の数分返して処理は終了となります。
Slack への通知
それでは、Slack への通知を行う仕組みを作っていきます。
まず、Slack への通知を行うための Incoming WebHook の設定をしましょう。
Slack Incoming WebHook の作成
こちらのページにアクセスし、 [Add to Slack] をクリックします。
[Choose a channel...] で通知先のチャンネルを選択し、[Add Incoming WebHooks Integration] をクリックして Incoming WebHook を作成します。
そして、下にある [Integration Settings] で、通知 Bot のアイコンや名前を設定を設定し [Save Settings] をクリックしたら、[Webhook URL] の内容をコピーします。
コピーした Webhook URL は通知のリクエスト先としてプロジェクトで利用します。
ですが、これもオープンにすべきでない秘匿情報に当たりますので、カレンダーID の保存と同じようにスクリプトプロパティへ保存します。
これで Incoming WebHook の設定は完了です!
それではプロジェクトに戻り、Slack 通知を行っているメソッドのコードを見ていきましょう。
1function postSlack_(message, attachments) {
2 // メッセージがなければ通知しない
3 if (!message) return
4
5 const payload = {
6 "text" : message,
7 "attachments" : attachments
8 }
9 const body = {
10 "method" : "POST",
11 "payload" : JSON.stringify(payload)
12 }
13
14 const webhookUrl = PropertiesService.getScriptProperties().getProperty("SLACK_WEBHOOK_URL")
15 const response = UrlFetchApp.fetch(webhookUrl, body)
16}
まず、通知メッセージと Attachment を、それぞれ payload に載せます。
そして、その payload を JSON 形式に変換して body に載せ、POST メソッドで 先程設定した Webhook URL に対してリクエストを行うことで、冒頭でお見せした Slack の通知を行うことができます。
これで、 Slack への通知部分の実装は完了です!
「平日の午前8時」に通知する仕組みの作成
あとは、これを定時実行させる仕組みが必要です。
今回の通知を行うのは「平日の午前8時」でしたね。
GAS には作ったアプリケーションを実行するタイミングを設定できるトリガーという機能があります。
しかし、このトリガーという機能、GUI 上ではあまりタイミングを柔軟に設定できるわけではなく...
このように、「平日」という指定、そして「◯◯時ぴったり」という指定をすることができません。
一番細かく設定できて「毎日◯◯時~◯◯時の間に実行する」ぐらいなんですね。
「じゃあ、実現できない...?」
と思われるかもしれませんが、GAS では、標準搭載の ScriptApp を利用することで、コード上でトリガーを設定することができます。
この「コード上でのトリガー設定」と「GUI 上でのトリガー設定」の2つを以下の様に組み合わせて利用することで、「平日の午前8時」のトリガーを実現します。
- 「実行日が平日であれば、午前8時に通知メソッドを実行するトリガーを設定する」メソッドを実装
- 1のメソッドを、「毎日午前5時~6時(午前8時よりも前であればOK)」の実行するように GUI 上でトリガーを設定
少し分かりづらいかもですが、
- 毎日午前5~6時に今日が平日かチェック
- 平日であれば、朝8時に当日の予定一覧を通知する
という処理をするようにしていくわけです。
1のメソッドは以下の様になります。
1function setTriggerOf8AM() {
2 let dateTime = new Date()
3 const day = dateTime.getDay()
4 // 平日のみトリガーを作成する
5 if (day == 0 || day == 6) return
6 dateTime.setHours(8)
7 dateTime.setMinutes(0)
8 ScriptApp.newTrigger('notifyTodayHolidayEvents').timeBased().at(dateTime).create()
9}
最終的にこの setTriggerOf8AM() メソッドを GUI 上で設定するトリガーから実行するように設定します。
GAS アプリケーションのデプロイ
これで、機能の実装は全て完了しましたので、アプリケーションとして実行できるようにするためにデプロイを行っていきましょう!
プロジェクト右上にある [デプロイ] から [新しいデプロイ] をクリックします。
すると、[新しいデプロイ] というウィンドウが開きますので、今回は設定は全てデフォルトのまま、右下の [デプロイ] をクリックします。
これでデプロイは完了です!
GUI 上でのトリガーの設定
それでは、いよいよ最後です!あと一息!
このアプリケーションを実際に動かすために、 setTriggerOf8AM() メソッドを実行するトリガーを GUI 上から設定していきましょう。
プロジェクト左の [トリガー] アイコンをクリックし、右下の [トリガーを追加] をクリックします。
すると [トリガーを追加] のウィンドウが開くので、各項目を以下の画像のように設定し、[保存] をクリックします。
以上で全ての行程完了です!お疲れ様でした!🎉
今回実装したコード全体
最終的に以下のようなコードになります。
見ながら実装してくださっていた方は、ここで足りていないものがないか確認してみてくださいね。
実際のコードは視認性やメンテナンス性を上げるためにファイルを分けていますが、今回は便宜上1ファイルに全て記載しています。
1function notifyTodayHolidayEvents() {
2 const calendarId = PropertiesService.getScriptProperties().getProperty("CALENDAR_ID")
3 const calendar = CalendarApp.getCalendarById(calendarId)
4
5 const date = new Date()
6 const events = calendar.getEventsForDay(date).filter(
7 (event) => event.isAllDayEvent() && !event.getTitle().includes("出張")
8 )
9
10 // 今日の予定数に応じて送信メッセージを設定
11 let message = ""
12 switch (events.length) {
13 case 0:
14 message = "There is no event today"
15 break
16 case 1:
17 message = "There is *1* event today"
18 break
19 default:
20 message = `There are *${events.length}* events today`
21 }
22
23 const attachments = makeAttachmentsFromCalendarEvents_(events, calendarId)
24 postSlack_(message, attachments)
25}
26
27function makeAttachmentsFromCalendarEvents_(events, calendarId) {
28 return events.map(function(event) {
29 // title: 予定タイトル
30 const title = event.getTitle()
31
32 // title_link: 予定へのリンクを作成
33 const splitEventId = event.getId().split('@')
34 const calendarIdForEid = calendarId.split('@')[0] + "@g"
35 const eid = Utilities.base64Encode(splitEventId[0] + " " + calendarIdForEid).replace("=","")
36 const titleLink = `https://calendar.google.com/calendar/event?eid=${eid}`
37
38 // text: 予定の日付(ex. 12/31(日) ~ 1/5(木))
39 const startTime = event.getStartTime()
40 const endTime = event.getEndTime()
41 endTime.setDate(endTime.getDate() - 1) // 終日だと予定終了時間が翌日0:00となり1日多くなってしまうため、1日引いて日付を戻す
42 let text = Utilities.formatDate(startTime, "GMT+0900", "M/d") + `(${LocalizedDay[startTime.getDay()]})`
43 if (startTime.getDate() != endTime.getDate()) {
44 text += Utilities.formatDate(endTime, "GMT+0900", "〜 M/d") + `(${LocalizedDay[endTime.getDay()]})`
45 }
46
47 return new Attachment(title, titleLink, text)
48 })
49}
50
51function postSlack_(message, attachments) {
52 // メッセージがなければ通知しない
53 if (!message) return
54
55 const payload = {
56 "text" : message,
57 "attachments" : attachments
58 }
59 const body = {
60 "method" : "POST",
61 "payload" : JSON.stringify(payload)
62 }
63
64 const webhookUrl = PropertiesService.getScriptProperties().getProperty("SLACK_WEBHOOK_URL")
65 const response = UrlFetchApp.fetch(webhookUrl, body)
66}
67
68function setTriggerOf8AM() {
69 let dateTime = new Date()
70 const day = dateTime.getDay()
71 // 平日のみトリガーを作成する
72 if (day == 0 || day == 6) return
73 dateTime.setHours(8)
74 dateTime.setMinutes(0)
75 ScriptApp.newTrigger('notifyTodayHolidayEvents').timeBased().at(dateTime).create()
76}
77
78class Attachment {
79 /*
80 * https://api.slack.com/reference/messaging/attachments
81 * @param {String} title 一番上に表示する文字列
82 * @param {String} titleLink titleに付与するリンク
83 * @param {String} text titleの下に表示する文字列
84 * @param {String} color Attachmentの縦線の色(#36a64eで固定)
85 */
86 constructor(title, titleLink, text) {
87 this.title = title
88 this.title_link = titleLink
89 this.text = text
90 this.color = "#36a64f"
91 }
92}
93
94const LocalizedDay = {
95 0: "日",
96 1: "月",
97 2: "火",
98 3: "水",
99 4: "木",
100 5: "金",
101 6: "土"
102}
103
104Object.freeze(LocalizedDay)
さいごに
いかがでしたでしょうか?
今回は GAS で自作した Google Calendar for Team Events for Slack の一部機能の実装過程を解説していきました。
一部とは言いながらも「この記事を見れば同じアプリケーションが作れるハンズオン的な記事」を目指したので、かなり長くなってしまいましたね(汗)
全体を眺めてみて興味を持っていただけたら、詳しく読んでいただきぜひご自分で実装してみてくださいね!
次回は実装したもうひとつの「即時通知編」の解説記事を書こうと思っていますので、興味のある方はお楽しみに!
余談ですが、今まで作った自動化の機構やスクリプトがプロジェクト事情で日の目を見ないことが続いた中、今回の通知システムは実運用されてホッとしています(笑)
前回の記事では「使われることじゃなく、作ったことに意味がある!」と言い聞かせてましたが、やっぱり実際運用に至ると達成感は段違いですね(笑)
今回の記事が、皆さんが自動化や GAS などに興味を持つきっかけになれば、これほど嬉しいことはありません。
それでは、最後までご覧いただきありがとうございました!
ライトコードでは、エンジニアを積極採用中!
ライトコードでは、エンジニアを積極採用しています!社長と一杯しながらお話しする機会もご用意しております。そのほかカジュアル面談等もございますので、くわしくは採用情報をご確認ください。
採用情報へ
「クレヨンしんちゃんは人生のマニュアル」が口癖なモバイルアプリとバックエンドやってる人
おすすめ記事
immichを知ってほしい
2024.10.31