288 lines
9.4 KiB
Python
288 lines
9.4 KiB
Python
|
|
from __future__ import annotations
|
|||
|
|
|
|||
|
|
import shlex
|
|||
|
|
|
|||
|
|
from yuxi.channel.extensions.line.actions import create_message_action, create_uri_action, create_postback_action
|
|||
|
|
from yuxi.channel.extensions.line.template_messages import (
|
|||
|
|
create_buttons_template,
|
|||
|
|
create_confirm_template,
|
|||
|
|
create_image_carousel_template,
|
|||
|
|
create_image_carousel_column,
|
|||
|
|
create_yes_no_confirm,
|
|||
|
|
create_button_menu,
|
|||
|
|
create_link_menu,
|
|||
|
|
create_product_carousel,
|
|||
|
|
to_template_message,
|
|||
|
|
)
|
|||
|
|
from yuxi.channel.extensions.line.flex_templates import (
|
|||
|
|
create_receipt_card,
|
|||
|
|
create_event_card,
|
|||
|
|
create_agenda_card,
|
|||
|
|
create_media_player_card,
|
|||
|
|
create_device_control_card,
|
|||
|
|
create_apple_tv_remote,
|
|||
|
|
)
|
|||
|
|
|
|||
|
|
|
|||
|
|
LINE_CARD_COMMANDS: dict[str, dict] = {
|
|||
|
|
"info": {
|
|||
|
|
"syntax": '/card info "Title" "Body" ["Footer"]',
|
|||
|
|
"description": "信息卡片(标题+正文+可选页脚)",
|
|||
|
|
},
|
|||
|
|
"image": {
|
|||
|
|
"syntax": '/card image "Title" "Caption" --url <image-url>',
|
|||
|
|
"description": "图片卡片",
|
|||
|
|
},
|
|||
|
|
"action": {
|
|||
|
|
"syntax": '/card action "Title" "Body" --actions "Btn1|url1,Btn2|/cmd"',
|
|||
|
|
"description": "操作卡片(自动识别 URL/命令/postback)",
|
|||
|
|
},
|
|||
|
|
"list": {
|
|||
|
|
"syntax": '/card list "Title" "Item1|Desc1,Item2|Desc2"',
|
|||
|
|
"description": "列表卡片",
|
|||
|
|
},
|
|||
|
|
"receipt": {
|
|||
|
|
"syntax": '/card receipt "Title" "Item1:$10,Item2:$20" --total "$30"',
|
|||
|
|
"description": "收据/摘要卡片",
|
|||
|
|
},
|
|||
|
|
"confirm": {
|
|||
|
|
"syntax": '/card confirm "Question?" --yes "Yes|data" --no "No|data"',
|
|||
|
|
"description": "确认对话框",
|
|||
|
|
},
|
|||
|
|
"buttons": {
|
|||
|
|
"syntax": '/card buttons "Title" "Text" --actions "Btn1|url,Btn2|data"',
|
|||
|
|
"description": "按钮模板",
|
|||
|
|
},
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
|
|||
|
|
def parse_card_command(text: str) -> dict | None:
|
|||
|
|
if not text.startswith("/card "):
|
|||
|
|
return None
|
|||
|
|
|
|||
|
|
args_str = text[6:].strip()
|
|||
|
|
try:
|
|||
|
|
parts = shlex.split(args_str)
|
|||
|
|
except ValueError:
|
|||
|
|
return None
|
|||
|
|
|
|||
|
|
if not parts:
|
|||
|
|
return None
|
|||
|
|
|
|||
|
|
card_type = parts[0].lower()
|
|||
|
|
if card_type not in LINE_CARD_COMMANDS:
|
|||
|
|
return None
|
|||
|
|
|
|||
|
|
positional: list[str] = []
|
|||
|
|
flags: dict[str, str] = {}
|
|||
|
|
current_flag: str | None = None
|
|||
|
|
|
|||
|
|
for item in parts[1:]:
|
|||
|
|
if item.startswith("--"):
|
|||
|
|
current_flag = item[2:]
|
|||
|
|
flags[current_flag] = ""
|
|||
|
|
elif current_flag:
|
|||
|
|
flags[current_flag] = item
|
|||
|
|
current_flag = None
|
|||
|
|
else:
|
|||
|
|
positional.append(item)
|
|||
|
|
|
|||
|
|
return {
|
|||
|
|
"type": card_type,
|
|||
|
|
"positional": positional,
|
|||
|
|
"flags": flags,
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
|
|||
|
|
def build_card_message(parsed: dict) -> dict | None:
|
|||
|
|
card_type = parsed["type"]
|
|||
|
|
positional = parsed.get("positional", [])
|
|||
|
|
flags = parsed.get("flags", {})
|
|||
|
|
|
|||
|
|
match card_type:
|
|||
|
|
case "info":
|
|||
|
|
title = positional[0] if len(positional) > 0 else "Info"
|
|||
|
|
body = positional[1] if len(positional) > 1 else ""
|
|||
|
|
footer = positional[2] if len(positional) > 2 else None
|
|||
|
|
return create_info_card(title, body, footer)
|
|||
|
|
case "image":
|
|||
|
|
title = positional[0] if len(positional) > 0 else "Image"
|
|||
|
|
caption = positional[1] if len(positional) > 1 else ""
|
|||
|
|
url = flags.get("url", "")
|
|||
|
|
return create_image_card(title, caption, url)
|
|||
|
|
case "action":
|
|||
|
|
title = positional[0] if len(positional) > 0 else "Actions"
|
|||
|
|
body = positional[1] if len(positional) > 1 else ""
|
|||
|
|
actions_str = flags.get("actions", "")
|
|||
|
|
return create_action_card(title, body, actions_str)
|
|||
|
|
case "list":
|
|||
|
|
title = positional[0] if len(positional) > 0 else "List"
|
|||
|
|
items_str = positional[1] if len(positional) > 1 else ""
|
|||
|
|
return create_list_card(title, items_str)
|
|||
|
|
case "receipt":
|
|||
|
|
title = positional[0] if len(positional) > 0 else "Receipt"
|
|||
|
|
items_str = positional[1] if len(positional) > 1 else ""
|
|||
|
|
total = flags.get("total")
|
|||
|
|
footer = flags.get("footer")
|
|||
|
|
return create_receipt_card_command(title, items_str, total, footer)
|
|||
|
|
case "confirm":
|
|||
|
|
question = positional[0] if positional else "Are you sure?"
|
|||
|
|
yes_data = flags.get("yes", "Yes|yes")
|
|||
|
|
no_data = flags.get("no", "No|no")
|
|||
|
|
return create_confirm_card(question, yes_data, no_data)
|
|||
|
|
case "buttons":
|
|||
|
|
title = positional[0] if len(positional) > 0 else "Menu"
|
|||
|
|
text = positional[1] if len(positional) > 1 else ""
|
|||
|
|
actions_str = flags.get("actions", "")
|
|||
|
|
return create_buttons_card(title, text, actions_str)
|
|||
|
|
case _:
|
|||
|
|
return None
|
|||
|
|
|
|||
|
|
|
|||
|
|
def create_info_card(title: str, body: str, footer: str | None = None) -> dict:
|
|||
|
|
contents = {
|
|||
|
|
"type": "bubble",
|
|||
|
|
"body": {
|
|||
|
|
"type": "box",
|
|||
|
|
"layout": "vertical",
|
|||
|
|
"contents": [
|
|||
|
|
{"type": "text", "text": title, "weight": "bold", "size": "xl", "wrap": True},
|
|||
|
|
{"type": "text", "text": body, "size": "md", "wrap": True, "margin": "md"},
|
|||
|
|
],
|
|||
|
|
},
|
|||
|
|
}
|
|||
|
|
if footer:
|
|||
|
|
contents["footer"] = {
|
|||
|
|
"type": "box",
|
|||
|
|
"layout": "vertical",
|
|||
|
|
"contents": [
|
|||
|
|
{"type": "text", "text": footer, "size": "xs", "color": "#AAAAAA", "wrap": True},
|
|||
|
|
],
|
|||
|
|
}
|
|||
|
|
return {"type": "flex", "altText": title[:400], "contents": contents}
|
|||
|
|
|
|||
|
|
|
|||
|
|
def create_image_card(title: str, caption: str, url: str) -> dict:
|
|||
|
|
return {
|
|||
|
|
"type": "flex",
|
|||
|
|
"altText": title[:400],
|
|||
|
|
"contents": {
|
|||
|
|
"type": "bubble",
|
|||
|
|
"hero": {
|
|||
|
|
"type": "image",
|
|||
|
|
"url": url,
|
|||
|
|
"size": "full",
|
|||
|
|
"aspectRatio": "16:9",
|
|||
|
|
"aspectMode": "cover",
|
|||
|
|
},
|
|||
|
|
"body": {
|
|||
|
|
"type": "box",
|
|||
|
|
"layout": "vertical",
|
|||
|
|
"contents": [
|
|||
|
|
{"type": "text", "text": title, "weight": "bold", "size": "lg", "wrap": True},
|
|||
|
|
{"type": "text", "text": caption, "size": "sm", "wrap": True, "color": "#888888",
|
|||
|
|
"margin": "md"},
|
|||
|
|
],
|
|||
|
|
},
|
|||
|
|
},
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
|
|||
|
|
def create_action_card(title: str, body: str, actions_str: str) -> dict:
|
|||
|
|
actions = parse_actions(actions_str)
|
|||
|
|
return create_buttons_template(
|
|||
|
|
title=title,
|
|||
|
|
text=body,
|
|||
|
|
actions=actions,
|
|||
|
|
)
|
|||
|
|
|
|||
|
|
|
|||
|
|
def create_list_card(title: str, items_str: str) -> dict:
|
|||
|
|
items: list[tuple[str, str]] = []
|
|||
|
|
for item in items_str.split(","):
|
|||
|
|
parts = item.strip().split("|", 1)
|
|||
|
|
label = parts[0].strip()
|
|||
|
|
desc = parts[1].strip() if len(parts) > 1 else ""
|
|||
|
|
items.append((label, desc))
|
|||
|
|
|
|||
|
|
return create_receipt_card(title, items)
|
|||
|
|
|
|||
|
|
|
|||
|
|
def create_receipt_card_command(title: str, items_str: str, total: str | None, footer: str | None) -> dict:
|
|||
|
|
items: list[tuple[str, str]] = []
|
|||
|
|
for item in items_str.split(","):
|
|||
|
|
parts = item.strip().split(":", 1)
|
|||
|
|
label = parts[0].strip()
|
|||
|
|
value = parts[1].strip() if len(parts) > 1 else ""
|
|||
|
|
items.append((label, value))
|
|||
|
|
|
|||
|
|
return create_receipt_card(title, items, total=total, footer=footer)
|
|||
|
|
|
|||
|
|
|
|||
|
|
def create_confirm_card(question: str, yes_data: str, no_data: str) -> dict:
|
|||
|
|
yes_label, yes_d = _split_label_data(yes_data, "Yes", "yes")
|
|||
|
|
no_label, no_d = _split_label_data(no_data, "No", "no")
|
|||
|
|
action_type = _detect_action_type(yes_d)
|
|||
|
|
confirm_action = _build_action(action_type, yes_label, yes_d)
|
|||
|
|
cancel_action = _build_action(_detect_action_type(no_d), no_label, no_d)
|
|||
|
|
|
|||
|
|
template = create_confirm_template(question, confirm_action, cancel_action)
|
|||
|
|
return to_template_message(question, template)
|
|||
|
|
|
|||
|
|
|
|||
|
|
def create_buttons_card(title: str, text: str, actions_str: str) -> dict:
|
|||
|
|
actions = parse_actions(actions_str)
|
|||
|
|
template = create_buttons_template(title=title, text=text, actions=actions)
|
|||
|
|
return to_template_message(title, template)
|
|||
|
|
|
|||
|
|
|
|||
|
|
def parse_actions(actions_str: str) -> list[dict]:
|
|||
|
|
actions: list[dict] = []
|
|||
|
|
if not actions_str:
|
|||
|
|
return actions
|
|||
|
|
|
|||
|
|
for item in actions_str.split(","):
|
|||
|
|
parts = item.strip().split("|", 1)
|
|||
|
|
label = parts[0].strip()
|
|||
|
|
data = parts[1].strip() if len(parts) > 1 else label
|
|||
|
|
action_type = _detect_action_type(data)
|
|||
|
|
actions.append(_build_action(action_type, label, data))
|
|||
|
|
|
|||
|
|
return actions
|
|||
|
|
|
|||
|
|
|
|||
|
|
def _split_label_data(raw: str, default_label: str, default_data: str) -> tuple[str, str]:
|
|||
|
|
if "|" in raw:
|
|||
|
|
parts = raw.split("|", 1)
|
|||
|
|
return parts[0].strip(), parts[1].strip()
|
|||
|
|
return default_label, raw if raw else default_data
|
|||
|
|
|
|||
|
|
|
|||
|
|
def _detect_action_type(data: str) -> str:
|
|||
|
|
if data.startswith(("http://", "https://")):
|
|||
|
|
return "uri"
|
|||
|
|
if "=" in data and ":" not in data:
|
|||
|
|
return "postback"
|
|||
|
|
return "message"
|
|||
|
|
|
|||
|
|
|
|||
|
|
def _build_action(action_type: str, label: str, data: str) -> dict:
|
|||
|
|
match action_type:
|
|||
|
|
case "uri":
|
|||
|
|
return {"type": "uri", "label": label[:20], "uri": data}
|
|||
|
|
case "postback":
|
|||
|
|
return {"type": "postback", "label": label[:20], "data": data}
|
|||
|
|
case _:
|
|||
|
|
return {"type": "message", "label": label[:20], "text": data}
|
|||
|
|
|
|||
|
|
|
|||
|
|
def create_image_carousel_column(
|
|||
|
|
image_url: str,
|
|||
|
|
action_type: str = "uri",
|
|||
|
|
label: str = "",
|
|||
|
|
data: str = "",
|
|||
|
|
) -> dict:
|
|||
|
|
return {
|
|||
|
|
"imageUrl": image_url,
|
|||
|
|
"action": _build_action(action_type, label, data),
|
|||
|
|
}
|