新增了Alexa渠道的所有核心功能模块,包括配置管理、请求校验、会话管理、安全验证、去重、响应格式化、渐进式响应、主动推送、提醒功能、配对绑定以及Webhook端点,并完成了插件注册和配置项定义。
338 lines
11 KiB
Python
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) |