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 "抱歉,我遇到了一些问题,请稍后再试。"