ForcePilot/backend/package/yuxi/channel/extensions/alexa/outbound.py
Kris 37493b6da6 feat(alexa): 新增Amazon Alexa渠道插件完整实现
新增了Alexa渠道的所有核心功能模块,包括配置管理、请求校验、会话管理、安全验证、去重、响应格式化、渐进式响应、主动推送、提醒功能、配对绑定以及Webhook端点,并完成了插件注册和配置项定义。
2026-05-21 10:38:31 +08:00

338 lines
11 KiB
Python

import html
import logging
logger = logging.getLogger(__name__)
APL_DOCUMENT_TEMPLATE = {
"type": "APL",
"version": "1.8",
"theme": "dark",
"import": [],
"resources": [],
"styles": {},
"layouts": {},
"mainTemplate": {
"parameters": ["payload"],
"items": [
{
"type": "Container",
"width": "100vw",
"height": "100vh",
"paddingTop": "8vh",
"paddingLeft": "8vw",
"paddingRight": "8vw",
"items": [
{
"type": "Text",
"text": "${payload.title}",
"fontSize": "36dp",
"fontWeight": "700",
"color": "#FFFFFF",
"paddingBottom": "24dp",
},
{
"type": "ScrollView",
"height": "60vh",
"item": {
"type": "Text",
"text": "${payload.content}",
"fontSize": "28dp",
"color": "#CCCCCC",
"lineHeight": "1.4",
},
},
],
}
],
},
}
APL_LIST_TEMPLATE = {
"type": "APL",
"version": "1.8",
"theme": "dark",
"import": [],
"resources": [],
"styles": {},
"layouts": {},
"mainTemplate": {
"parameters": ["payload"],
"items": [
{
"type": "Container",
"width": "100vw",
"height": "100vh",
"paddingTop": "4vh",
"paddingLeft": "6vw",
"paddingRight": "6vw",
"items": [
{
"type": "Text",
"text": "${payload.title}",
"fontSize": "32dp",
"fontWeight": "700",
"color": "#FFFFFF",
"paddingBottom": "20dp",
},
{
"type": "ScrollView",
"height": "72vh",
"item": {
"type": "Sequence",
"data": "${payload.items}",
"numbered": False,
"items": [
{
"type": "TouchWrapper",
"onPress": {
"type": "SendEvent",
"arguments": ["selectItem", "${data.index}"]
},
"item": {
"type": "Container",
"borderRadius": "12dp",
"backgroundColor": "#2A2A3A",
"padding": "16dp",
"spacing": "6dp",
"items": [
{
"type": "Text",
"text": "${data.title}",
"fontSize": "26dp",
"fontWeight": "600",
"color": "#FFFFFF",
},
{
"type": "Text",
"text": "${data.summary}",
"fontSize": "20dp",
"color": "#AAAAAA",
"maxLines": 3,
},
],
},
},
],
},
},
],
}
],
},
}
APL_IMAGE_TEMPLATE = {
"type": "APL",
"version": "1.8",
"theme": "dark",
"import": [],
"resources": [],
"styles": {},
"layouts": {},
"mainTemplate": {
"parameters": ["payload"],
"items": [
{
"type": "Container",
"width": "100vw",
"height": "100vh",
"paddingTop": "6vh",
"paddingLeft": "6vw",
"paddingRight": "6vw",
"alignItems": "center",
"items": [
{
"type": "Text",
"text": "${payload.title}",
"fontSize": "28dp",
"fontWeight": "700",
"color": "#FFFFFF",
"paddingBottom": "16dp",
},
{
"type": "Image",
"source": "${payload.imageUrl}",
"width": "80vw",
"height": "50vh",
"scale": "best-fit",
"align": "center",
},
{
"type": "Text",
"text": "${payload.caption}",
"fontSize": "22dp",
"color": "#AAAAAA",
"paddingTop": "16dp",
"maxLines": 2,
},
],
}
],
},
}
class SSMLResponseBuilder:
@staticmethod
def build_response(
ssml: str,
should_end_session: bool = False,
session_attributes: dict | None = None,
card_title: str | None = None,
card_content: str | None = None,
) -> dict:
resp = {
"version": "1.0",
"sessionAttributes": session_attributes or {},
"response": {
"outputSpeech": {
"type": "SSML",
"ssml": ssml,
},
"shouldEndSession": should_end_session,
},
}
if card_title and card_content:
resp["response"]["card"] = {
"type": "Simple",
"title": card_title,
"content": card_content,
}
return resp
@staticmethod
def build_error_response(message: str = "抱歉,我遇到了一些问题。") -> dict:
return SSMLResponseBuilder.build_response(
ssml=f"<speak>{message}</speak>",
should_end_session=True,
)
@staticmethod
def build_apl_directive(title: str, content: str) -> dict:
safe_title = html.escape(title, quote=False)
safe_content = html.escape(content, quote=False)
return {
"type": "Alexa.Presentation.APL.RenderDocument",
"token": "forcepilot-response-token",
"document": APL_DOCUMENT_TEMPLATE,
"datasources": {
"payload": {
"title": safe_title,
"content": safe_content,
}
},
}
@staticmethod
def build_apl_list_directive(title: str, items: list[dict]) -> dict:
safe_title = html.escape(title, quote=False)
safe_items = []
for idx, item in enumerate(items):
safe_items.append({
"index": idx,
"title": html.escape(str(item.get("title", "")), quote=False),
"summary": html.escape(str(item.get("summary", "")), quote=False),
})
return {
"type": "Alexa.Presentation.APL.RenderDocument",
"token": "forcepilot-list-token",
"document": APL_LIST_TEMPLATE,
"datasources": {
"payload": {
"title": safe_title,
"items": safe_items,
}
},
}
@staticmethod
def build_apl_image_directive(title: str, image_url: str, caption: str = "") -> dict:
safe_title = html.escape(title, quote=False)
safe_caption = html.escape(caption, quote=False)
return {
"type": "Alexa.Presentation.APL.RenderDocument",
"token": "forcepilot-image-token",
"document": APL_IMAGE_TEMPLATE,
"datasources": {
"payload": {
"title": safe_title,
"imageUrl": image_url,
"caption": safe_caption,
}
},
}
@staticmethod
def supports_apl(handler_input) -> bool:
try:
envelope = handler_input.request_envelope
device = envelope.context.system.device
if device and device.supported_interfaces:
return "Alexa.Presentation.APL" in device.supported_interfaces
except Exception:
pass
return False
@staticmethod
def build_execute_commands_directive(
token: str,
commands: list[dict],
) -> dict:
return {
"type": "Alexa.Presentation.APL.ExecuteCommands",
"token": token,
"commands": commands,
}
@staticmethod
def build_set_value_command(
component_id: str,
property_name: str,
value: str,
) -> dict:
return {
"type": "SetValue",
"componentId": component_id,
"property": property_name,
"value": value,
}
@staticmethod
def build_speak_item_command(ssml: str) -> dict:
return {
"type": "SpeakItem",
"componentId": "",
"highlightMode": "line",
"minimumDwellTime": 1000,
}
@staticmethod
def build_scroll_command(distance: int = 200) -> dict:
return {
"type": "Scroll",
"distance": distance,
}
@staticmethod
def build_update_content_commands(
token: str,
title: str | None = None,
content: str | None = None,
) -> dict:
commands = []
if title is not None:
commands.append(
SSMLResponseBuilder.build_set_value_command(
"contentTitle", "text", html.escape(title, quote=False)
)
)
if content is not None:
commands.append(
SSMLResponseBuilder.build_set_value_command(
"contentBody", "text", html.escape(content, quote=False)
)
)
return SSMLResponseBuilder.build_execute_commands_directive(token, commands)