毎月の最終営業日（平日）に通知するSlack bot

## モチベーション
人事の方が毎月最終営業日（平日）に月締めの連絡（勤怠や経費精算）を社内全員にSlackで通知していたので自動化したい

## 条件など
- 主に文字列だけ送信（複雑なことはない）
- 毎月最終営業日（平日）は月によって日数や曜日、日本の祝日があるので変化する


## 案出し
- [Slack Workflow](https://slack.com/intl/ja-jp/help/articles/16583775096083-%E8%87%AA%E5%8B%95%E5%8C%96---Slack-%E3%83%AF%E3%83%BC%E3%82%AF%E3%83%95%E3%83%AD%E3%83%BC%E3%81%A8%E3%81%AF)
    - > 毎月最終営業日（平日）は月によって日数や曜日、日本の祝日があるので変化する
    - 上記の制約により細かいスケジュール設定がSlack Workflowではできなかったのでボツ
- [cron-job](https://cron-job.org/en/)
    - 無料で使えるcron-jobを利用しクラウドに上げたリソースを叩く方針
    - スケジュールがSlack Workflowと同じような感じにしか設定できなかったためボツ
- AWS Lambda & EventBridge
    - 上記の案よりスケジュールの設定が詳細にでき、リソースも管理できるかつ料金はほぼかからないため、今回こちらを採用
    - デプロイするときに[Lambda Layers](https://docs.aws.amazon.com/ja_jp/lambda/latest/dg/chapter-layers.html)を使用して共通のパッケージをシェアできるようにすることやリソースサイズを下げることができるが今回は126MBほどだったことやシェアするパッケージが特にないので全てビジネスロジックにまとめてデプロイすることにします

## 実装（コード）
### 完成コード
https://github.com/soysan/notify_last_weekday

PythonとTypeScriptの検討をしましたが、今回はPythonで実装します。日本の祝日判定ができるライブラリ[jpholiday](https://github.com/Lalcs/jpholiday)の更新が[japanese-holidays](https://www.npmjs.com/package/japanese-holidays)や[holidays-jp](https://www.npmjs.com/package/@zefact/holidays-jp)より最近だったためです。どちらでも実装できそうです。

Slackに表示したいtextを環境変数に入れてLambdaの環境変数をいじるだけで通知内容を変更できるように設計しました。

#### main.py (file名と関数名は任意です。importは省略してます）

```python
def lambda_handler(event=None, context=None):
  load_dotenv(verbose=True)

  slack_token = os.getenv("SLACK_API_BOT_TOKEN")
  slack_channel = os.getenv("SLACK_CHANNEL")
  chat_text = os.getenv("CHAT_TEXT")

  sClient = SClient(slack_token, slack_channel)

  today = sClient.today().strftime('%d')
  # today = '31' # for test
  check_day = sClient.get_last_weekday()

  if today == check_day:
    sClient.post_message(chat_text)
  else:
    pprint.pprint(f'unmatch {today}')
```

#### slack_client.py（importは省略してます）

```python
class SClient:
  def __init__(self, token, channel):
    self.client = WebClient(token=token)
    self.channel = channel

  def post_message(self, text) -> Union[dict, None]:
    """
    Post a message to Slack

    Parameters
    ----------
    text : str
      The message to post
    """

    # Convert \\n to newline for lambda_handler()
    replaced_text = text.replace('\\n', '\n')

    try:
      response = self.client.chat_postMessage(
        channel=self.channel,
        text=replaced_text,
        mrkdwn=True,
        link_names=True,
      )

      return response
    except SlackApiError as e:
      print(f"Error posting message: {e.response['error']}")
      return None

  def get_last_weekday(self) -> str:
    """
    Get the last weekday of the month

    Returns
    -------
    day : str
      The last weekday of the month, e.g., '31'
    """

    today = self.today()

    next_month = today.replace(day=28) + timedelta(days=4)
    last_day = next_month - timedelta(days=next_month.day)

    while last_day.weekday() >= 5 or jpholiday.is_holiday(last_day):
      last_day -= timedelta(days=1)

    day = last_day.strftime('%d')

    return day

  def today(self) -> datetime:
    """
    Get today's date and time in JST

    Returns
    -------
    today : datetime
      Today's date and time
    """
    JST = timezone(timedelta(hours=+9))
    today = datetime.now(JST)
    return today
```
#### 特記
- 毎月の最終営業日の判定はシンプル
    - 閏年の影響がある2月を起点に考慮し毎月26日以降（土日を避けたい）に関数を実行する
    - 関数実行日を28日に変換し、4日足して強制的に次の月のどこかの日にする
    - 次の月からその日数分を引いて特定したい月の最終日を割り出す
    - 月の最終日が土曜、日曜、祝日だった場合1日戻る（平日になるまで行う）
    - （p.s　AIに聞いてもほぼ同じコードが出てきた。。。） 
- TimezoneをJSTにする
    - AWS Lambdaの実行環境はUTCになっているので直す必要がある
- 環境変数の任意の文字列の改行`\n`を`\\n`にする（改行がある場合）
    - うまく変換されず改行されない文がSlackに送られてしまう

## Slack Appのセッティング
[Slack Api](https://api.slack.com/apps)でCreate New APPを押してbotを作成する。
[こちらの記事](https://qiita.com/odm_knpr0122/items/04c342ec8d9fe85e0fe9)を参考に作成しました。ありがとうございます。

必要なもの
- OAuth TokensのBot User OAuth Token
- OAuth & PermissonのScopesでBot Token Scopeに`chat:write`を付与する

#### Slack Channelに作成したアプリ（bot）を追加
1. botアプリを追加したいSlackチャンネルを開いて右上の`その他`を押す。（点が3つあるやつ）
1. チャンネル詳細を開くを押す
1. インテグレーションを押す
1. APPのアプリを追加するから先ほど作成したbotアプリを追加する

## zip fileの作成
AWS Lambdaにコードやパッケージをデプロイするときにzip fileにする必要があるのでVertual Environmentを作成し、そこにパッケージをinstallしてzip fileを作る。

```bash
$ python -m venv venv
$ pip install -r requirements.txt
```
#### make_zip.sh

```bash
#!/bin/bash

# Define the name of the zip file
ZIP_FILE="lambda_function.zip"

# Remove the existing zip file if it exists
if [ -f "$ZIP_FILE" ]; then
  rm "$ZIP_FILE"
fi

# Create a new zip file with the contents of the current directory
mkdir -p build

cp -r venv/lib/python3.13/site-packages/* build/
cp *.py build/
cp requirements.txt build/

cd build || exit
zip -r "../$ZIP_FILE" .
cd ..

echo "Created $ZIP_FILE for AWS Lambda function."
```
lambda_function.zipというfileが作成されます。

## AWS Lambda & EventBrigeの設定
AWS accountやIAMの設定などはここでは記述しません。

#### [AWS Lambda関数の作成](https://docs.aws.amazon.com/ja_jp/lambda/latest/dg/lambda-python.html)
1. 関数を作成を押す
1. 一から作成
    1. 関数名を任意で決める
    1. ランタイムはPython3.13を指定
    1. アーキテクチャはx86_64
1. 関数の作成を押す


#### [EventBrigeの設定](https://docs.aws.amazon.com/ja_jp/eventbridge/latest/userguide/eb-create-rule-schedule.html)
1. 上記で作成したLambda関数のダッシュボードに遷移
1. トリガーを追加を押す
1. トリガーをEventBrigeを指定する
1. ルール
    1. 新規作成
    1. ルール名と説明をわかりやすく任意で書いておく
1. ルールタイプ
    1. スケジュール式を選択
    1. [公式を参考](https://docs.aws.amazon.com/ja_jp/eventbridge/latest/userguide/eb-scheduled-rule-pattern.html#eb-cron-expressions)にcron式を設定
    1. `cron(0 6 26-31 * ? *)` カッコ内左から（分, 時間, 日, 月, 曜日, 年）
    `*`はワイルドカードで全て、`?`は特に指定なしの意味
    つまり、`全ての年の毎月（全ての月）26~31日の{6時(UTC) == 15時(JST)}に曜日指定なし`に実行の意味
    ※ cron式に矛盾や書式ミスがあった場合、警告が出ます
    1. 追加を押す

#### [コードデプロイ](https://docs.aws.amazon.com/ja_jp/lambda/latest/dg/python-package.html#python-package-create-update)
1. AWS Lambdaで作成した関数のダッシュボードに遷移
1. コードタブを押す（デフォルトで開いている）
1. コードソース内の deploy を押して先ほど作成した lambda_function.zip をuploadする（しばらく待つとデプロイ成功など通知が画面に出る）
1. 画面下にスクロールしてランタイム設定の編集を押す
    1. handerを`file名.関数名`に設定する。今回の場合`main.lambda_handler`

#### [環境変数を設定](https://docs.aws.amazon.com/ja_jp/lambda/latest/dg/configuration-envvars.html)
1. AWS Lambdaで作成した関数のダッシュボードに遷移
1. 設定を押す
1. 左ペインから環境変数を押す
    1. `CHAT_TEXT`: Slackに表示させたい任意の文字
    1. `SLACK_API_BOT_TOKEN`: 先ほど取得したSLACK_API_BOT_TOKEN
    1. `SLACK_CHANNEL`: 文字列を表示させたいSlackチャンネルのidを指定する
        1. Slackを開いて表示させたいチャンネルを選択
        1. 右上の`その他`を押す
        1. チャンネル詳細を押す
        1. Window の一番下に表示されているものが channel_id

## 完成
![bot.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/643403/ee12f6b5-ad81-40ad-a4fe-f0321e7d9762.png)

テストはlambda_handler内の下記を修正し、コードソース内のテストまたはテストタプを実行すると実行できる。
うまくいけば指定チャンネルに`CHAT_TEXT`で指定した文字列が投稿されます。

```python
# today = sClient.today().strftime('%d')
today = '31' # for test　← ここを実行月の最終営業日に設定
```

APPの画像はAIで作りました

[calendar](https://docs.python.org/ja/3.13/library/calendar.html)を使用した方が綺麗に書けたかも。