新增 LINE 渠道扩展,支持在 Yuxi 平台中集成 LINE 即时通讯渠道。 包含以下功能模块: - bot: LINE Bot 客户端封装 - config: 渠道配置管理 - gateway: SSE/WebSocket 网关接入 - webhook: Webhook 事件处理 - outbound: 外发消息管理 - streaming: 流式消息处理 - pairing: 用户配对与绑定 - security: 安全校验 - signature: 请求签名验证 - token_manager: Token 管理 - dedupe: 消息去重 - monitor: 渠道状态监控 - status: 会话状态管理 - session: 会话管理 - flex_templates: Flex 模板消息 - card_command: 卡片指令处理 - template_messages: 模板消息 - rich_menu: 富菜单管理 - actions: 动作处理 - directives: 指令处理 - delivery: 消息送达确认 - loading: 加载动画 - media: 媒体资源处理 - types: 类型定义
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),
|
||
} |