Skip to content

Instantly share code, notes, and snippets.

@iDavidMorales
Created November 2, 2025 15:05
Show Gist options
  • Select an option

  • Save iDavidMorales/f5740638a3ad2451c5ea50e3fdc8c9d9 to your computer and use it in GitHub Desktop.

Select an option

Save iDavidMorales/f5740638a3ad2451c5ea50e3fdc8c9d9 to your computer and use it in GitHub Desktop.
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