LINE Messaging API v3 - Flex Message with Python (Flask) [備忘録]

### Flex Message の手作りはめんどい

Line の Messaging API と Python でボット開発を何回かしてきましたが、Flex Message を使おうと思ったらまずは [Flex Message Simulator](https://developers.line.biz/flex-simulator/) でゴチャゴチャいじくり回した上でそのJSONをマイテンプレートとして保存し、そこに実際のデータをぶち込んでいくという方式でやっている方も多いかと思います。

ただ、ネストが深くなるとコーディングが非常に面倒になり、KeyError や IndexError が頻発します。その度にテンプレートを確認しながら「keyがxxx...`contents`の2番目の`type`...」などのようにお経を唱える日々です。

### v3 ... ??

今までLINEボットを作る時のコードは基本過去の物の使い回しだったのですが、ふと公式の line-bot-sdk の [GitHub](https://github.com/line/line-bot-sdk-python/tree/master) を覗いてみると、なんと v3 なるものがあるということに気づきました（今更ですが）。なるほどなるほど、`linebot.v3.webhooks` と `linebot.v3.messaging` に分けた上で、全てのタイプのメッセージを `ReplyMessageRequest(message=[...])` として送るということか。確かに今までは `linebot.models` の中に受信側も送信側もまとめて入っていて分かりにくかったし、`TextSendMessage` や `FlexSendMessage` のように異なる関数だったから、それを整理したということなのでしょう。

しかも、これにはどうやら `FlexSeparator` や `FlexBox` といったクラスが用意されているらしい。ということは、JSON（dict）を直接いじることなく、オブジェクトの積み重ねだけで Flex Message が作れるのではないか？と色々試行錯誤してみました。その結果をここに記録しておきます。

ちなみになぜ「試行錯誤」しなければならなかったかというと、

- 公式の Python SDK v3 の [documentation](https://github.com/line/line-bot-sdk-python/tree/master) には Flex Message の使い方の詳細がほとんど書かれていない
- Line は日本、タイ、台湾などでしか使われていないガラパゴスツールなので、Stack Overflow に情報がない
- 最近はGASで作る人が多く、Python は少数派(?)のためQiitaでも情報が少ない

などの理由です。なので日本語で情報発信してどなたかのお役に立つことを願います。

---

Flex Message 関連のクラスは、`linebot.v3.messaging` 下にまとまっています。今回はこれらを使ってカレンダーを作ろうと思います。デザインはこちらからほとんど拝借いたしました。本当にありがとうございます。

https://qiita.com/7kaji/items/69c790efc9ed8813fe73

~~~python
from linebot.v3.messaging import (
    FlexBubble,
    FlexSeparator,
    FlexBox,
    FlexText
)
~~~

とりあえず今回使うのはこれくらいです。要領さえ分かれば、他のものも同様に使えるようになるはずです。作るものはタイの祝日入りのカレンダーで、`jpholiday` のように祝日かどうかを自動判定してくれる便利なライブラリは存在しないので、あらかじめこんな感じのデータを作っておく必要がありました。ちなみにタイは土曜が祝日だと月曜が振替休日になります、日本も見習うべきですね。

~~~thai_holiday.csv
year,month,day,name
2024,1,1,วันปีใหม่
2024,2,24,วันมาฆบูชา
2024,2,26,วันหยุดชดเชยวันมาฆบูชา
2024,4,6,วันจักรี
2024,4,8,วันหยุดชดเชยวันจักรี
2024,4,13,วันสงกรานต์
2024,4,14,วันสงกรานต์
2024,4,15,วันสงกรานต์
2024,4,16,วันหยุดชดเชยวันสงกรานต์
...
~~~

作り方としては、

1. 子要素から作り、それを親要素のコンストラクタに渡すボトムアップ式
2. 親要素から作り、子要素を順次追加していくトップダウン式

の2通りが考えられますが、今回は 2. のトップダウン式を採用しました。

まずこんな感じでバブルタイプのコンテナを用意します。

~~~python
container = FlexBubble()
container
~~~

notebook で実行してみると、

~~~
FlexBubble(type='bubble', direction=None, styles=None, header=None, hero=None, body=None, footer=None, size=None, action=None)
~~~

こんな感じで全てが `None` になっているのがわかります。ここに子要素を少しずつ入れていきます。今回は `header`は無しで、全て `body` に `vertival` で積み重ねていきます。 `contents` は必須要素なので、指定しないと ` ValidationError : contents field required` というものが出て怒られます。とりあえずは空リストにしておきます。

~~~python
container.body = FlexBox(layout='vertical', contents=[])
~~~

ちなみにこれは、辞書を使って

~~~python
container.body = {'type':'box', 'layout':'vertical', 'contents':[]}
~~~

と、本来のJSON同様に指定することも可能です。今回はせっかくなので `FlexBox` クラスを使いましょう。

次に、「タイトル部分のテキスト要素」を追加します。普通のリスト同様に append すればOKです。ここではとりあえず 2024年2月とし、変数 `YEAR` と `MONTH` に格納しておきます。

~~~python
## 2024-2 という緑太字のタイトルを作成
YEAR, MONTH = 2024, 2
title = FlexText(text=f'{YEAR}-{MONTH}', weight='bold', color='#1db446', size='lg')

## タイトル追加
container.body.contents.append(title)
~~~

ここからカレンダー本体を作ります。vertical なのでタイトルの真下にそのまま一行ずつ追加してもいいのですが、マージンを取った方が見栄えが良くなるので、さらにBOX要素を追加してその中に格納していくことにします。また、`spacing` を設定することで、その中に入る各週に隙間ができます。

~~~python
## カレンダー用の空box作成
calendar_box = FlexBox(layout='vertical', margin='md', spacing='md', contents=[])

## タイトルの下に追加
container.body.contents.append(calendar_box)
~~~

このように要素をインスタンス化して命名しておくことで、ルートから一々辿らなくても親ノードを直接指定してそこに追加できる利便性が生まれます。HTMLでいうDOMみたいなものですね。

曜日のヘッダーを作成します。これは水平配置なので `horizontal` を指定します。仕切り線も入れていきます。

~~~python
## ヘッダー用水平BOX作成
header = FlexBox(layout='horizontal', contents=[])

## 各曜日追加
youbi = '日月火水木金土'
color = ['#ff0000'] + ['#000000']*5 + ['#0000FF'] # 日曜は赤、土曜は青
for i, (y, c) in enumerate(zip(youbi, color)):
    text_element = FlexText(text=y, size='lg', color=c, align='center') # 各曜日
    header.contents.append(text_element)
    if i != 6:
        header.contents.append(FlexSeparator()) # 土曜日以外は右に仕切り線追加

## BOXに横仕切り線とヘッダーを追加
calendar_box.contents.append(FlexSeparator())
calendar_box.contents.append(header)
~~~

とりあえずここまでで仕上がりがどんなものか見てみましょう。これらのクラスには全て `from_json()`, `to_json()` というメソッドがあり、今回の場合は `to_json()` を使うことでJSONとして出力し、[Flex Message Simulator](https://developers.line.biz/flex-simulator/) で途中経過が確認できます。なんて便利。

~~~python
container.to_json()
~~~

~~~json
{"type": "bubble", "body": {"type": "box", "layout": "vertical", "contents": [{"type": "text", "text": "2024-2", "size": "lg", "color": "#1db446", "weight": "bold"}, {"type": "box", "layout": "vertical", "contents": [{"type": "separator"}, {"type": "box", "layout": "horizontal", "contents": [{"type": "text", "text": "日", "size": "lg", "align": "center", "color": "#ff0000"}, {"type": "separator"}, {"type": "text", "text": "月", "size": "lg", "align": "center", "color": "#000000"}, {"type": "separator"}, {"type": "text", "text": "火", "size": "lg", "align": "center", "color": "#000000"}, {"type": "separator"}, {"type": "text", "text": "水", "size": "lg", "align": "center", "color": "#000000"}, {"type": "separator"}, {"type": "text", "text": "木", "size": "lg", "align": "center", "color": "#000000"}, {"type": "separator"}, {"type": "text", "text": "金", "size": "lg", "align": "center", "color": "#000000"}, {"type": "separator"}, {"type": "text", "text": "土", "size": "lg", "align": "center", "color": "#0000FF"}]}], "spacing": "md", "margin": "md"}]}}
~~~

~~ただ、今回の場合は日本語が入っており、そのままだと `\uxxxx` のようにユニコードエンコーディングされてしまいます。実際にAPI経由ででメッセージ送信する時には問題ないのですが、Flex Message Simulator 上だと \uxxx のように表示されてしまうんですよね。それが嫌な場合は、ソースコードを直接書き換えてしまいましょう（私はやりました）。~~

~~`.../linebot/v3/messaging/models/flex_bubble.py` の中にある `to_json()` メソッドに、`ensure_ascii=False` を追加すれば良いです。以下の部分です。~~

~~~python:flex_bubble.py
    def to_json(self) -> str:
        """Returns the JSON representation of the model using alias"""
        return json.dumps(self.to_dict(), ensure_ascii=False)
~~~

**[2024/1/8 追記] エスケープ文字も全く問題ありませんでした。`to_json()` でJSON文字列化したものを `print()` してからコピーすればいいだけです。ソースコードの書き換えも不要です。詳しくは[こちら](https://qiita.com/nozomiyamada/items/1be9ae292b01fb35273e)** 

![Screenshot 2023-12-30 at 06.04.41.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/727634/b96eae9c-c9c3-b848-8f80-aed4ccb9d9d3.png)

このようなプレビューになるはずです。上出来。

あとは日付を入れていきます。幸いにして Python には `calendar.monthcalendar()` という素晴らしいツールがあり、これで週ごとのリストとして取得できます。その月でない部分は0になります。ただし、デフォルトでは月曜始まりなので、日曜始まりにするには以下のように設定しておく必要があります。

~~~python
import calendar
calendar.setfirstweekday(6)
~~~

このようなコードを書いていきます、基本は先ほどのヘッダー作成と同じです。先ほどの休日一覧のファイルを用いて、指定した `YEAR` と `MONTH` に加え、日付も一致するレコードがあれば、その日は赤色にします。  

~~~python
import pandas as pd

## 祝日一覧取得
holiday_df = pd.read_csv('thai_holiday.csv')

## 各週の日付のリスト
weeks = calendar.monthcalendar(YEAR, MONTH)

for week in weeks:
    ## 各週の水平BOX作成
    week_box = FlexBox(layout='horizontal', contents=[])

    for i, day in enumerate(week):
        ## 色を決定
        if len(holiday_df.query(f'year=={YEAR} and month=={MONTH} and day=={day}')) > 0:
            color = '#FF0000'  ## レコードが存在する場合、祝日なので赤
        elif i == 0:
            color = '#FF0000'  ## 日曜日は赤
        elif i == 6:
            color = '#0000FF'  ## 土曜日は青
        else:
            color = '#000000'  ## それ以外は黒

        if day == 0:  ## その月の日付ではない場合、空白挿入
            week_box.contents.append(FlexText(text=" ", size='lg', color=color, align='center'))
        else:
            week_box.contents.append(FlexText(text=str(day), size='lg', color=color, align='center'))
        if i != 6:
            week_box.contents.append(FlexSeparator()) # 土曜日以外は右に仕切り線も追加

    ## BOXに横仕切り線と週を追加
    calendar_box.contents.append(FlexSeparator())
    calendar_box.contents.append(week_box)
~~~

これで完成です。また `container.to_json()` して Flex Message Simulator 上で確認してみると、きちんと生成できていることがわかります。

![Screenshot 2023-12-30 at 06.10.52.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/727634/63bd9899-0390-6a00-4793-520c1316c54d.png)

あとはこの完成した `container` を、v3 なら以下のように `FlexMessage()` でくるんで、APIに渡せば良いです。

~~~python
## linebot の処理諸々
## year と month を受け取る処理

## FlexBubble を作る
container = create_flex_calendar(year, month)

## Message Object にする
message = FlexMessage(alt_text=<お好きに>, contents=container)

## 返信
line_bot_api.reply_message(
    ReplyMessageRequest(
        replyToken=event.reply_token,
		messages=[message]
    )
)
~~~


ただ、v3 でない今までのコードを流用したいということでしたら、`FlexSendMessage()` は辞書型として受け取れるので、以下のようにすることでも可能です。

~~~python
container = create_flex_calendar(year, month)

line_bot_api.reply_message(
    event.reply_token,
    FlexSendMessage(alt_text=<お好きに>, contents=container.to_dict())
)
~~~

私もこれで動作確認済みです。このように作成する部分だけ v3 にし、結果は辞書型に変換してしまう方が、流用前提ならば楽だと思います。

## 全コード

祝日データを読み込んで処理する部分は、`jpholiday` を使うなり、削除するなり、適宜修正して使ってみてください。

~~~python
import calendar
calendar.setfirstweekday(6)
import pandas as pd

from linebot.v3.messaging import (
    FlexBubble,
    FlexSeparator,
    FlexBox,
    FlexText
)

def create_flex_calendar(year, month) -> FlexBubble:

    ## コンテナ作成
    container = FlexBubble()

    ## BODY作成
    container.body = FlexBox(layout='vertical', contents=[])

    ## year-month という緑太字のタイトルを作成
    title = FlexText(text=f'{year}-{month}', weight='bold', color='#1db446', size='lg')

    ## タイトル追加
    container.body.contents.append(title)

    ## カレンダー用の空box作成
    calendar_box = FlexBox(layout='vertical', margin='md', spacing='md', contents=[])

    ## タイトルの下に追加
    container.body.contents.append(calendar_box)

    ## ヘッダー用水平BOX作成
    header = FlexBox(layout='horizontal', contents=[])

    ## 各曜日追加
    youbi = '日月火水木金土'
    color = ['#ff0000'] + ['#000000']*5 + ['#0000FF'] # 日曜は赤、土曜は青
    for i, (y, c) in enumerate(zip(youbi, color)):
        text_element = FlexText(text=y, size='lg', color=c, align='center') # 各曜日
        header.contents.append(text_element)
        if i != 6:
            header.contents.append(FlexSeparator()) # 土曜日以外は右に仕切り線追加

    ## BOXに横仕切り線とヘッダーを追加
    calendar_box.contents.append(FlexSeparator())
    calendar_box.contents.append(header)

    ## 祝日一覧取得
    holiday_df = pd.read_csv('thai_holiday.csv')

    ## 各週の日付のリスト
    weeks = calendar.monthcalendar(year, month)

    for week in weeks:
        ## 各週の水平BOX作成
        week_box = FlexBox(layout='horizontal', contents=[])

        for i, day in enumerate(week):
            ## 色を決定
            if len(holiday_df.query(f'year=={year} and month=={month} and day=={day}')) > 0:
                color = '#FF0000'  ## レコードが存在する場合、祝日なので赤
            elif i == 0:
                color = '#FF0000'  ## 日曜日は赤
            elif i == 6:
                color = '#0000FF'  ## 土曜日は青
            else:
                color = '#000000'  ## それ以外は黒

            if day == 0:  ## その月の日付ではない場合、空白挿入
                week_box.contents.append(FlexText(text=" ", size='lg', color=color, align='center'))
            else:
                week_box.contents.append(FlexText(text=str(day), size='lg', color=color, align='center'))
            if i != 6:
                week_box.contents.append(FlexSeparator()) # 土曜日以外は右に仕切り線も追加

        ## BOXに横仕切り線と週を追加
        calendar_box.contents.append(FlexSeparator())
        calendar_box.contents.append(week_box)

    return container

~~~