Created
November 8, 2025 02:36
-
-
Save Dubu2500/37031790e3202c492dc61ca8968fe942 to your computer and use it in GitHub Desktop.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| import json | |
| import logging | |
| import urllib.request | |
| import urllib.error | |
| import urllib.parse | |
| from typing import Dict, List, Optional | |
| logger = logging.getLogger(__name__) | |
| logger.setLevel(logging.INFO) | |
| # --- Endpoints de Steam --- | |
| STEAM_API_BASE = "https://store.steampowered.com/api" | |
| CC = "mx" | |
| LANG = "spanish" | |
| FEATURED_ENDPOINT = f"{STEAM_API_BASE}/featured/?cc={CC}&l={LANG}" | |
| APP_DETAILS_ENDPOINT = f"{STEAM_API_BASE}/appdetails" | |
| STORE_SEARCH_ENDPOINT = f"{STEAM_API_BASE}/storesearch" | |
| def get_featured_deals(limit: int = 5) -> Optional[List[Dict]]: | |
| """Obtiene las ofertas destacadas de Steam. | |
| Retorna una lista de diccionarios con claves: name, discount, final_price, original_price | |
| o None si no hay resultados válidos / hubo error. | |
| """ | |
| try: | |
| req = urllib.request.Request( | |
| FEATURED_ENDPOINT, | |
| headers={"User-Agent": "SteamOfertasAlexa/1.0"} | |
| ) | |
| with urllib.request.urlopen(req, timeout=6) as resp: | |
| raw = resp.read() | |
| data = json.loads(raw.decode("utf-8")) if raw else {} | |
| items = ( | |
| data.get("featured_win") | |
| or data.get("featured_linux") | |
| or data.get("featured_mac") | |
| or [] | |
| ) | |
| deals: List[Dict] = [] | |
| for item in items[:limit]: | |
| discount = int(item.get("discount_percent") or 0) | |
| deals.append({ | |
| "name": item.get("name") or "Juego desconocido", | |
| "discount": discount, | |
| "final_price": (item.get("final_price") or 0) / 100, | |
| "original_price": (item.get("original_price") or 0) / 100, | |
| }) | |
| return deals or None | |
| except urllib.error.URLError as e: | |
| logger.error(f"Error de red al obtener ofertas: {e}", exc_info=True) | |
| return None | |
| except Exception as e: | |
| logger.error(f"Error obteniendo ofertas: {e}", exc_info=True) | |
| return None | |
| def search_game(game_name: str) -> Optional[Dict]: | |
| """Busca un juego por nombre y devuelve datos de precio/discount en MXN. | |
| Retorna dict: {name, appid, discount, final_price, original_price}. | |
| Si no hay precio (gratis/no a la venta), precios = 0 y discount = 0. | |
| """ | |
| try: | |
| term = urllib.parse.quote(game_name) | |
| search_url = f"{STORE_SEARCH_ENDPOINT}?term={term}&cc={CC}&l={LANG}" | |
| req = urllib.request.Request(search_url, headers={"User-Agent": "SteamOfertasAlexa/1.0"}) | |
| with urllib.request.urlopen(req, timeout=6) as resp: | |
| raw = resp.read() | |
| data = json.loads(raw.decode("utf-8")) if raw else {} | |
| items = data.get("items") or [] | |
| if not items: | |
| return None | |
| cand = items[0] | |
| appid = cand.get("id") or cand.get("appid") | |
| name = cand.get("name") or game_name | |
| if not appid: | |
| return {"name": name, "appid": None, "discount": 0, "final_price": 0.0, "original_price": 0.0} | |
| details_url = f"{APP_DETAILS_ENDPOINT}?appids={appid}&cc={CC}&l={LANG}" | |
| dreq = urllib.request.Request(details_url, headers={"User-Agent": "SteamOfertasAlexa/1.0"}) | |
| with urllib.request.urlopen(dreq, timeout=6) as dresp: | |
| draw = dresp.read() | |
| djson = json.loads(draw.decode("utf-8")) if draw else {} | |
| node = djson.get(str(appid)) or {} | |
| pdata = (node.get("data") or {}).get("price_overview") or {} | |
| discount = int(pdata.get("discount_percent") or 0) | |
| final_price = (pdata.get("final") or 0) / 100 | |
| original_price = (pdata.get("initial") or 0) / 100 | |
| discount_expiration = pdata.get("discount_expiration") # epoch secs optional | |
| return { | |
| "name": name, | |
| "appid": appid, | |
| "discount": discount, | |
| "final_price": float(final_price), | |
| "original_price": float(original_price), | |
| "discount_expiration": discount_expiration, | |
| } | |
| except urllib.error.URLError as e: | |
| logger.error(f"Error de red en búsqueda de juego: {e}", exc_info=True) | |
| return None | |
| except Exception as e: | |
| logger.error(f"Error buscando juego: {e}", exc_info=True) | |
| return None | |
| def get_deals_by_genre(genre: str, limit: int = 5, max_price: Optional[float] = None) -> Optional[List[Dict]]: | |
| """Busca juegos relacionados con un género y retorna ofertas con precio MXN. | |
| Heurística: usa `storesearch?term=<genre>` y para los primeros resultados | |
| consulta `appdetails` para obtener `price_overview`. Filtra por descuento > 0 | |
| y por `max_price` si se proporciona. | |
| """ | |
| try: | |
| term = urllib.parse.quote(genre) | |
| search_url = f"{STORE_SEARCH_ENDPOINT}?term={term}&cc={CC}&l={LANG}" | |
| req = urllib.request.Request(search_url, headers={"User-Agent": "SteamOfertasAlexa/1.0"}) | |
| with urllib.request.urlopen(req, timeout=7) as resp: | |
| raw = resp.read() | |
| data = json.loads(raw.decode("utf-8")) if raw else {} | |
| items = data.get("items") or [] | |
| if not items: | |
| return None | |
| deals: List[Dict] = [] | |
| for cand in items[:20]: # examina algunos y arma un top | |
| appid = cand.get("id") or cand.get("appid") | |
| name = cand.get("name") or "Juego" | |
| if not appid: | |
| continue | |
| details_url = f"{APP_DETAILS_ENDPOINT}?appids={appid}&cc={CC}&l={LANG}" | |
| dreq = urllib.request.Request(details_url, headers={"User-Agent": "SteamOfertasAlexa/1.0"}) | |
| try: | |
| with urllib.request.urlopen(dreq, timeout=7) as dresp: | |
| draw = dresp.read() | |
| djson = json.loads(draw.decode("utf-8")) if draw else {} | |
| node = djson.get(str(appid)) or {} | |
| pdata = (node.get("data") or {}).get("price_overview") or {} | |
| discount = int(pdata.get("discount_percent") or 0) | |
| final_price = (pdata.get("final") or 0) / 100 | |
| original_price = (pdata.get("initial") or 0) / 100 | |
| if discount <= 0 and not (final_price == 0 and original_price == 0): | |
| # si no hay descuento y no es gratis, continuar | |
| continue | |
| if max_price is not None and final_price and final_price > max_price: | |
| continue | |
| deals.append({ | |
| "name": name, | |
| "discount": discount, | |
| "final_price": float(final_price), | |
| "original_price": float(original_price), | |
| }) | |
| if len(deals) >= limit: | |
| break | |
| except Exception: | |
| continue | |
| return deals or None | |
| except urllib.error.URLError as e: | |
| logger.error(f"Error de red en filtro por género: {e}", exc_info=True) | |
| return None | |
| except Exception as e: | |
| logger.error(f"Error filtrando por género: {e}", exc_info=True) | |
| return None |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| from ask_sdk_core.skill_builder import SkillBuilder | |
| from .handlers import ( | |
| LaunchRequestHandler, | |
| GetDailyDealsIntentHandler, | |
| GetGameDetailsIntentHandler, | |
| SetPriceRangeIntentHandler, | |
| FilterByGenreIntentHandler, | |
| HelpIntentHandler, | |
| CancelOrStopIntentHandler, | |
| FallbackIntentHandler, | |
| AnyIntentCatchAllHandler, | |
| SessionEndedRequestHandler, | |
| IntentReflectorHandler, | |
| CatchAllExceptionHandler, | |
| ) | |
| from .interceptors import LogRequestInterceptor, LogResponseInterceptor | |
| sb = SkillBuilder() | |
| # Handlers específicos | |
| sb.add_request_handler(LaunchRequestHandler()) | |
| sb.add_request_handler(GetDailyDealsIntentHandler()) | |
| sb.add_request_handler(GetGameDetailsIntentHandler()) | |
| sb.add_request_handler(SetPriceRangeIntentHandler()) | |
| sb.add_request_handler(FilterByGenreIntentHandler()) | |
| sb.add_request_handler(HelpIntentHandler()) | |
| sb.add_request_handler(CancelOrStopIntentHandler()) | |
| sb.add_request_handler(FallbackIntentHandler()) | |
| sb.add_request_handler(AnyIntentCatchAllHandler()) | |
| sb.add_request_handler(SessionEndedRequestHandler()) | |
| # Opcional para depurar | |
| sb.add_request_handler(IntentReflectorHandler()) | |
| # Interceptores y excepciones | |
| sb.add_global_request_interceptor(LogRequestInterceptor()) | |
| sb.add_global_response_interceptor(LogResponseInterceptor()) | |
| sb.add_exception_handler(CatchAllExceptionHandler()) | |
| lambda_handler = sb.lambda_handler() |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| import logging | |
| from typing import Dict | |
| from ask_sdk_core.dispatch_components import AbstractRequestHandler, AbstractExceptionHandler | |
| from ask_sdk_core.handler_input import HandlerInput | |
| from ask_sdk_core.utils import is_request_type, is_intent_name | |
| from ask_sdk_model import Response | |
| from ask_sdk_model.ui import SimpleCard | |
| from api_helper import get_featured_deals, search_game, get_deals_by_genre | |
| import speech_text as st | |
| from utils import get_slot_value, get_number_slot_value, ssml | |
| logger = logging.getLogger(__name__) | |
| class LaunchRequestHandler(AbstractRequestHandler): | |
| def can_handle(self, handler_input: HandlerInput) -> bool: | |
| return is_request_type("LaunchRequest")(handler_input) | |
| def handle(self, handler_input: HandlerInput) -> Response: | |
| # Bienvenida + muestra breve de ofertas destacadas | |
| deals = get_featured_deals(limit=3) | |
| if deals: | |
| deals_speech, card = st.daily_deals_speech(deals) | |
| speak = f"{st.WELCOME_SPEECH} {deals_speech}" | |
| reprompt = st.DEALS_REPROMPT | |
| card_title = "Steam Ofertas" | |
| card_text = card | |
| else: | |
| speak = st.WELCOME_SPEECH | |
| reprompt = st.WELCOME_REPROMPT | |
| card_title = "Steam Ofertas" | |
| card_text = "¡Bienvenido!" | |
| return ( | |
| handler_input.response_builder | |
| .speak(ssml(speak)) | |
| .ask(ssml(reprompt)) | |
| .set_card(SimpleCard(card_title, card_text)) | |
| .response | |
| ) | |
| class GetDailyDealsIntentHandler(AbstractRequestHandler): | |
| def can_handle(self, handler_input: HandlerInput) -> bool: | |
| return is_intent_name("GetDailyDealsIntent")(handler_input) | |
| def handle(self, handler_input: HandlerInput) -> Response: | |
| # Aplica rango de precio si el usuario lo estableció antes | |
| attrs = handler_input.attributes_manager.session_attributes | |
| max_price = attrs.get("max_price") | |
| deals = get_featured_deals() | |
| if max_price is not None and deals: | |
| deals = [d for d in deals if not d.get("final_price") or d.get("final_price") <= float(max_price)] | |
| if deals: | |
| speak_output, card_content = st.daily_deals_speech(deals) | |
| else: | |
| speak_output, card_content = st.daily_deals_fallback_speech() | |
| return ( | |
| handler_input.response_builder | |
| .speak(ssml(speak_output)) | |
| .ask(ssml(st.DEALS_REPROMPT)) | |
| .set_card(SimpleCard("Ofertas del Día", card_content)) | |
| .response | |
| ) | |
| class GetGameDetailsIntentHandler(AbstractRequestHandler): | |
| def can_handle(self, handler_input: HandlerInput) -> bool: | |
| return is_intent_name("GetGameDetailsIntent")(handler_input) | |
| def handle(self, handler_input: HandlerInput) -> Response: | |
| game = get_slot_value(handler_input, "gameName") | |
| if not game: | |
| speak_output, card_content = st.game_not_understood_speech() | |
| else: | |
| info = search_game(game) | |
| if info: | |
| speak_output, card_content = st.game_details_from_data(game, info) | |
| else: | |
| speak_output, card_content = st.game_details_speech(game) | |
| return ( | |
| handler_input.response_builder | |
| .speak(ssml(speak_output)) | |
| .ask(ssml("¿Quieres saber de otro juego?")) | |
| .set_card(SimpleCard("Detalles del Juego", card_content)) | |
| .response | |
| ) | |
| class SetPriceRangeIntentHandler(AbstractRequestHandler): | |
| def can_handle(self, handler_input: HandlerInput) -> bool: | |
| return is_intent_name("SetPriceRangeIntent")(handler_input) | |
| def handle(self, handler_input: HandlerInput) -> Response: | |
| value = get_number_slot_value(handler_input, "maxPrice") | |
| if value is None: | |
| speak = "No entendí tu presupuesto. Di por ejemplo: ofertas menores a 200 pesos." | |
| return ( | |
| handler_input.response_builder | |
| .speak(ssml(speak)) | |
| .ask(ssml("¿Cuál es tu presupuesto máximo?")) | |
| .response | |
| ) | |
| handler_input.attributes_manager.session_attributes["max_price"] = float(value) | |
| speak = f"Perfecto, tomaré en cuenta un máximo de {int(value)} pesos en las ofertas." | |
| reprompt = "¿Quieres ver ofertas de hoy o por género?" | |
| return ( | |
| handler_input.response_builder | |
| .speak(ssml(speak)) | |
| .ask(ssml(reprompt)) | |
| .response | |
| ) | |
| class FilterByGenreIntentHandler(AbstractRequestHandler): | |
| def can_handle(self, handler_input: HandlerInput) -> bool: | |
| return is_intent_name("FilterByGenreIntent")(handler_input) | |
| def handle(self, handler_input: HandlerInput) -> Response: | |
| genre = get_slot_value(handler_input, "gameGenre") | |
| if not genre: | |
| speak = "No entendí el género. Por ejemplo: juegos de acción, o de estrategia." | |
| return ( | |
| handler_input.response_builder | |
| .speak(ssml(speak)) | |
| .ask(ssml("¿Qué género te interesa?")) | |
| .response | |
| ) | |
| attrs = handler_input.attributes_manager.session_attributes | |
| max_price = attrs.get("max_price") | |
| deals = get_deals_by_genre(genre, limit=5, max_price=float(max_price) if max_price is not None else None) | |
| if not deals: | |
| extra = f" con máximo {int(max_price)} pesos" if max_price is not None else "" | |
| speak = f"No encontré ofertas para {genre}{extra} en este momento." | |
| reprompt = "¿Quieres intentar con otro género o ver ofertas del día?" | |
| return ( | |
| handler_input.response_builder | |
| .speak(ssml(speak)) | |
| .ask(ssml(reprompt)) | |
| .response | |
| ) | |
| deals_speech, card = st.daily_deals_speech(deals) | |
| speak_full = f"Esto encontré en {genre}: {deals_speech}" | |
| return ( | |
| handler_input.response_builder | |
| .speak(ssml(speak_full)) | |
| .ask(ssml(st.DEALS_REPROMPT)) | |
| .set_card(SimpleCard(f"Ofertas en {genre}", card)) | |
| .response | |
| ) | |
| class HelpIntentHandler(AbstractRequestHandler): | |
| def can_handle(self, handler_input: HandlerInput) -> bool: | |
| return is_intent_name("AMAZON.HelpIntent")(handler_input) | |
| def handle(self, handler_input: HandlerInput) -> Response: | |
| return ( | |
| handler_input.response_builder | |
| .speak(ssml(st.HELP_SPEECH)) | |
| .ask(ssml(st.HELP_SPEECH)) | |
| .set_card(SimpleCard("Ayuda", "Ejemplos: ofertas del día, detalles de un juego")) | |
| .response | |
| ) | |
| class CancelOrStopIntentHandler(AbstractRequestHandler): | |
| def can_handle(self, handler_input: HandlerInput) -> bool: | |
| return ( | |
| is_intent_name("AMAZON.StopIntent")(handler_input) | |
| or is_intent_name("AMAZON.CancelIntent")(handler_input) | |
| ) | |
| def handle(self, handler_input: HandlerInput) -> Response: | |
| return ( | |
| handler_input.response_builder | |
| .speak(ssml(st.GOODBYE_SPEECH)) | |
| .set_card(SimpleCard("Steam Ofertas", "¡Hasta pronto!")) | |
| .set_should_end_session(True) | |
| .response | |
| ) | |
| class FallbackIntentHandler(AbstractRequestHandler): | |
| def can_handle(self, handler_input: HandlerInput) -> bool: | |
| return is_intent_name("AMAZON.FallbackIntent")(handler_input) | |
| def handle(self, handler_input: HandlerInput) -> Response: | |
| return ( | |
| handler_input.response_builder | |
| .speak(ssml(st.FALLBACK_SPEECH)) | |
| .ask(ssml(st.GENERIC_REPROMPT)) | |
| .response | |
| ) | |
| class AnyIntentCatchAllHandler(AbstractRequestHandler): | |
| """Captura cualquier IntentRequest no manejado para asegurar respuesta.""" | |
| def can_handle(self, handler_input: HandlerInput) -> bool: | |
| return is_request_type("IntentRequest")(handler_input) | |
| def handle(self, handler_input: HandlerInput) -> Response: | |
| return ( | |
| handler_input.response_builder | |
| .speak(ssml(st.FALLBACK_SPEECH)) | |
| .ask(ssml(st.GENERIC_REPROMPT)) | |
| .response | |
| ) | |
| class SessionEndedRequestHandler(AbstractRequestHandler): | |
| def can_handle(self, handler_input: HandlerInput) -> bool: | |
| return is_request_type("SessionEndedRequest")(handler_input) | |
| def handle(self, handler_input: HandlerInput) -> Response: | |
| reason = getattr(handler_input.request_envelope.request, "reason", "desconocida") | |
| logger.info(f"Sesión terminada: {reason}") | |
| return handler_input.response_builder.response | |
| class CatchAllExceptionHandler(AbstractExceptionHandler): | |
| def can_handle(self, handler_input: HandlerInput, exception: Exception) -> bool: | |
| return True | |
| def handle(self, handler_input: HandlerInput, exception: Exception) -> Response: | |
| logger.error(f"Error encontrado: {exception}", exc_info=True) | |
| return ( | |
| handler_input.response_builder | |
| .speak(ssml("Lo siento, tuve problemas para procesar tu solicitud. Intenta de nuevo.")) | |
| .ask(ssml("¿Qué te gustaría hacer?")) | |
| .response | |
| ) | |
| class IntentReflectorHandler(AbstractRequestHandler): | |
| """Refleja cualquier IntentRequest no manejado previamente (para depurar).""" | |
| def can_handle(self, handler_input: HandlerInput) -> bool: | |
| return is_request_type("IntentRequest")(handler_input) | |
| def handle(self, handler_input: HandlerInput) -> Response: | |
| try: | |
| intent = handler_input.request_envelope.request.intent # type: ignore[attr-defined] | |
| intent_name = getattr(intent, "name", "IntentDesconocido") | |
| slots = getattr(intent, "slots", None) or {} | |
| pairs = [] | |
| log: Dict[str, str] = {} | |
| for name, slot in slots.items(): | |
| value = getattr(slot, "value", None) | |
| log[name] = value | |
| if value: | |
| pairs.append(f"{name}: {value}") | |
| slots_spoken = ". ".join(pairs) if pairs else "sin slots" | |
| logger.info(f"IntentReflector -> intent={intent_name}, slots={log}") | |
| except Exception: | |
| intent_name = "IntentDesconocido" | |
| slots_spoken = "sin datos" | |
| return ( | |
| handler_input.response_builder | |
| .speak(ssml(f"Recibí el intent {intent_name}, {slots_spoken}. Puedes intentarlo de otra forma.")) | |
| .ask(ssml("¿Qué te gustaría hacer?")) | |
| .set_card(SimpleCard("Intent recibido", f"{intent_name}\n{slots_spoken}")) | |
| .response | |
| ) | |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| import logging | |
| from typing import Optional | |
| from ask_sdk_core.dispatch_components import AbstractRequestInterceptor, AbstractResponseInterceptor | |
| from ask_sdk_core.handler_input import HandlerInput | |
| from ask_sdk_model import Response | |
| logger = logging.getLogger(__name__) | |
| class LogRequestInterceptor(AbstractRequestInterceptor): | |
| """Intercepción global para loguear el tipo de request e intent.""" | |
| def process(self, handler_input: HandlerInput) -> None: | |
| try: | |
| req = handler_input.request_envelope.request | |
| rtype = getattr(req, "object_type", type(req).__name__) | |
| iname = getattr(getattr(req, "intent", None), "name", None) | |
| logger.info(f"[REQ] type={rtype} intent={iname}") | |
| except Exception: | |
| pass | |
| class LogResponseInterceptor(AbstractResponseInterceptor): | |
| """Intercepción global para loguear si sale voz y fin de sesión.""" | |
| def process(self, handler_input: HandlerInput, response: Optional[Response]) -> None: | |
| try: | |
| if not response: | |
| logger.info("[RESP] None") | |
| return | |
| os = getattr(response, "output_speech", None) | |
| has_ssml = bool(getattr(os, "ssml", None)) | |
| has_text = bool(getattr(os, "text", None)) | |
| end = getattr(response, "should_end_session", None) | |
| logger.info(f"[RESP] ssml={has_ssml} text={has_text} end={end}") | |
| except Exception: | |
| pass | |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| from app.builder import lambda_handler |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| ask-sdk-core==1.19.0 | |
| ask-sdk-model==1.61.0 | |
| requests==2.32.3 |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| from typing import Dict, List, Tuple | |
| from datetime import datetime, timezone | |
| from utils import format_mxn | |
| # --- Mensajes base --- | |
| WELCOME_SPEECH = ( | |
| "¡Bienvenido a Steam Ofertas! " | |
| "Por ejemplo, puedes decir: 'dime las ofertas del día de hoy' " | |
| "o 'dime más sobre el juego Hades'. ¿Qué deseas hacer?" | |
| ) | |
| WELCOME_REPROMPT = ( | |
| "¿Qué te gustaría saber? Pide las ofertas del día o los detalles de un juego." | |
| ) | |
| HELP_SPEECH = ( | |
| "Puedes pedirme las ofertas del día diciendo 'dime las ofertas de hoy', " | |
| "o los detalles de un juego diciendo 'dime más sobre el juego Hades'. " | |
| "¿Qué deseas hacer?" | |
| ) | |
| FALLBACK_SPEECH = ( | |
| "No entendí eso. Puedes pedirme las ofertas del día " | |
| "o preguntar por un juego, por ejemplo: 'detalles del juego Hades'." | |
| ) | |
| GENERIC_REPROMPT = "¿Qué deseas hacer?" | |
| DEALS_REPROMPT = "¿Te gustaría saber más sobre algún juego?" | |
| GOODBYE_SPEECH = "¡Hasta luego! Vuelve pronto para más ofertas de Steam." | |
| # --- Constructores de textos --- | |
| def daily_deals_speech(deals: List[Dict]) -> Tuple[str, str]: | |
| """Genera el speech y el contenido de tarjeta para ofertas del día.""" | |
| top = deals[:3] | |
| parts = ["Estas son las ofertas destacadas de hoy: "] | |
| for i, d in enumerate(top, 1): | |
| price = format_mxn(d.get("final_price", 0)) | |
| parts.append( | |
| f"{d['name']} con {d['discount']} por ciento de descuento, a {price}" | |
| ) | |
| if i < len(top): | |
| parts.append(", ") | |
| speak_output = "".join(parts) + ". ¿Quieres saber más sobre algún juego?" | |
| card_content_lines = [] | |
| for d in top: | |
| fp = format_mxn(d.get("final_price", 0)) | |
| op = format_mxn(d.get("original_price", 0)) | |
| card_content_lines.append(f"• {d['name']}: {d['discount']}% off — {fp} (antes {op})") | |
| card_content = "\n".join(card_content_lines) | |
| return speak_output, card_content | |
| def daily_deals_fallback_speech() -> Tuple[str, str]: | |
| """Fallback de ejemplo si la API no devuelve datos.""" | |
| speak_output = ( | |
| "Estas son algunas ofertas destacadas: " | |
| "Hades con 50 por ciento de descuento, " | |
| "Stardew Valley con 30 por ciento y " | |
| "Hollow Knight con 40 por ciento. " | |
| "¿Quieres detalles de alguno?" | |
| ) | |
| card_content = "• Hades: 50% off\n• Stardew Valley: 30% off\n• Hollow Knight: 40% off" | |
| return speak_output, card_content | |
| def game_details_from_data(game_query: str, info: Dict) -> Tuple[str, str]: | |
| """Construye el speech para detalles de un juego a partir de datos de API.""" | |
| name = info.get("name") or game_query | |
| discount = int(info.get("discount") or 0) | |
| final_p = info.get("final_price", 0) | |
| original_p = info.get("original_price", 0) | |
| exp = info.get("discount_expiration") | |
| expires_text = None | |
| try: | |
| if exp: | |
| # exp es epoch seconds UTC | |
| now = datetime.now(timezone.utc).timestamp() | |
| remaining = int(exp - now) | |
| if remaining > 0: | |
| days = remaining // 86400 | |
| hours = (remaining % 86400) // 3600 | |
| if days >= 1: | |
| expires_text = f"La oferta termina en {days} día{'s' if days!=1 else ''}." | |
| elif hours >= 1: | |
| expires_text = f"La oferta termina en {hours} hora{'s' if hours!=1 else ''}." | |
| else: | |
| expires_text = "La oferta termina en menos de una hora." | |
| except Exception: | |
| expires_text = None | |
| if final_p and original_p: | |
| speak_output = ( | |
| f"{name} tiene {discount} por ciento de descuento. " | |
| f"Precio final: {format_mxn(final_p)}, antes {format_mxn(original_p)}." | |
| ) | |
| card_content = ( | |
| f"Juego: {name}\nDescuento: {discount}%\n" | |
| f"Precio: {format_mxn(final_p)} (antes {format_mxn(original_p)})" | |
| ) | |
| elif final_p: | |
| speak_output = f"Actualmente {name} cuesta {format_mxn(final_p)}." | |
| card_content = f"Juego: {name}\nPrecio: {format_mxn(final_p)}" | |
| else: | |
| speak_output = f"{name} parece estar gratis o sin precio disponible ahora mismo." | |
| card_content = f"Juego: {name}\nGratis o sin precio disponible" | |
| if expires_text: | |
| speak_output = f"{speak_output} {expires_text}" | |
| card_content = f"{card_content}\n{expires_text}" | |
| return speak_output, card_content | |
| def game_details_speech(game: str) -> Tuple[str, str]: | |
| """Texto genérico por si no hay datos de API disponibles.""" | |
| speak_output = ( | |
| f"Buscando información sobre {game}. " | |
| f"Te recomiendo verificar en Steam para ver el precio actual." | |
| ) | |
| card_content = f"Juego: {game}\nVerifica Steam para detalles actualizados." | |
| return speak_output, card_content | |
| def game_not_understood_speech() -> Tuple[str, str]: | |
| speak_output = ( | |
| "No escuché el nombre del juego correctamente. " | |
| "Intenta decir: 'dime más sobre el juego Hades'." | |
| ) | |
| card_content = "Nombre de juego no reconocido." | |
| return speak_output, card_content |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| from typing import Optional | |
| import html | |
| from ask_sdk_core.handler_input import HandlerInput | |
| def get_slot_value(handler_input: HandlerInput, slot_name: str) -> Optional[str]: | |
| """Obtiene el valor de un slot por nombre de forma segura.""" | |
| try: | |
| intent = handler_input.request_envelope.request.intent | |
| if not intent or not intent.slots: | |
| return None | |
| slot = intent.slots.get(slot_name) | |
| value = getattr(slot, "value", None) | |
| return value or None | |
| except Exception: | |
| return None | |
| def get_number_slot_value(handler_input: HandlerInput, slot_name: str) -> Optional[float]: | |
| """Devuelve el valor numérico de un slot, si es convertible a float.""" | |
| try: | |
| raw = get_slot_value(handler_input, slot_name) | |
| if raw is None: | |
| return None | |
| raw = raw.replace(",", ".") | |
| return float(raw) | |
| except Exception: | |
| return None | |
| class ResponseBuilder: | |
| """Helper para construir respuestas consistentes (reservado/ejemplo).""" | |
| @staticmethod | |
| def build_speech(text: str, reprompt: Optional[str] = None, | |
| card_title: Optional[str] = None): | |
| return {"speech": text, "reprompt": reprompt or text, "card_title": card_title} | |
| def ssml(text: str) -> str: | |
| """Envuelve un texto en <speak> escapando caracteres especiales. | |
| Acepta texto plano y devuelve SSML válido para usar en .speak()/.ask(). | |
| """ | |
| safe = html.escape(text or "", quote=False).replace("\n", " ") | |
| return f"<speak>{safe}</speak>" | |
| def format_mxn(amount: float) -> str: | |
| """Formatea números como pesos mexicanos para voz. | |
| Redondea a entero para que suene natural: "149 pesos". | |
| """ | |
| try: | |
| n = int(round(float(amount))) | |
| except Exception: | |
| n = 0 | |
| return f"{n:,} pesos" | |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment