ForcePilot/backend/package/yuxi/channel/extensions/line/card_command.py
Kris 7fd658af57 feat(channel): 添加 LINE 渠道扩展
新增 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: 类型定义
2026-05-21 11:16:16 +08:00

288 lines
9.4 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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),
}