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