Created
November 2, 2025 15:05
-
-
Save iDavidMorales/f5740638a3ad2451c5ea50e3fdc8c9d9 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
| const Alexa = require('ask-sdk-core'); | |
| const axios = require('axios'); | |
| // --- CONFIGURACIÓN DE LA URL --- | |
| const MUSIC_DATA_URL = 'https://routicket.com/plugins/play/musica/track/alexa.php'; | |
| // --- INTERCEPTORES --- | |
| // Interceptor para cargar y guardar la lista de música una vez por sesión (o al primer uso) | |
| const LoadMusicDataInterceptor = { | |
| async process(handlerInput) { | |
| const { attributesManager } = handlerInput; | |
| const sessionAttributes = attributesManager.getSessionAttributes(); | |
| // Solo carga si no está ya en la sesión o si se ha invalidado. | |
| if (!sessionAttributes.audioData || sessionAttributes.audioData.length === 0) { | |
| console.log("Interceptor: Cargando lista de música desde Routicket..."); | |
| try { | |
| const response = await axios.get(MUSIC_DATA_URL); | |
| if (Array.isArray(response.data) && response.data.length > 0 && | |
| response.data.every(item => item.url && item.title && item.token)) { | |
| sessionAttributes.audioData = response.data; | |
| console.log(`ÉXITO: Se cargaron ${sessionAttributes.audioData.length} canciones en sessionAttributes.`); | |
| } else { | |
| throw new Error('Formato de datos de música inválido o lista vacía desde Routicket.'); | |
| } | |
| } catch (error) { | |
| console.error(`ERROR DEL INTERCEPTOR AL CARGAR MÚSICA desde ${MUSIC_DATA_URL}:`, error.message); | |
| sessionAttributes.audioData = []; // Asegura que esté vacío si falla | |
| } | |
| } | |
| } | |
| }; | |
| // Interceptor para guardar el estado de la reproducción al detenerse | |
| const SaveStateInterceptor = { | |
| process(handlerInput) { | |
| const { requestEnvelope, attributesManager } = handlerInput; | |
| const sessionAttributes = attributesManager.getSessionAttributes(); | |
| if (requestEnvelope.request.type === 'AudioPlayer.PlaybackStopped' && sessionAttributes.token) { | |
| sessionAttributes.playbackOffset = requestEnvelope.request.offsetInMilliseconds; | |
| console.log(`SaveStateInterceptor: Reproducción detenida en offset ${sessionAttributes.playbackOffset / 1000} segundos.`); | |
| } | |
| } | |
| }; | |
| // --- FUNCIÓN CENTRAL DE REPRODUCCIÓN --- | |
| function playSong(handlerInput, songIndex, offset = 0, enqueue = false) { | |
| const { attributesManager, responseBuilder } = handlerInput; | |
| const sessionAttributes = attributesManager.getSessionAttributes(); | |
| const audioData = sessionAttributes.audioData; // Usamos la lista de la sesión | |
| if (!audioData || audioData.length === 0) { | |
| console.warn("ADVERTENCIA: Intentando reproducir sin datos de audio cargados en sesión."); | |
| responseBuilder.speak('Lo siento, la lista de canciones de Routicket no está disponible en este momento. Por favor, intenta de nuevo más tarde.'); | |
| return; | |
| } | |
| let newIndex; | |
| // Asegurarse de que el índice esté dentro de los límites y envuelva si es necesario. | |
| if (typeof songIndex !== 'number' || isNaN(songIndex) || songIndex < 0 || songIndex >= audioData.length) { | |
| // Si el índice es inválido (ej. al inicio, o fuera de rango por "siguiente/anterior" en los límites), | |
| // o si es la primera vez que se reproduce, elige una aleatoria. | |
| newIndex = Math.floor(Math.random() * audioData.length); | |
| console.log(`Índice de canción inválido o no especificado (${songIndex}). Reproduciendo una canción aleatoria en el índice ${newIndex}.`); | |
| } else { | |
| // Si el índice es válido, lo usamos. | |
| newIndex = songIndex; | |
| console.log(`Reproduciendo canción en el índice especificado: ${newIndex}.`); | |
| } | |
| const songToPlay = audioData[newIndex]; | |
| // Validación de la URL de la canción | |
| if (!songToPlay || !songToPlay.url || typeof songToPlay.url !== 'string' || !songToPlay.url.startsWith('https://')) { | |
| console.error(`ERROR: La URL de la canción en el índice ${newIndex} es inválida o no existe: ${JSON.stringify(songToPlay)}`); | |
| const speakOutput = `Lo siento, no puedo reproducir "${songToPlay ? songToPlay.title : 'esta canción'}". Intentando con otra.`; | |
| responseBuilder.speak(speakOutput); | |
| // Intenta con la siguiente canción si la actual es inválida. Evita bucles infinitos en listas pequeñas. | |
| if (audioData.length > 1) { | |
| playSong(handlerInput, (newIndex + 1) % audioData.length); // Recursión a la siguiente | |
| } else { | |
| // Si solo hay una canción y es inválida, no hay más que hacer. | |
| console.log("No hay más canciones válidas para intentar."); | |
| } | |
| return; | |
| } | |
| // Actualiza los atributos de sesión con la canción que se va a reproducir | |
| sessionAttributes.currentIndex = newIndex; | |
| sessionAttributes.playbackOffset = offset; | |
| sessionAttributes.token = songToPlay.token; | |
| console.log(`INFO: Preparando para reproducir: Índice ${newIndex}, Título: ${songToPlay.title}, URL: ${songToPlay.url}, Token: ${songToPlay.token}, Offset: ${offset}ms, Enqueue: ${enqueue}`); | |
| // Solo hablar si no estamos encolando una canción (eso se hace en PlaybackNearlyFinished) | |
| if (!enqueue) { | |
| responseBuilder.speak(`Reproduciendo ${songToPlay.title}`); | |
| } | |
| responseBuilder.addAudioPlayerPlayDirective( | |
| enqueue ? 'ENQUEUE' : 'REPLACE_ALL', | |
| songToPlay.url, | |
| songToPlay.token, | |
| offset, | |
| enqueue ? sessionAttributes.token : null // Previous token es necesario para ENQUEUE | |
| ); | |
| } | |
| // --- HANDLERS DE INICIO Y REPRODUCCIÓN --- | |
| const LaunchRequestHandler = { | |
| canHandle(handlerInput) { | |
| return Alexa.getRequestType(handlerInput.requestEnvelope) === 'LaunchRequest'; | |
| }, | |
| handle(handlerInput) { | |
| console.log("Evento: LaunchRequest"); | |
| const sessionAttributes = handlerInput.attributesManager.getSessionAttributes(); | |
| const audioData = sessionAttributes.audioData; | |
| if (!audioData || audioData.length === 0) { | |
| const speakOutput = "Bienvenido a Routicket Play. Lo siento, no pude cargar la música en este momento. Por favor, intenta de nuevo más tarde."; | |
| return handlerInput.responseBuilder | |
| .speak(speakOutput) | |
| .getResponse(); | |
| } | |
| // Reproducir una canción aleatoria al inicio | |
| playSong(handlerInput, Math.floor(Math.random() * audioData.length)); | |
| return handlerInput.responseBuilder | |
| .withShouldEndSession(false) | |
| .getResponse(); | |
| } | |
| }; | |
| const PlayMusicIntentHandler = { | |
| canHandle(handlerInput) { | |
| return Alexa.getRequestType(handlerInput.requestEnvelope) === 'IntentRequest' | |
| && Alexa.getIntentName(handlerInput.requestEnvelope) === 'PlayMusicIntent'; | |
| }, | |
| handle(handlerInput) { | |
| console.log("Evento: PlayMusicIntent"); | |
| const sessionAttributes = handlerInput.attributesManager.getSessionAttributes(); | |
| const audioData = sessionAttributes.audioData; | |
| if (!audioData || audioData.length === 0) { | |
| const speakOutput = "Lo siento, la lista de canciones de Routicket no está disponible en este momento. Por favor, intenta de nuevo más tarde."; | |
| return handlerInput.responseBuilder | |
| .speak(speakOutput) | |
| .getResponse(); | |
| } | |
| // Reproducir una canción aleatoria al pedir "reproducir música" | |
| playSong(handlerInput, Math.floor(Math.random() * audioData.length)); | |
| return handlerInput.responseBuilder | |
| .withShouldEndSession(false) | |
| .getResponse(); | |
| } | |
| }; | |
| const PlaySongByTitleIntentHandler = { | |
| canHandle(handlerInput) { | |
| return Alexa.getRequestType(handlerInput.requestEnvelope) === 'IntentRequest' | |
| && Alexa.getIntentName(handlerInput.requestEnvelope) === 'PlaySongByTitleIntent'; | |
| }, | |
| handle(handlerInput) { | |
| console.log("Evento: PlaySongByTitleIntent"); | |
| const { requestEnvelope, responseBuilder, attributesManager } = handlerInput; | |
| const songTitleSlot = Alexa.getSlotValue(requestEnvelope, 'songTitle'); | |
| const sessionAttributes = attributesManager.getSessionAttributes(); | |
| const audioData = sessionAttributes.audioData; | |
| if (!audioData || audioData.length === 0) { | |
| const speakOutput = "Lo siento, la lista de canciones de Routicket no está disponible en este momento para buscar por título. Por favor, intenta de nuevo más tarde."; | |
| return responseBuilder.speak(speakOutput).getResponse(); | |
| } | |
| if (songTitleSlot) { | |
| const normalizedQuery = songTitleSlot.toLowerCase(); | |
| const foundIndex = audioData.findIndex(song => | |
| song.title.toLowerCase().includes(normalizedQuery) | |
| ); | |
| if (foundIndex !== -1) { | |
| playSong(handlerInput, foundIndex); | |
| return responseBuilder.withShouldEndSession(false).getResponse(); | |
| } else { | |
| const speakOutput = `Lo siento, no pude encontrar la canción "${songTitleSlot}" en la lista de Routicket. Reproduciendo algo al azar.`; | |
| playSong(handlerInput, Math.floor(Math.random() * audioData.length)); | |
| return responseBuilder.speak(speakOutput).reprompt("¿Te gustaría que reproduzca algo al azar?").withShouldEndSession(false).getResponse(); | |
| } | |
| } else { | |
| const speakOutput = "No entendí el nombre de la canción. Por favor, intenta de nuevo. ¿Qué canción te gustaría escuchar?"; | |
| return responseBuilder.speak(speakOutput).reprompt(speakOutput).withShouldEndSession(false).getResponse(); | |
| } | |
| } | |
| }; | |
| // --- HANDLERS DE CONTROL DE REPRODUCCIÓN (AMAZON.*) --- | |
| const AMAZON_NextIntentHandler = { | |
| canHandle(handlerInput) { | |
| return Alexa.getRequestType(handlerInput.requestEnvelope) === 'IntentRequest' | |
| && Alexa.getIntentName(handlerInput.requestEnvelope) === 'AMAZON.NextIntent'; | |
| }, | |
| handle(handlerInput) { | |
| console.log("Evento: AMAZON.NextIntent"); | |
| const sessionAttributes = handlerInput.attributesManager.getSessionAttributes(); | |
| const audioData = sessionAttributes.audioData; | |
| if (!audioData || audioData.length === 0) { | |
| const speakOutput = "Lo siento, no hay canciones para avanzar en la lista de Routicket. Por favor, intenta reproducir música primero."; | |
| return handlerInput.responseBuilder.speak(speakOutput).getResponse(); | |
| } | |
| const currentIndex = typeof sessionAttributes.currentIndex === 'number' ? sessionAttributes.currentIndex : 0; | |
| // Calcula el siguiente índice, envolviendo al principio si llega al final. | |
| const nextIndex = (currentIndex + 1) % audioData.length; | |
| playSong(handlerInput, nextIndex); | |
| return handlerInput.responseBuilder.withShouldEndSession(false).getResponse(); | |
| } | |
| }; | |
| const AMAZON_PreviousIntentHandler = { | |
| canHandle(handlerInput) { | |
| return Alexa.getRequestType(handlerInput.requestEnvelope) === 'IntentRequest' | |
| && Alexa.getIntentName(handlerInput.requestEnvelope) === 'AMAZON.PreviousIntent'; | |
| }, | |
| handle(handlerInput) { | |
| console.log("Evento: AMAZON.PreviousIntent"); | |
| const sessionAttributes = handlerInput.attributesManager.getSessionAttributes(); | |
| const audioData = sessionAttributes.audioData; | |
| if (!audioData || audioData.length === 0) { | |
| const speakOutput = "Lo siento, no hay canciones para retroceder en la lista de Routicket. Por favor, intenta reproducir música primero."; | |
| return handlerInput.responseBuilder.speak(speakOutput).getResponse(); | |
| } | |
| const currentIndex = typeof sessionAttributes.currentIndex === 'number' ? sessionAttributes.currentIndex : 0; | |
| // Calcula el índice anterior, envolviendo al final si llega al principio. | |
| const previousIndex = (currentIndex - 1 + audioData.length) % audioData.length; | |
| playSong(handlerInput, previousIndex); | |
| return handlerInput.responseBuilder.withShouldEndSession(false).getResponse(); | |
| } | |
| }; | |
| const AMAZON_PauseIntentHandler = { | |
| canHandle(handlerInput) { | |
| return Alexa.getRequestType(handlerInput.requestEnvelope) === 'IntentRequest' | |
| && Alexa.getIntentName(handlerInput.requestEnvelope) === 'AMAZON.PauseIntent'; | |
| }, | |
| handle(handlerInput) { | |
| console.log("Evento: AMAZON.PauseIntent"); | |
| return handlerInput.responseBuilder | |
| .addAudioPlayerStopDirective() | |
| .speak("Música pausada.") | |
| .getResponse(); | |
| } | |
| }; | |
| const AMAZON_ResumeIntentHandler = { | |
| canHandle(handlerInput) { | |
| return Alexa.getRequestType(handlerInput.requestEnvelope) === 'IntentRequest' | |
| && Alexa.getIntentName(handlerInput.requestEnvelope) === 'AMAZON.ResumeIntent'; | |
| }, | |
| handle(handlerInput) { | |
| console.log("Evento: AMAZON.ResumeIntent"); | |
| const sessionAttributes = handlerInput.attributesManager.getSessionAttributes(); | |
| const currentIndex = sessionAttributes.currentIndex; | |
| const offset = sessionAttributes.playbackOffset || 0; | |
| const token = sessionAttributes.token; | |
| const audioData = sessionAttributes.audioData; | |
| // Intentar reanudar la canción actual si hay datos suficientes | |
| if (audioData && audioData.length > 0 && typeof currentIndex === 'number' && token) { | |
| const songToResume = audioData[currentIndex]; | |
| if (songToResume && songToResume.url && typeof songToResume.url === 'string' && songToResume.url.startsWith('https://') && songToResume.token === token) { | |
| console.log(`Reanudando: Índice ${currentIndex}, Título: ${songToResume.title}, Offset: ${offset}ms`); | |
| handlerInput.responseBuilder | |
| .speak("Reanudando.") | |
| .addAudioPlayerPlayDirective( | |
| 'REPLACE_ALL', | |
| songToResume.url, | |
| token, | |
| offset, | |
| null | |
| ); | |
| } else { | |
| console.error(`ERROR: La canción para reanudar en el índice ${currentIndex} tiene URL/token inválido o no existe.`, songToResume); | |
| handlerInput.responseBuilder.speak("Lo siento, no pude reanudar esa canción. Reproduciendo algo nuevo."); | |
| playSong(handlerInput, Math.floor(Math.random() * audioData.length)); | |
| } | |
| } else { | |
| const speakOutput = "No hay música para reanudar o la lista no está disponible. Reproduciendo algo al azar."; | |
| handlerInput.responseBuilder.speak(speakOutput); | |
| if (audioData && audioData.length > 0) { | |
| playSong(handlerInput, Math.floor(Math.random() * audioData.length)); | |
| } | |
| } | |
| return handlerInput.responseBuilder | |
| .withShouldEndSession(false) | |
| .getResponse(); | |
| } | |
| }; | |
| // --- HANDLER PRINCIPAL PARA EVENTOS DEL AUDIOPLAYER --- | |
| const AudioPlayerEventHandler = { | |
| canHandle(handlerInput) { | |
| // Solo maneja si el audioData ya está cargado, de lo contrario el interceptor lo intentará. | |
| return Alexa.getRequestType(handlerInput.requestEnvelope).startsWith('AudioPlayer.'); | |
| }, | |
| handle(handlerInput) { // Ya no es async directamente, playSong lo es si tiene que hacer fetch | |
| const { requestEnvelope, attributesManager, responseBuilder } = handlerInput; | |
| const audioPlayerEventName = Alexa.getRequestType(requestEnvelope); | |
| const sessionAttributes = attributesManager.getSessionAttributes(); | |
| const audioData = sessionAttributes.audioData; // Obtener la lista de la sesión | |
| console.log(`Evento de AudioPlayer recibido: ${audioPlayerEventName}`); | |
| switch (audioPlayerEventName) { | |
| case 'AudioPlayer.PlaybackStarted': { | |
| console.log("Evento: PlaybackStarted. La reproducción ha iniciado."); | |
| // Aquí podrías guardar el token de la canción que acaba de empezar si necesitas una confirmación. | |
| break; | |
| } | |
| case 'AudioPlayer.PlaybackFinished': { | |
| console.log("Evento: PlaybackFinished. La canción anterior terminó por completo. (Siguiente ya encolada)"); | |
| // No necesitamos llamar a playSong aquí; PlaybackNearlyFinished ya la ha encolado. | |
| break; | |
| } | |
| case 'AudioPlayer.PlaybackStopped': { | |
| console.log("Evento: PlaybackStopped. La música se detuvo (por pausa o fin de sesión)."); | |
| // El SaveStateInterceptor ya maneja el guardado del offset. | |
| break; | |
| } | |
| case 'AudioPlayer.PlaybackNearlyFinished': { | |
| console.log("Evento: PlaybackNearlyFinished. Preparando la siguiente canción en la cola."); | |
| if (!audioData || audioData.length === 0) { | |
| console.warn("ADVERTENCIA: No hay datos de audio en sesión para encolar en PlaybackNearlyFinished."); | |
| responseBuilder.speak("Lo siento, no hay más canciones en la lista. Por favor, intenta de nuevo más tarde."); | |
| return responseBuilder.getResponse(); | |
| } | |
| const currentIndex = typeof sessionAttributes.currentIndex === 'number' ? sessionAttributes.currentIndex : 0; | |
| const nextIndex = (currentIndex + 1) % audioData.length; // Siguiente canción en la secuencia | |
| const nextSong = audioData[nextIndex]; | |
| if (nextSong && nextSong.url && typeof nextSong.url === 'string' && nextSong.url.startsWith('https://')) { | |
| console.log(`Agregando ${nextSong.title} (índice ${nextIndex}) a la cola y anunciando.`); | |
| // Anuncia la siguiente canción con su título si está disponible | |
| responseBuilder.speak(`Reproduciendo a continuación: ${nextSong.title}`); | |
| // Enqueue la siguiente canción. | |
| responseBuilder.addAudioPlayerPlayDirective( | |
| 'ENQUEUE', | |
| nextSong.url, | |
| nextSong.token, | |
| 0, // Offset 0 para la canción encolada | |
| sessionAttributes.token // Token de la canción que está a punto de terminar | |
| ); | |
| sessionAttributes.currentIndex = nextIndex; // Actualiza el índice en la sesión | |
| } else { | |
| console.error(`ERROR: La siguiente canción a encolar en el índice ${nextIndex} tiene URL inválida o no existe. No se encolará.`); | |
| responseBuilder.speak("Lo siento, hubo un problema con la siguiente canción. Intentando con una nueva."); | |
| // Si no puede encolar la siguiente secuencial, intenta con una aleatoria. | |
| playSong(handlerInput, Math.floor(Math.random() * audioData.length)); | |
| } | |
| break; | |
| } | |
| case 'AudioPlayer.PlaybackFailed': { | |
| const error = requestEnvelope.request.error; | |
| console.error('ERROR de AudioPlayer.PlaybackFailed:', JSON.stringify(error)); | |
| const speakOutput = "Lo siento, hubo un problema al reproducir la música. Intentando con una nueva canción."; | |
| responseBuilder.speak(speakOutput); | |
| const audioData = sessionAttributes.audioData; | |
| if (audioData && audioData.length > 0) { | |
| playSong(handlerInput, Math.floor(Math.random() * audioData.length)); // Reproducir una aleatoria | |
| } | |
| // No necesitamos un .getResponse() aquí, playSong lo hará. | |
| break; | |
| } | |
| default: | |
| console.log(`Evento de AudioPlayer no manejado explícitamente: ${audioPlayerEventName}`); | |
| break; | |
| } | |
| return responseBuilder.getResponse(); | |
| } | |
| }; | |
| // --- HANDLERS ESTÁNDAR Y DE SOPORTE --- | |
| const HelpIntentHandler = { | |
| canHandle(handlerInput) { | |
| return Alexa.getRequestType(handlerInput.requestEnvelope) === 'IntentRequest' | |
| && Alexa.getIntentName(handlerInput.requestEnvelope) === 'AMAZON.HelpIntent'; | |
| }, | |
| handle(handlerInput) { | |
| const speakOutput = 'Puedes decir "reproduce música" para comenzar. También puedes decir "siguiente", "anterior", "pausa" o "reanuda". También puedo buscar canciones por título. ¿Qué te gustaría escuchar?'; | |
| return handlerInput.responseBuilder.speak(speakOutput).reprompt(speakOutput).getResponse(); | |
| } | |
| }; | |
| const CancelAndStopIntentHandler = { | |
| canHandle(handlerInput) { | |
| return Alexa.getRequestType(handlerInput.requestEnvelope) === 'IntentRequest' | |
| && (Alexa.getIntentName(handlerInput.requestEnvelope) === 'AMAZON.CancelIntent' | |
| || Alexa.getIntentName(handlerInput.requestEnvelope) === 'AMAZON.StopIntent'); | |
| }, | |
| handle(handlerInput) { | |
| console.log("Evento: Cancel o Stop. Finalizando sesión."); | |
| handlerInput.responseBuilder | |
| .speak('¡Adiós, David! Gracias por escuchar música en Routicket Play. ¡Que tengas un excelente día!') | |
| .addAudioPlayerClearQueueDirective('CLEAR_ALL'); | |
| return handlerInput.responseBuilder.getResponse(); | |
| } | |
| }; | |
| const SessionEndedRequestHandler = { | |
| canHandle(handlerInput) { | |
| return Alexa.getRequestType(handlerInput.requestEnvelope) === 'SessionEndedRequest'; | |
| }, | |
| handle(handlerInput) { | |
| const reason = handlerInput.requestEnvelope.request.reason; | |
| console.log(`Sesión terminada. Razón: ${reason}`); | |
| // Limpiar la cola del AudioPlayer cuando la sesión termina. | |
| handlerInput.responseBuilder.addAudioPlayerClearQueueDirective('CLEAR_ALL'); | |
| return handlerInput.responseBuilder.getResponse(); | |
| } | |
| }; | |
| const ErrorHandler = { | |
| canHandle() { | |
| return true; | |
| }, | |
| handle(handlerInput, error) { | |
| console.error(`~~~~ ERROR MANEJADO POR EL CATCH-ALL: ${error.stack}`); | |
| const speakOutput = 'Lo siento, tuve un problema técnico con Routicket Play. Por favor, intenta de nuevo más tarde.'; | |
| return handlerInput.responseBuilder.speak(speakOutput).reprompt(speakOutput).getResponse(); | |
| } | |
| }; | |
| // --- CONSTRUCTOR DE LA SKILL --- | |
| exports.handler = Alexa.SkillBuilders.custom() | |
| .addRequestHandlers( | |
| LaunchRequestHandler, | |
| PlayMusicIntentHandler, | |
| PlaySongByTitleIntentHandler, | |
| AMAZON_NextIntentHandler, | |
| AMAZON_PreviousIntentHandler, | |
| AMAZON_PauseIntentHandler, | |
| AMAZON_ResumeIntentHandler, | |
| CancelAndStopIntentHandler, | |
| AudioPlayerEventHandler, | |
| HelpIntentHandler, | |
| SessionEndedRequestHandler | |
| ) | |
| .addErrorHandlers(ErrorHandler) | |
| .addRequestInterceptors(LoadMusicDataInterceptor) // El interceptor carga la lista al inicio | |
| .addResponseInterceptors(SaveStateInterceptor) | |
| .withCustomUserAgent('routicket/music-skill/v1.5-david-sequential-play') | |
| .lambda(); |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment