新增 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: 类型定义
290 lines
11 KiB
Python
290 lines
11 KiB
Python
from __future__ import annotations
|
|
|
|
import re
|
|
|
|
DIRECTIVE_PATTERN = re.compile(r"\[\[(\w+):\s*(.*?)\]\]")
|
|
|
|
|
|
def parse_line_directives(text: str) -> tuple[str, dict]:
|
|
directives: dict = {}
|
|
cleaned = text
|
|
|
|
for match in DIRECTIVE_PATTERN.finditer(text):
|
|
directive_name = match.group(1)
|
|
args_str = match.group(2)
|
|
|
|
directive_data = _parse_directive_args(directive_name, args_str)
|
|
if directive_data:
|
|
directives.setdefault(directive_name, []).append(directive_data)
|
|
|
|
cleaned = cleaned.replace(match.group(0), "")
|
|
|
|
cleaned = re.sub(r"\n{3,}", "\n\n", cleaned).strip()
|
|
return cleaned, directives
|
|
|
|
|
|
def _parse_directive_args(name: str, args_str: str) -> dict | None:
|
|
match name:
|
|
case "quick_replies":
|
|
items = [item.strip() for item in args_str.split(",") if item.strip()]
|
|
return {"type": "quick_replies", "items": items[:13]} if items else None
|
|
case "location":
|
|
parts = [p.strip() for p in args_str.split("|")]
|
|
if len(parts) >= 4:
|
|
try:
|
|
return {
|
|
"type": "location",
|
|
"title": parts[0],
|
|
"address": parts[1],
|
|
"latitude": float(parts[2]),
|
|
"longitude": float(parts[3]),
|
|
}
|
|
except ValueError:
|
|
return None
|
|
return None
|
|
case "confirm":
|
|
parts = [p.strip() for p in args_str.split("|")]
|
|
if len(parts) >= 2:
|
|
return {
|
|
"type": "confirm",
|
|
"question": parts[0],
|
|
"yes_label": _extract_label(parts[1]) if len(parts) > 1 else "Yes",
|
|
"yes_data": _extract_data(parts[1]) if len(parts) > 1 else "yes",
|
|
"no_label": _extract_label(parts[2]) if len(parts) > 2 else "No",
|
|
"no_data": _extract_data(parts[2]) if len(parts) > 2 else "no",
|
|
}
|
|
return None
|
|
case "buttons":
|
|
parts = [p.strip() for p in args_str.split("|")]
|
|
if len(parts) >= 3:
|
|
title = parts[0]
|
|
description = parts[1]
|
|
button_items = [b.strip() for b in parts[2].split(",")]
|
|
buttons = []
|
|
for btn in button_items:
|
|
btn_parts = btn.split(":", 1)
|
|
btn_label = btn_parts[0].strip()
|
|
btn_data = btn_parts[1].strip() if len(btn_parts) > 1 else btn_label
|
|
action_type = _detect_action_type(btn_data)
|
|
buttons.append({"label": btn_label, "data": btn_data, "action_type": action_type})
|
|
return {
|
|
"type": "buttons",
|
|
"title": title,
|
|
"description": description,
|
|
"buttons": buttons[:4],
|
|
}
|
|
return None
|
|
case "media_player":
|
|
parts = [p.strip() for p in args_str.split("|")]
|
|
if len(parts) >= 4:
|
|
return {
|
|
"type": "media_player",
|
|
"song": parts[0],
|
|
"artist": parts[1],
|
|
"source": parts[2],
|
|
"image_url": parts[3],
|
|
"state": parts[4] if len(parts) > 4 else "playing",
|
|
}
|
|
return None
|
|
case "event":
|
|
parts = [p.strip() for p in args_str.split("|")]
|
|
if len(parts) >= 3:
|
|
return {
|
|
"type": "event",
|
|
"title": parts[0],
|
|
"date": parts[1],
|
|
"time": parts[2],
|
|
"location": parts[3] if len(parts) > 3 else "",
|
|
"description": parts[4] if len(parts) > 4 else "",
|
|
}
|
|
return None
|
|
case "agenda":
|
|
parts = [p.strip() for p in args_str.split("|")]
|
|
if len(parts) >= 2:
|
|
events = []
|
|
for evt in parts[1].split(","):
|
|
evt_parts = evt.strip().split(":", 1)
|
|
event_name = evt_parts[0].strip()
|
|
event_time = evt_parts[1].strip() if len(evt_parts) > 1 else ""
|
|
events.append({"name": event_name, "time": event_time})
|
|
return {
|
|
"type": "agenda",
|
|
"title": parts[0],
|
|
"events": events,
|
|
}
|
|
return None
|
|
case "device":
|
|
parts = [p.strip() for p in args_str.split("|")]
|
|
if len(parts) >= 3:
|
|
name = parts[0]
|
|
device_type = parts[1]
|
|
state = parts[2]
|
|
controls = []
|
|
if len(parts) > 3:
|
|
for ctrl in parts[3].split(","):
|
|
ctrl_parts = ctrl.strip().split(":", 1)
|
|
ctrl_label = ctrl_parts[0].strip()
|
|
ctrl_data = ctrl_parts[1].strip() if len(ctrl_parts) > 1 else ctrl_label
|
|
action_type = _detect_action_type(ctrl_data)
|
|
controls.append({"label": ctrl_label, "data": ctrl_data, "action_type": action_type})
|
|
return {
|
|
"type": "device",
|
|
"name": name,
|
|
"device_type": device_type,
|
|
"state": state,
|
|
"controls": controls[:6],
|
|
}
|
|
return None
|
|
case "appletv_remote":
|
|
parts = [p.strip() for p in args_str.split("|")]
|
|
return {
|
|
"type": "appletv_remote",
|
|
"name": parts[0] if parts else "Apple TV",
|
|
"state": parts[1] if len(parts) > 1 else "idle",
|
|
}
|
|
case _:
|
|
return None
|
|
|
|
|
|
def _extract_label(item: str) -> str:
|
|
if ":" in item:
|
|
return item.split(":", 1)[0]
|
|
return item
|
|
|
|
|
|
def _extract_data(item: str) -> str:
|
|
if ":" in item:
|
|
return item.split(":", 1)[1]
|
|
return item
|
|
|
|
|
|
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_line_messages_from_directives(directives: dict) -> list[dict]:
|
|
messages: list[dict] = []
|
|
|
|
for directive_type, items in directives.items():
|
|
for item in items:
|
|
message = _build_directive_message(directive_type, item)
|
|
if message:
|
|
if isinstance(message, list):
|
|
messages.extend(message)
|
|
else:
|
|
messages.append(message)
|
|
|
|
return messages
|
|
|
|
|
|
def _build_directive_message(directive_type: str, data: dict) -> dict | list[dict] | None:
|
|
match directive_type:
|
|
case "quick_replies":
|
|
quick_reply_items = _build_quick_reply_action_items(data.get("items", []))
|
|
return {
|
|
"type": "text",
|
|
"text": data.get("text", ""),
|
|
"quickReply": {"items": quick_reply_items},
|
|
}
|
|
case "location":
|
|
return {
|
|
"type": "location",
|
|
"title": data.get("title", ""),
|
|
"address": data.get("address", ""),
|
|
"latitude": data.get("latitude", 0),
|
|
"longitude": data.get("longitude", 0),
|
|
}
|
|
case "confirm":
|
|
template = _build_confirm_template(data)
|
|
return {"type": "template", "altText": data.get("question", "Confirm")[:400], "template": template}
|
|
case "buttons":
|
|
template = _build_buttons_template(data)
|
|
return {"type": "template", "altText": data.get("title", "Menu")[:400], "template": template}
|
|
case "media_player":
|
|
from yuxi.channel.extensions.line.flex_templates import create_media_player_card
|
|
return create_media_player_card(
|
|
song=data.get("song", ""),
|
|
artist=data.get("artist", ""),
|
|
source=data.get("source", ""),
|
|
image_url=data.get("image_url", ""),
|
|
state=data.get("state", "playing"),
|
|
)
|
|
case "event":
|
|
from yuxi.channel.extensions.line.flex_templates import create_event_card
|
|
return create_event_card(
|
|
title=data.get("title", ""),
|
|
date=data.get("date", ""),
|
|
time=data.get("time", ""),
|
|
location=data.get("location", ""),
|
|
description=data.get("description", ""),
|
|
)
|
|
case "agenda":
|
|
from yuxi.channel.extensions.line.flex_templates import create_agenda_card
|
|
return create_agenda_card(
|
|
title=data.get("title", ""),
|
|
events=data.get("events", []),
|
|
)
|
|
case "device":
|
|
from yuxi.channel.extensions.line.flex_templates import create_device_control_card
|
|
return create_device_control_card(
|
|
name=data.get("name", ""),
|
|
device_type=data.get("device_type", ""),
|
|
state=data.get("state", ""),
|
|
controls=data.get("controls", []),
|
|
)
|
|
case "appletv_remote":
|
|
from yuxi.channel.extensions.line.flex_templates import create_apple_tv_remote
|
|
return create_apple_tv_remote(
|
|
name=data.get("name", "Apple TV"),
|
|
state=data.get("state", "idle"),
|
|
)
|
|
case _:
|
|
return None
|
|
|
|
|
|
def _build_quick_reply_action_items(items: list[str]) -> list[dict]:
|
|
result: list[dict] = []
|
|
for label in items[:13]:
|
|
result.append({
|
|
"type": "action",
|
|
"action": {"type": "message", "label": label[:20], "text": label},
|
|
})
|
|
return result
|
|
|
|
|
|
def _build_confirm_template(data: dict) -> dict:
|
|
yes_label = data.get("yes_label", "Yes")
|
|
yes_data = data.get("yes_data", "yes")
|
|
no_label = data.get("no_label", "No")
|
|
no_data = data.get("no_data", "no")
|
|
return {
|
|
"type": "confirm",
|
|
"text": data.get("question", ""),
|
|
"actions": [
|
|
{"type": "message", "label": yes_label[:20], "text": yes_data},
|
|
{"type": "message", "label": no_label[:20], "text": no_data},
|
|
],
|
|
}
|
|
|
|
|
|
def _build_buttons_template(data: dict) -> dict:
|
|
buttons = data.get("buttons", [])
|
|
actions: list[dict] = []
|
|
for btn in buttons[:4]:
|
|
action_type = btn.get("action_type", "message")
|
|
if action_type == "uri":
|
|
actions.append({"type": "uri", "label": btn["label"][:20], "uri": btn["data"]})
|
|
elif action_type == "postback":
|
|
actions.append({"type": "postback", "label": btn["label"][:20], "data": btn["data"]})
|
|
else:
|
|
actions.append({"type": "message", "label": btn["label"][:20], "text": btn["data"]})
|
|
return {
|
|
"type": "buttons",
|
|
"title": data.get("title", "")[:40],
|
|
"text": data.get("description", "")[:160],
|
|
"actions": actions,
|
|
} |