Flex Message の手作りはめんどい
Line の Messaging API と Python でボット開発を何回かしてきましたが、Flex Message を使おうと思ったらまずは Flex Message Simulator でゴチャゴチャいじくり回した上でそのJSONをマイテンプレートとして保存し、そこに実際のデータをぶち込んでいくという方式でやっている方も多いかと思います。
ただ、ネストが深くなるとコーディングが非常に面倒になり、KeyError や IndexError が頻発します。その度にテンプレートを確認しながら「keyがxxx...contents
の2番目のtype
...」などのようにお経を唱える日々です。
v3 ... ??
今までLINEボットを作る時のコードは基本過去の物の使い回しだったのですが、ふと公式の line-bot-sdk の GitHub を覗いてみると、なんと v3 なるものがあるということに気づきました(今更ですが)。なるほどなるほど、linebot.v3.webhooks
と linebot.v3.messaging
に分けた上で、全てのタイプのメッセージを ReplyMessageRequest(message=[...])
として送るということか。確かに今までは linebot.models
の中に受信側も送信側もまとめて入っていて分かりにくかったし、TextSendMessage
や FlexSendMessage
のように異なる関数だったから、それを整理したということなのでしょう。
しかも、これにはどうやら FlexSeparator
や FlexBox
といったクラスが用意されているらしい。ということは、JSON(dict)を直接いじることなく、オブジェクトの積み重ねだけで Flex Message が作れるのではないか?と色々試行錯誤してみました。その結果をここに記録しておきます。
ちなみになぜ「試行錯誤」しなければならなかったかというと、
- 公式の Python SDK v3 の documentation には Flex Message の使い方の詳細がほとんど書かれていない
- Line は日本、タイ、台湾などでしか使われていないガラパゴスツールなので、Stack Overflow に情報がない
- 最近はGASで作る人が多く、Python は少数派(?)のためQiitaでも情報が少ない
などの理由です。なので日本語で情報発信してどなたかのお役に立つことを願います。
Flex Message 関連のクラスは、linebot.v3.messaging
下にまとまっています。今回はこれらを使ってカレンダーを作ろうと思います。デザインはこちらからほとんど拝借いたしました。本当にありがとうございます。
from linebot.v3.messaging import (
FlexBubble,
FlexSeparator,
FlexBox,
FlexText
)
とりあえず今回使うのはこれくらいです。要領さえ分かれば、他のものも同様に使えるようになるはずです。作るものはタイの祝日入りのカレンダーで、jpholiday
のように祝日かどうかを自動判定してくれる便利なライブラリは存在しないので、あらかじめこんな感じのデータを作っておく必要がありました。ちなみにタイは土曜が祝日だと月曜が振替休日になります、日本も見習うべきですね。
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,วันหยุดชดเชยวันสงกรานต์
...
作り方としては、
- 子要素から作り、それを親要素のコンストラクタに渡すボトムアップ式
- 親要素から作り、子要素を順次追加していくトップダウン式
の2通りが考えられますが、今回は 2. のトップダウン式を採用しました。
まずこんな感じでバブルタイプのコンテナを用意します。
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
というものが出て怒られます。とりあえずは空リストにしておきます。
container.body = FlexBox(layout='vertical', contents=[])
ちなみにこれは、辞書を使って
container.body = {'type':'box', 'layout':'vertical', 'contents':[]}
と、本来のJSON同様に指定することも可能です。今回はせっかくなので FlexBox
クラスを使いましょう。
次に、「タイトル部分のテキスト要素」を追加します。普通のリスト同様に append すればOKです。ここではとりあえず 2024年2月とし、変数 YEAR
と MONTH
に格納しておきます。
## 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
を設定することで、その中に入る各週に隙間ができます。
## カレンダー用の空box作成
calendar_box = FlexBox(layout='vertical', margin='md', spacing='md', contents=[])
## タイトルの下に追加
container.body.contents.append(calendar_box)
このように要素をインスタンス化して命名しておくことで、ルートから一々辿らなくても親ノードを直接指定してそこに追加できる利便性が生まれます。HTMLでいうDOMみたいなものですね。
曜日のヘッダーを作成します。これは水平配置なので horizontal
を指定します。仕切り線も入れていきます。
## ヘッダー用水平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 で途中経過が確認できます。なんて便利。
container.to_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
を追加すれば良いです。以下の部分です。
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()
してからコピーすればいいだけです。ソースコードの書き換えも不要です。詳しくはこちら
このようなプレビューになるはずです。上出来。
あとは日付を入れていきます。幸いにして Python には calendar.monthcalendar()
という素晴らしいツールがあり、これで週ごとのリストとして取得できます。その月でない部分は0になります。ただし、デフォルトでは月曜始まりなので、日曜始まりにするには以下のように設定しておく必要があります。
import calendar
calendar.setfirstweekday(6)
このようなコードを書いていきます、基本は先ほどのヘッダー作成と同じです。先ほどの休日一覧のファイルを用いて、指定した YEAR
と MONTH
に加え、日付も一致するレコードがあれば、その日は赤色にします。
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 上で確認してみると、きちんと生成できていることがわかります。
あとはこの完成した container
を、v3 なら以下のように FlexMessage()
でくるんで、APIに渡せば良いです。
## 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()
は辞書型として受け取れるので、以下のようにすることでも可能です。
container = create_flex_calendar(year, month)
line_bot_api.reply_message(
event.reply_token,
FlexSendMessage(alt_text=<お好きに>, contents=container.to_dict())
)
私もこれで動作確認済みです。このように作成する部分だけ v3 にし、結果は辞書型に変換してしまう方が、流用前提ならば楽だと思います。
全コード
祝日データを読み込んで処理する部分は、jpholiday
を使うなり、削除するなり、適宜修正して使ってみてください。
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