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

397 lines
14 KiB
Python

import asyncio
import json
import logging
import uuid
from datetime import datetime, timezone
from ask_sdk_core.skill_builder import SkillBuilder
from ask_sdk_core.dispatch_components import (
AbstractRequestHandler,
AbstractExceptionHandler,
AbstractRequestInterceptor,
)
from ask_sdk_core.utils import is_request_type, is_intent_name
from ask_sdk_model.ui import AskForPermissionsConsentCard
try:
from ask_sdk_model.canfulfill import CanFulfillIntent, CanFulfillIntentValues
_CANFULFILL_AVAILABLE = True
except ImportError:
_CANFULFILL_AVAILABLE = False
from yuxi.channel.extensions.alexa.format import MarkdownToSSML, ssml_safe
from yuxi.channel.extensions.alexa.session import AlexaSessionManager
from yuxi.channel.extensions.alexa.security import AlexaSecurity
from yuxi.channel.extensions.alexa.outbound import SSMLResponseBuilder
from yuxi.channel.extensions.alexa.progressive import ProgressiveResponseService
from yuxi.channel.message.models import MessageType, PeerInfo, UnifiedMessage
from yuxi.channel.routing.models import PeerKind
logger = logging.getLogger(__name__)
_agent_response_cache: dict[str, str] = {}
RESPONSE_TEXTS = {
"zh-CN": {
"welcome": "欢迎使用 ForcePilot 智能助手。您可以向我提问任何知识库相关的问题,例如:'公司请假流程是什么'。请问有什么可以帮您?",
"help": "欢迎使用 ForcePilot 智能助手。您可以向我提问任何知识库相关的问题,例如:'公司请假流程是什么'。请问有什么可以帮您?",
"ask_prompt": "请说出您的问题。",
"goodbye": "好的,再见。",
"auth_failed": "账号验证失败,请重新在 Alexa App 中关联您的账号。",
"account_link_required": "请先在 Alexa App 中关联您的 ForcePilot 账号。",
"service_unavailable": "抱歉,服务暂时不可用,请稍后再试。",
"no_result": "抱歉,我没有找到相关信息。",
"timeout": "抱歉,回答这个问题需要较长时间,请您换个方式问我。",
"error": "抱歉,我遇到了一些问题,请稍后再试。",
},
"en-US": {
"welcome": "Welcome to ForcePilot Knowledge Assistant. You can ask me anything about your knowledge base. For example: What are the company leave policies? How can I help you?",
"help": "Welcome to ForcePilot Knowledge Assistant. You can ask me anything about your knowledge base. For example: What are the company leave policies? How can I help you?",
"ask_prompt": "Please tell me your question.",
"goodbye": "Goodbye!",
"auth_failed": "Account verification failed. Please re-link your account in the Alexa App.",
"account_link_required": "Please link your ForcePilot account in the Alexa App first.",
"service_unavailable": "Sorry, the service is temporarily unavailable. Please try again later.",
"no_result": "Sorry, I couldn't find any relevant information.",
"timeout": "Sorry, answering this question takes longer than expected. Please try rephrasing your question.",
"error": "Sorry, I encountered an issue. Please try again later.",
},
}
def _t(locale: str, key: str) -> str:
texts = RESPONSE_TEXTS.get(locale, RESPONSE_TEXTS["en-US"])
return texts.get(key, RESPONSE_TEXTS["en-US"].get(key, key))
def _get_locale(handler_input) -> str:
try:
locale = handler_input.request_envelope.request.locale
return locale if locale in RESPONSE_TEXTS else "en-US"
except Exception:
return "en-US"
def set_cached_agent_response(request_id: str, response_text: str):
_agent_response_cache[request_id] = response_text
def _pop_cached_agent_response(request_id: str) -> str | None:
return _agent_response_cache.pop(request_id, None)
class SkillIdInterceptor(AbstractRequestInterceptor):
def process(self, handler_input):
expected_skill_id = _get_expected_skill_id()
if not expected_skill_id:
return
try:
actual_skill_id = handler_input.request_envelope.context.system.application.application_id
if actual_skill_id != expected_skill_id:
logger.warning(
"Skill ID mismatch: expected=%s, actual=%s",
expected_skill_id,
actual_skill_id,
)
raise ValueError("Invalid Skill ID")
except ValueError:
raise
except Exception:
logger.exception("Skill ID verification failed")
_expected_skill_id: str | None = None
def _get_expected_skill_id() -> str | None:
return _expected_skill_id
def set_expected_skill_id(skill_id: str | None):
global _expected_skill_id
_expected_skill_id = skill_id
_sb = SkillBuilder()
_sb.add_global_request_interceptor(SkillIdInterceptor())
class ChatIntentHandler(AbstractRequestHandler):
def can_handle(self, handler_input):
return (
is_intent_name("ChatIntent")(handler_input)
or is_intent_name("AMAZON.FallbackIntent")(handler_input)
)
def handle(self, handler_input):
envelope = handler_input.request_envelope
system = envelope.context.system
session = envelope.session
access_token = system.user.access_token
user_id = system.user.user_id
session_id = session.session_id if session else ""
is_new = session.new if session else True
request_obj = envelope.request
slots = getattr(request_obj, "intent", None)
query_text = "你好"
if slots and hasattr(slots, "slots") and slots.slots and "query" in slots.slots:
slot_value = slots.slots["query"]
if slot_value and slot_value.value:
query_text = slot_value.value
request_id = getattr(request_obj, "request_id", str(uuid.uuid4()))
locale_str = _get_locale(handler_input)
_plugin = _get_plugin()
security = _plugin._security if _plugin else None
if access_token and security:
verified = security.verify_access_token(access_token)
if not verified.get("verified"):
return handler_input.response_builder.speak(
_t(locale_str, "auth_failed")
).set_should_end_session(True).response
elif security and security.account_linking_enabled:
return handler_input.response_builder.speak(
_t(locale_str, "account_link_required")
).set_card(
AskForPermissionsConsentCard(permissions=[])
).set_should_end_session(True).response
agent_response = _pop_cached_agent_response(request_id)
if agent_response is None:
agent_response = _t(locale_str, "service_unavailable")
formatter = _plugin._formatter if _plugin else MarkdownToSSML()
ssml = formatter.convert(agent_response)
session_attrs = session.attributes if session and session.attributes else {}
if is_new:
session_attrs = {}
session_attrs["_last_query"] = query_text
session_attrs["_turn"] = session_attrs.get("_turn", 0) + 1
handler_input.response_builder.speak(ssml)
handler_input.response_builder.set_should_end_session(False)
response = handler_input.response_builder.response
if SSMLResponseBuilder.supports_apl(handler_input):
title = query_text[:50] + ("" if len(query_text) > 50 else "")
apl_directive = SSMLResponseBuilder.build_apl_directive(title, agent_response)
try:
if response.directives is None:
response.directives = []
response.directives.append(apl_directive)
except (TypeError, AttributeError):
pass
return response
class LaunchRequestHandler(AbstractRequestHandler):
def can_handle(self, handler_input):
return is_request_type("LaunchRequest")(handler_input)
def handle(self, handler_input):
locale_str = _get_locale(handler_input)
speech = _t(locale_str, "welcome")
handler_input.response_builder.speak(speech).ask(_t(locale_str, "ask_prompt"))
return handler_input.response_builder.response
class HelpIntentHandler(AbstractRequestHandler):
def can_handle(self, handler_input):
return is_intent_name("AMAZON.HelpIntent")(handler_input)
def handle(self, handler_input):
locale_str = _get_locale(handler_input)
speech = _t(locale_str, "help")
handler_input.response_builder.speak(speech).ask(_t(locale_str, "ask_prompt"))
return handler_input.response_builder.response
class StopCancelHandler(AbstractRequestHandler):
def can_handle(self, handler_input):
return (
is_intent_name("AMAZON.StopIntent")(handler_input)
or is_intent_name("AMAZON.CancelIntent")(handler_input)
)
def handle(self, handler_input):
locale_str = _get_locale(handler_input)
handler_input.response_builder.speak(_t(locale_str, "goodbye")).set_should_end_session(True)
return handler_input.response_builder.response
class SessionEndedHandler(AbstractRequestHandler):
def can_handle(self, handler_input):
return is_request_type("SessionEndedRequest")(handler_input)
def handle(self, handler_input):
session = handler_input.request_envelope.session
if session:
reason = getattr(
getattr(handler_input.request_envelope.request, "reason", None),
"value",
"unknown",
)
logger.info(
"Alexa session ended: session_id=%s, reason=%s",
getattr(session, "session_id", ""),
reason,
)
return handler_input.response_builder.response
class CanFulfillIntentHandler(AbstractRequestHandler):
def can_handle(self, handler_input):
if not _CANFULFILL_AVAILABLE:
return False
return is_request_type("CanFulfillIntentRequest")(handler_input)
def handle(self, handler_input):
intent_name = handler_input.request_envelope.request.intent.name
can_fulfill = CanFulfillIntentValues.YES if intent_name == "ChatIntent" else CanFulfillIntentValues.NO
return (
handler_input.response_builder
.set_can_fulfill_intent(CanFulfillIntent(can_fulfill=can_fulfill))
.response
)
class CatchAllExceptionHandler(AbstractExceptionHandler):
def can_handle(self, handler_input, exception):
return True
def handle(self, handler_input, exception):
logger.exception("Unhandled Alexa skill exception")
locale_str = _get_locale(handler_input)
return handler_input.response_builder.speak(
_t(locale_str, "error")
).set_should_end_session(True).response
_sb.add_request_handler(CanFulfillIntentHandler())
_sb.add_request_handler(LaunchRequestHandler())
_sb.add_request_handler(ChatIntentHandler())
_sb.add_request_handler(HelpIntentHandler())
_sb.add_request_handler(StopCancelHandler())
_sb.add_request_handler(SessionEndedHandler())
_sb.add_exception_handler(CatchAllExceptionHandler())
_plugin_ref = None
def set_plugin_ref(plugin):
global _plugin_ref
_plugin_ref = plugin
def _get_plugin():
return _plugin_ref
def create_skill_handler():
return _sb.create()
def extract_query_from_envelope(envelope: dict) -> dict:
request_obj = envelope.get("request", {})
request_type = request_obj.get("type", "")
request_id = request_obj.get("requestId", str(uuid.uuid4()))
locale = request_obj.get("locale", "en-US")
session = envelope.get("session", {})
session_id = session.get("sessionId", "")
is_new = session.get("new", True)
context = envelope.get("context", {})
system = context.get("System", context.get("system", {}))
user = system.get("user", {})
user_id = user.get("userId", "")
access_token = user.get("accessToken", None)
device = system.get("device", {})
device_id = device.get("deviceId", "")
query_text = "你好"
intent_name = request_type
if request_type == "IntentRequest":
intent = request_obj.get("intent", {})
intent_name = intent.get("name", request_type)
slots = intent.get("slots", {})
if "query" in slots:
slot_value = slots["query"]
if isinstance(slot_value, dict):
query_text = slot_value.get("value", "你好")
elif hasattr(slot_value, "value"):
query_text = slot_value.value
return {
"request_id": request_id,
"session_id": session_id,
"user_id": user_id,
"access_token": access_token,
"intent_name": intent_name,
"query_text": query_text,
"locale": locale,
"is_new_session": is_new,
"device_id": device_id,
}
async def dispatch_to_agent(query_info: dict) -> str:
from yuxi.channel.runtime.manager import gateway
processor = gateway._processor
if processor is None:
return "抱歉,服务暂时不可用。"
unified_msg = UnifiedMessage(
msg_id=query_info["request_id"],
channel_type="alexa",
account_id="default",
content=query_info["query_text"],
message_type=MessageType.TEXT,
sender=PeerInfo(
id=query_info["user_id"],
kind=PeerKind.DIRECT,
display_name=query_info["user_id"][:16],
),
timestamp=datetime.now(timezone.utc),
body_for_agent=query_info["query_text"],
metadata={
"session_id": query_info["session_id"],
"device_id": query_info["device_id"],
"locale": query_info["locale"],
"is_new_session": query_info["is_new_session"],
"intent_name": query_info["intent_name"],
},
)
try:
result = await asyncio.wait_for(processor.process(unified_msg), timeout=7.0)
if result is None:
return "抱歉,我没有找到相关信息。"
if isinstance(result, str):
return result
if isinstance(result, dict):
return result.get("content", result.get("text", str(result)))
return str(result)
except asyncio.TimeoutError:
return "抱歉,回答这个问题需要较长时间,请您换个方式问我。"
except Exception:
logger.exception("Agent dispatch failed for Alexa")
return "抱歉,我遇到了一些问题,请稍后再试。"