ForcePilot/backend/package/yuxi/channel/extensions/line/directives.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

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