目標:把你在專案裡反覆複製的 FlexMessage,整理成可重用、可維護的 UI 套件。
一套「宣告式」Schema → 自動產出 主選單 / 子選單 / 快速回覆,再也不怕改文案、加功能。
⸻
⸻
app/
├─ app_fastapi.py # 事件入口(呼叫 menu 模組)
├─ menu/
│ ├─ schema.py # 宣告式菜單定義
│ ├─ builder.py # Flex/QuickReply 建置工具
│ └─ handlers.py # Postback 路由(menu:*)
也可放在既有專案中,路徑自訂即可。
⸻
from typing import Dict, List, TypedDict, Literal
Style = Literal["primary", "secondary", "link"]
class MenuButton(TypedDict, total=False):
label: str
text: str # MessageAction 用:使用者看到的輸入
data: str # PostbackAction 用:menu:xxx 或你自訂
style: Style # LINE 的按鈕 style
color: str # 自訂十六進位色(可省略)
class MenuSpec(TypedDict):
title: str
rows: List[List[MenuButton]] # 2 欄或 3 欄一列,自己安排
MENU_SCHEMA: Dict[str, MenuSpec] = {
"main": {
"title": "AI 助理主選單",
"rows": [
[
{"label": "💹 金融查詢", "data": "menu:finance", "style": "primary"},
{"label": "🎰 彩票分析", "data": "menu:lottery", "style": "primary"}
],
[
{"label": "💖 AI 角色扮演", "data": "menu:persona", "style": "secondary"},
{"label": "🌐 翻譯工具", "data": "menu:translate", "style": "secondary"}
],
]
},
"finance": {
"title": "💹 金融查詢",
"rows": [
[
{"label": "台股大盤", "text": "台股大盤", "style": "primary"},
{"label": "美股大盤", "text": "美股大盤", "style": "primary"},
],
[
{"label": "黃金價格", "text": "金價", "style": "secondary"},
{"label": "日圓匯率", "text": "JPY", "style": "secondary"},
],
[
{"label": "查 2330 台積電", "text": "2330", "style": "link"},
{"label": "查 NVDA 輝達", "text": "NVDA", "style": "link"},
],
]
},
"lottery": {
"title": "🎰 彩票分析",
"rows": [
[
{"label": "大樂透", "text": "大樂透", "style": "primary"},
{"label": "威力彩", "text": "威力彩", "style": "primary"},
],
[
{"label": "今彩539", "text": "539", "style": "secondary"},
]
]
},
"persona": {
"title": "💖 AI 角色扮演",
"rows": [
[
{"label": "甜美女友", "text": "甜", "style": "primary"},
{"label": "傲嬌女友", "text": "鹹", "style": "primary"},
],
[
{"label": "萌系女友", "text": "萌", "style": "secondary"},
{"label": "酷系御姐", "text": "酷", "style": "secondary"},
],
[
{"label": "隨機切換", "text": "random", "style": "link"},
]
]
},
"translate": {
"title": "🌐 翻譯工具",
"rows": [
[
{"label": "翻成英文", "text": "翻譯->英文", "style": "primary"},
{"label": "翻成日文", "text": "翻譯->日文", "style": "primary"},
],
[
{"label": "翻成繁中", "text": "翻譯->繁體中文", "style": "secondary"},
{"label": "結束翻譯模式", "text": "翻譯->結束", "style": "secondary"},
]
]
}
}
⸻
from typing import List
from linebot.v3.messaging import (
FlexMessage, FlexBubble, FlexBox, FlexText, FlexButton,
MessageAction, PostbackAction, QuickReply, QuickReplyItem
)
from .schema import MENU_SCHEMA, MenuSpec, MenuButton
PRIMARY_COLOR = "#5E86C1"
SECONDARY_COLOR = "#5EC186"
LINK_COLOR = "#6B7280" # 中性灰
def _btn_color(btn: MenuButton) -> str:
if "color" in btn: return btn["color"]
return {"primary": PRIMARY_COLOR, "secondary": SECONDARY_COLOR, "link": LINK_COLOR}.get(btn.get("style","secondary"), LINK_COLOR)
def _mk_button(btn: MenuButton) -> FlexButton:
style = btn.get("style", "secondary")
color = _btn_color(btn)
if "data" in btn:
action = PostbackAction(label=btn["label"], data=btn["data"])
else:
action = MessageAction(label=btn["label"], text=btn.get("text",""))
return FlexButton(action=action, style=style, color=color)
def build_menu_flex(kind: str) -> FlexMessage:
spec: MenuSpec = MENU_SCHEMA.get(kind) or {"title": "未定義選單", "rows": []} # 防呆
rows_boxes: List[FlexBox] = []
for row in spec["rows"]:
buttons = [_mk_button(b) for b in row]
rows_boxes.append(FlexBox(layout="horizontal", spacing="sm", contents=buttons))
bubble = FlexBubble(
header=FlexBox(layout="vertical", contents=[FlexText(text=spec["title"], weight="bold", size="lg")]),
body=FlexBox(layout="vertical", spacing="md", contents=rows_boxes or [FlexText(text="(尚無項目)")]),
)
return FlexMessage(alt_text=spec["title"], contents=bubble)
def build_quick_reply_default() -> QuickReply:
# 這組在各處共用;要改只改這裡
items = [
QuickReplyItem(action=MessageAction(label="主選單", text="選單")),
QuickReplyItem(action=MessageAction(label="台股大盤", text="台股大盤")),
QuickReplyItem(action=MessageAction(label="美股大盤", text="美股大盤")),
QuickReplyItem(action=MessageAction(label="黃金價格", text="金價")),
QuickReplyItem(action=MessageAction(label="查台積電", text="2330")),
QuickReplyItem(action=MessageAction(label="查輝達", text="NVDA")),
QuickReplyItem(action=MessageAction(label="查日圓", text="JPY")),
QuickReplyItem(action=PostbackAction(label="💖 AI 人設", data="menu:persona")),
QuickReplyItem(action=PostbackAction(label="🎰 彩票選單", data="menu:lottery")),
]
return QuickReply(items=items)
⸻
from linebot.v3.messaging import ReplyMessageRequest
from .builder import build_menu_flex
async def handle_menu_postback(kind: str, line_bot_api, reply_token: str):
msg = build_menu_flex(kind)
await line_bot_api.reply_message(ReplyMessageRequest(reply_token=reply_token, messages=[msg]))
⸻
只需要在兩個地方動手:
1. 回主選單
from menu.builder import build_menu_flex, build_quick_reply_default
if low in ("menu", "選單", "主選單"):
await line_bot_api.reply_message(
ReplyMessageRequest(reply_token=reply_token, messages=[build_menu_flex("main")])
)
return
2. 處理 Postback
from menu.handlers import handle_menu_postback
@handler.add(PostbackEvent)
async def handle_postback(event: PostbackEvent):
data = (event.postback.data or "").strip()
if data.startswith("menu:"):
kind = data.split(":", 1)[-1]
await handle_menu_postback(kind, line_bot_api, event.reply_token)
其他功能(金融、彩票、翻譯、人設)原本怎麼寫就怎麼寫,UI 都由 Builder 處理。
⸻
⸻
⸻
⸻
你不需要重畫 JSON,Schema 一改就生效。
⸻
結語
把 UI 結構抽象成 Schema + Builder,ai her 開發速度會飛快成長:
加一個功能 = 加一顆按鈕,不再有到處複製 Flex JSON 的痛苦。