Skip to content

Instantly share code, notes, and snippets.

@Dubu2500
Created November 8, 2025 02:36
Show Gist options
  • Select an option

  • Save Dubu2500/37031790e3202c492dc61ca8968fe942 to your computer and use it in GitHub Desktop.

Select an option

Save Dubu2500/37031790e3202c492dc61ca8968fe942 to your computer and use it in GitHub Desktop.
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
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()
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
)
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
from app.builder import lambda_handler
ask-sdk-core==1.19.0
ask-sdk-model==1.61.0
requests==2.32.3
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
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