32
Automatizando lectura de mensajes de Discord con Python y Alexa Skills
La automatización consiste en utilizar la tecnología para realizar tareas casi sin necesidad de realizar acciones humanas.
Un asistente inteligente es un software que realiza tareas y ofrece servicios a un individuo, ayuda en las tareas en sistemas automatizando y realizando acciones con la mínima interacción hombre-máquina. Una persona se comunica usando la voz o texto y el asistente lo procesa, identifica, interpreta, ejecuta y responde utilizando la voz o medios visuales.
Alexa es el servicio de voz ubicado en la nube de Amazon disponible en dispositivos Amazon y dispositivos con Alexa integrada, se encuentran dispositivos con solo altavoces como Echo, Echo Dot, dispositivos con pantalla cómo Echo Show, Fire Stick TV y aplicaciones en Android, Windows, y muchos otros dispositivos más incluido televisores smart.
Una Skill son las habilidades que posee Alexa para realizar tareas, similar a una aplicación en un smartphone es una Skill de Alexa, a través de las Skills se permite realizar ciertas tareas, interactuar con otros dispositivos, estas habilidades son realizadas por desarrolladores y compañías para permitir a Alexa integrarse con otros Sistemas y dispositivos.
Son proyectos de software o funciones que se alojan en alguna nube y se ejecutan según la demanda de los usuarios, cada vez que se invoca la habilidad específica.
Las rutinas son un conjunto de reglas y ejecuciones que permiten ejecutar diferentes tareas que se programan a través de la Aplicación de Alexa, enlazando características de los diferentes dispositivos, skills, tiempo de ejecución de tareas, todas ellas permiten crear rutinas, tan simple como encender una alarma sonora a una hora específica, controlar la temperatura de un ambiente utilizando un termómetro y un ventilador, o recordarte comprar un objeto al llegar a un lugar, escuchar las noticias de diferentes proveedores con solo decirle “Quiero escuchar noticias”.
Las rutinas más las Skills permiten sacar gran parte del potencial del asistente inteligente, a tal punto de influir en la vida misma y las acciones del dia a dia de cada persona, facilitando el trabajo, ayudando a recordar o informar, ayudando a mantenerse en salud óptima, divertirse y disfrutar de un momento de ocio.
La computación sin servidor o Serverless es un modelo en el cual los proveedores de cloud (AWS, Azure, GCP, etc.) son responsables de ejecutar un fragmento de código mediante la asignación dinámica de recursos. Cobrando solo por la cantidad de recursos utilizados para ejecutar el código. El código se envía al proveedor en la nube para ejecución en forma de una función, serverless puede ser denominado como “Funciones como servicio” o FaaS.
La computación sin servidor no significa que no hay un servidor en el cual se ejecuta sino que los proveedores se encargan del mantenimiento y solo se cobra por la ejecución de las funciones, tradicionalmente se suele pagar el mantenimiento de máquinas virtuales, plataformas como servicio aun cuando no se encuentran en uso por los clientes a través de solicitudes HTTP o Sockets y se encuentran en un estado Idle o de espera, con Serverless cuando no hay llamadas de ejecución, solicitudes HTTP o CRON, no hay recursos destinados a mantener activa la función en estado de espera, el costo se basa en la cantidad de recursos consumidos en cada ejecución por función.
Entre los más populares se encuentra:
- Amazon Web Services: AWS Lambda
- Microsoft Azure: Azure Functions
- Google Cloud: Cloud Functions
- Configurabilidad AWS Lambda, necesita configurar la asignación máxima de memoria entre 128 mb a 3 GB, se ejecuta bajo Amazon Linux. Azure Functions, posee un plan único para todas las funciones, 1.5 GB de memoria, es posible ejecutar en Windows o Linux
- Lenguajes AWS Lambda, permite JavaScript, Java, Python, C#, F#, PowerShell, Go y Ruby Azure Functions, permite JavaScript, Java, Python, C#, F#, y PowerShell, TypeScript
- Costo El precio de ambos es de aproximada 0.20 USD por millón de solicitudes y 16 dólares por millón de GB/s AWS Lambda cobra por la capacidad total de memoria aprovisionada. Azure Functions mide el consumo de memoria promedio real de las ejecuciones.
Crear el servidor de Discord (Si no se lo tiene)
Crear el canal de texto de noticias a utilizar, si no se tiene, puede ser público o privado.
Para el acceso al canal se tiene que obtener su ID del canal el cual se obtiene desde el Discord, este dato será nuestro DISCORD_CHANNEL_ID con el que accederemos a un canal en específico para noticias.
En caso de que no aparezca la opción para “Copiar ID” se debe activar desde Ajustes del Usuario > Avanzado > Modo Desarrollador
Se necesita crear una aplicación de discord para conectarse con el servidor, desde Discord Developer , en la cual crearemos una aplicación.
Una vez creada la aplicación debemos generar un bot dentro la aplicación el cual tendrá la capacidad de conectarse a los canales de Discord y obtener los mensajes.
Al generar el bot podemos obtener su Token de acceso con el cual podremos acceder mediante el bot, el cual lo utilizaremos más adelante.
Para agregar el bot de prueba a su servidor de Discord puede utilizar el siguiente enlace modificando los parámetros de acuerdo a los permisos que requiera.
APPLICATION_ID lo podremos obtener de la información general de la Aplicación, reemplazamos en la URL de autorización.
PERMISSION_INTEGER es el número referencial a los permisos que tendrá el bot usamos el 66560 para la lectura de mensajes.
Para más información de los alcances existentes puede visitar: https://discord.com/developers/docs/topics/oauth2#shared-resources-oauth2-scopes
Una vez agregado el bot al Servidor ya podemos realizar la lectura de los mensajes, en caso de que se encuentre en un canal privado primero se debe agregar al bot al canal para poder acceder a los mensajes.
Consumo del API de Discord para obtención de los mensajes de un canal
Teniendo ya el bot configurado podemos realizar la lectura de los mensajes utilizando solicitudes HTTP para lo cual podemos realizar desde python de la siguiente manera.
import requests
url = "https://discordapp.com/api/channels/DISCORD_CHANNEL_ID/messages?limit=10"
payload={}
headers = {'Authorization': 'Bot BOT_TOKEN'}
response = requests.request("GET", url, headers=headers, data=payload)
print(response.text)
Ingresamos con nuestra cuenta a https://developer.amazon.com/alexa/console/ask desde el cual procederemos a crear nuestra Skill
Al crear nuestra Skill tenemos que configurar su nombre, el idioma primario para el que utilizaremos el Español, el modelo para el Skill será Custom ya que será proporcionado por nosotros, y la ubicación de nuestro backend que también la proporcionaremos nosotros mismos.
Para la demostración utilizaremos el Template inicial el cual contiene funcionalidades básicas
Una vez terminado de construir ya tenemos la Skill inicial en la cual nosotros podemos realizar la configuración de los Intents, Samples y Slots para que Alexa pueda entender nuestra Skill y realizar un correcto funcionamiento.
Lo primero que debemos revisar es el nombre de invocación de nuestra Skill, es el conjunto de palabras que permite a Alexa identificar y ejecutar nuestra Skill, ingresando a Skill Invocation Name cambiamos el nombre para la invocación, y guardamos nuestro modelo.
Los Intents representan a las acciones que cumplen con la solicitud hablada de un usuario, para lo cual crearemos nuestro Intent desde el menú de Intents y nuestro nuevo intent será “NoticiasIntent”.
De manera integrada en cada Skill tenemos 4 Intents que son para Cancelar, Ayuda, Detener ejecución y navegar entre nuestra Skill.
Posterior a crear nuestro intent debemos adicionar los Samples que representan la forma en la que los usuarios llaman a nuestro intent desde la Skill, y guardamos nuestro modelo
Los Slots son argumentos que nos permiten obtener parámetros del usuario que se extraen de los Intent y se envían como datos adicionales. En nuestro caso utilizaremos un slot para obtener un número y obtener un límite de mensajes de acuerdo a necesidad del usuario.
Una vez adicionado el Slot podemos agregar Samples que contengan nuestro Slot y tener llamadas con cantidades específicas de mensajes, quedándonos de la siguiente manera nuestro Intent con sus Samples y Slot!, y guardamos nuestro modelo Final
Hasta este punto ya tenemos la estructura que tendrá nuestra Skill y cómo será invocado mediante voz, ahora tenemos que construir nuestro Backend, una vez procesado y analizado la comunicación con el usuario se debe comunicar al Backend de la Skill que la tendremos alojada en Azure Functions
Para crear el Backend primero abriremos VS Code e instalaremos la extensión de Azure Functions, esto para crear y publicar más fácilmente nuestras funciones https://marketplace.visualstudio.com/items?itemName=ms-azuretools.vscode-azurefunctions
Desde la pestaña de Azure se crea un nuevo Proyecto en la carpeta que contendrá nuestras Funciones.
Seleccionamos el Lenguaje “Python”
El entorno virtual a utilizar, se puede realizar un skip si no se utilizara entorno virtual, pero es recomendado siempre utilizar un entorno virtual.
Seleccionamos el tipo de Template de nuestro proyecto, al ser un Backend de la Skill tiene el formato de Solicitud HTTP Trigger
Como paso siguiente le proporcionamos el nombre a una función, en nuestro caso será “PotatoNoticiasTrigger”
Para este ejemplo utilizaremos un nivel de autorización anónimo
Por último lo abrimos en el mismo Workspace o en una nueva ventana
Con este último paso se realiza el generado del proyecto y tendremos una estructura similar de la siguiente forma:
.
├── .funcignore
├── .gitignore
├── host.json
├── local.settings.json
├── PotatoNoticiasTrigger
│ ├── function.json
│ ├── __init__.py
│ └── sample.dat
├── requirements.txt
└── .vscode
├── extensions.json
├── launch.json
├── settings.json
└── tasks.json
Para probar podemos ejecutar desde el proyecto la función
func host start
Al momento de iniciar se levanta la instancia de Azure Functions Core Tools, puede suceder una excepción
Value cannot be null. Parameter name: Provider error on func start
Puede ser causada por la versión de la herramienta por lo cual una forma es verificar la versión en el archivo host.json
Para el proyecto actual se utiliza la version "[3.3.0, 4.0.0)",
"extensionBundle": {
"id": "Microsoft.Azure.Functions.ExtensionBundle",
"version": "[3.3.0, 4.0.0)"
}
Adicionamos a las librerías de requerimientos los siguientes para el diseño de la Skill y consumo de solicitudes
requirements.txt
requests
ask-sdk-core
ask-sdk-webservice-support
Instalaremos las librerías utilizando
pip install -r requirements.txt
Ahora podemos implementar la lógica de nuestra Skill sobre la función. Para lo cual primero empezaremos por añadir un archivo de configuración, para obtener en formato de variables de entorno los Keys e ID’s para proteger esa información de publicarla libremente.
config.py
import os
ALEXA_SKILL_ID = os.getenv("ALEXA_SKILL_ID", "")
DISCORD_CHANNEL_ID = os.getenv("DISCORD_CHANNEL_ID", "")
DISCORD_BOT_TOKEN = os.getenv("DISCORD_BOT_TOKEN", "")
Estos 3 valores son los que usamos desde los distintos lugares para acceder a nuestros demás servicios.
ALEXA_SKILL_ID lo podemos obtener desde la consola de Alexa https://developer.amazon.com/alexa/console/ask en la cual podemos copiar nuestro Skill ID
DISCORD_CHANNEL_ID ya lo habíamos obtenido anteriormente de la creación del canal
DISCORD_BOT_TOKEN al igual lo obtuvimos con anterioridad al crear el BOT de Discord
Ahora crearemos el método en python que nos permitirá obtener los “N” últimos mensajes de discord
discord.py
from typing import List
import requests
import json
from PotatoNoticiasTrigger.config import DISCORD_CHANNEL_ID, DISCORD_BOT_TOKEN
def get_n_messages(size: int = 5):
# type: (int) -> List
payload = {}
url = "https://discordapp.com/api/channels/{0}/messages?limit={1}".format(
DISCORD_CHANNEL_ID, size)
headers = {
'Authorization': 'Bot {0}'.format(DISCORD_BOT_TOKEN)
}
response = requests.request("GET", url, headers=headers, data=payload)
if (response.status_code == 200):
data = response.json()
return data
else:
return None
Ahora debemos implementar las funciones básicas de la Skill e incluir todos los Intents, el que vamos a implementar “NoticiasIntent” como los por defecto como de excepciones para poder responder a posibles fallas de ejecución.
Los Intents son manejados en formato de clase, en el cual definimos de tipo AbstractRequestHandler, un método “can_handle” con el nombre del Intent a manejar en esa clase, y un método “handle” en el cual se realiza la ejecución de la acción para ser devuelta a Alexa a través de la Skill ya sea en voz o en texto visual.
default_intents.py
class LaunchRequestHandler(AbstractRequestHandler):
def can_handle(self, handler_input):
return is_request_type("LaunchRequest")(handler_input)
def handle(self, handler_input):
print("Ejecucion de la Skill.")
speech_text = "Bienvenido a Potato Noticias, puedes decir últimas noticias"
handler_input.response_builder.speak(speech_text).set_card(
SimpleCard("Potato Noticias", speech_text)).set_should_end_session(False)
return handler_input.response_builder.response
default_intents.py
class HelpIntentHandler(AbstractRequestHandler):
def can_handle(self, handler_input):
# type: (HandlerInput) -> bool
return is_intent_name("AMAZON.HelpIntent")(handler_input)
def handle(self, handler_input):
# type: (HandlerInput) -> Response
print("Ejecucion Intent Ayuda")
speech_text = "Puedes decir últimas noticias, últimos 2 mensajes"
handler_input.response_builder.speak(speech_text).ask(speech_text).set_card(
SimpleCard("Potato Noticias - Ayuda", speech_text))
return handler_input.response_builder.response
default_intents.py
class CancelAndStopIntentHandler(AbstractRequestHandler):
def can_handle(self, handler_input):
# type: (HandlerInput) -> bool
return is_intent_name("AMAZON.CancelIntent")(handler_input) or is_intent_name("AMAZON.StopIntent")(handler_input)
def handle(self, handler_input):
# type: (HandlerInput) -> Response
print("Ejecución Intent Cancel o Stop")
speech_text = "Hasta luego Atentamente Potato Noticias!"
handler_input.response_builder.speak(speech_text).set_card(
SimpleCard("Potato Noticias", speech_text)).set_should_end_session(True)
return handler_input.response_builder.response
default_intents.py
class SessionEndedRequestHandler(AbstractRequestHandler):
def can_handle(self, handler_input):
# type: (HandlerInput) -> bool
return is_request_type("SessionEndedRequest")(handler_input)
def handle(self, handler_input):
# type: (HandlerInput) -> Response
# any cleanup logic goes here
return handler_input.response_builder.response
default_intents.py
class AllExceptionHandler(AbstractExceptionHandler):
def can_handle(self, handler_input, exception):
# type: (HandlerInput, Exception) -> bool
return True
def handle(self, handler_input, exception):
# type: (HandlerInput, Exception) -> Response
print(exception)
speech = "Lo siento, No pude procesarlo. Puedes intentarlo nuevamente o decir Ayuda"
handler_input.response_builder.speak(speech).ask(speech)
return handler_input.response_builder.response
El archivo final default_intents quedaria de la siguiente manera
from ask_sdk_core.dispatch_components import AbstractRequestHandler, AbstractExceptionHandler
from ask_sdk_core.utils import is_request_type, is_intent_name
from ask_sdk_core.handler_input import HandlerInput
from ask_sdk_model import Response
from ask_sdk_model.ui import SimpleCard
class LaunchRequestHandler(AbstractRequestHandler):
def can_handle(self, handler_input):
return is_request_type("LaunchRequest")(handler_input)
def handle(self, handler_input):
print("Ejecucion de la Skill.")
speech_text = "Bienvenido a Potato Noticias, puedes decir últimas noticias"
handler_input.response_builder.speak(speech_text).set_card(
SimpleCard("Potato Noticias", speech_text)).set_should_end_session(False)
return handler_input.response_builder.response
class HelpIntentHandler(AbstractRequestHandler):
def can_handle(self, handler_input):
# type: (HandlerInput) -> bool
return is_intent_name("AMAZON.HelpIntent")(handler_input)
def handle(self, handler_input):
# type: (HandlerInput) -> Response
print("Ejecucion Intent Ayuda")
speech_text = "Puedes decir últimas noticias, últimos 2 mensajes"
handler_input.response_builder.speak(speech_text).ask(speech_text).set_card(
SimpleCard("Potato Noticias - Ayuda", speech_text))
return handler_input.response_builder.response
class CancelAndStopIntentHandler(AbstractRequestHandler):
def can_handle(self, handler_input):
# type: (HandlerInput) -> bool
return is_intent_name("AMAZON.CancelIntent")(handler_input) or is_intent_name("AMAZON.StopIntent")(handler_input)
def handle(self, handler_input):
# type: (HandlerInput) -> Response
print("Ejecucion Intent Cancel o Stop")
speech_text = "Hasta luego Atentamente Potato Noticias!"
handler_input.response_builder.speak(speech_text).set_card(
SimpleCard("Potato Noticias", speech_text)).set_should_end_session(True)
return handler_input.response_builder.response
class SessionEndedRequestHandler(AbstractRequestHandler):
def can_handle(self, handler_input):
# type: (HandlerInput) -> bool
return is_request_type("SessionEndedRequest")(handler_input)
def handle(self, handler_input):
# type: (HandlerInput) -> Response
# any cleanup logic goes here
return handler_input.response_builder.response
class AllExceptionHandler(AbstractExceptionHandler):
def can_handle(self, handler_input, exception):
# type: (HandlerInput, Exception) -> bool
return True
def handle(self, handler_input, exception):
# type: (HandlerInput, Exception) -> Response
print(exception)
speech = "Lo siento, No pude procesarlo. Puedes intentarlo nuevamente o decir Ayuda"
handler_input.response_builder.speak(speech).ask(speech)
return handler_input.response_builder.response
Hasta este punto ya hemos implementado los Intents básicos que debe poseer una Skill para facilitar a los usuarios su administración y manejo, ahora nos toca implementar de la misma manera nuestro propio Intent :D
Primeramente definiremos un generate_reponse que recibe un array de mensajes y los convierte en texto para ser escuchado y texto para ser leído desde un dispositivo.
noticias_intent.py
def generate_response(messages):
# type: (List) -> Tuple[str,str]
speak_out = ""
speak_out_visible = ""
itemCount = 1
for message in messages:
speak_out += 'Mensaje {0}: <break time="0.5s"/> {1}. <break time="1s"/>'.format(
itemCount, message.get("content", ""))
speak_out_visible += '{0}: {1}.\n'.format(
itemCount, message.get("content", ""))
itemCount += 1
return speak_out, speak_out_visible
En el caso de speak_out se añade la etiqueta break esto hace referencia al SSML (Speech Synthesis Markup Language), que es un estándar que permite a los asistentes de voz añadir pausas, efectos especiales de voz como susurro, o la pronunciación de palabras en un idioma extranjero de forma correcta
Después creamos el Handler para nuestro NoticiasIntent, en el cual accederemos a discord y obtendremos los mensajes les daremos un formato para ser escuchados y vistos y sean devueltos hacia el asistente.
noticias_intent.py
class NoticiasIntentHandler(AbstractRequestHandler):
def can_handle(self, handler_input):
# type: (HandlerInput) -> bool
return is_intent_name("NoticiasIntent")(handler_input)
def handle(self, handler_input):
# type: (HandlerInput) -> Response
cantidad = get_slot_value(handler_input, "cantidad")
print(cantidad)
if cantidad is not None:
messages = get_n_messages(size=cantidad)
else:
messages = get_n_messages()
if messages is None or len(messages) == 0:
speak_output = "No se encontraron mensajes"
visual_output = "No se encontraron mensajes :("
else:
speak_output,visual_output = generate_response(messages)
speak_output += " Esos son todos los mensajes!."
visual_output += " Esos son todos los mensajes!."
handler_input.response_builder.speak(speak_output).set_card(
SimpleCard("Potato Noticias", visual_output)).set_should_end_session(True)
return handler_input.response_builder.response
Hasta este punto ya tenemos los intents y nos faltaria integrarlo en el init de la función y realizar pruebas :D
Reemplazamos todo el contenido de nuestro con lo siguiente, que integra tanto nuestros intents por defecto como el Intent custom para lectura de discord.
__ init __.py
import json
import azure.functions as func
from ask_sdk_core.skill_builder import SkillBuilder
from ask_sdk_webservice_support.webservice_handler import WebserviceSkillHandler
from PotatoNoticiasTrigger.default_intents import AllExceptionHandler, CancelAndStopIntentHandler, HelpIntentHandler, LaunchRequestHandler, SessionEndedRequestHandler
from PotatoNoticiasTrigger.noticias_intent import NoticiasIntentHandler
from PotatoNoticiasTrigger.config import ALEXA_SKILL_ID
def main(req: func.HttpRequest) -> func.HttpResponse:
skill_builder = SkillBuilder()
skill_builder.skill_id = ALEXA_SKILL_ID
# Default Intents
skill_builder.add_request_handler(LaunchRequestHandler())
skill_builder.add_request_handler(HelpIntentHandler())
skill_builder.add_request_handler(CancelAndStopIntentHandler())
skill_builder.add_request_handler(SessionEndedRequestHandler())
skill_builder.add_exception_handler(AllExceptionHandler())
# Custom Intents
skill_builder.add_request_handler(NoticiasIntentHandler())
webservice_handler = WebserviceSkillHandler(skill=skill_builder.create())
response = webservice_handler.verify_request_and_dispatch(req.headers, req.get_body().decode("utf-8"))
return func.HttpResponse(json.dumps(response),mimetype="application/json")
Ahora podemos probar el Intent y conectarlo a la Skill a través de Ngrok :D
Primeramente ejecutamos nuestra Función utilizando:
func host start
Esta llamada nos genera una salida similar en la cual nos muestra la salida de nuestra Función en local similar a:
http://localhost:7071/api/PotatoNoticiasTrigger
Ahora gracias a la herramienta Ngrok que nos permite abrir tuneles publicos hacia nuestros Endpoints locales ejecutaremos en otra terminal
ngrok http 7071
Que nos generará una dirección temporal para que conectemos nuestro EndPoint de la siguiente manera:
Forwarding http://2dba-200-87-92-9.ngrok.io -> http://localhost:7071
Forwarding https://2dba-200-87-92-9.ngrok.io -> http://localhost:7071
Utilizando la dirección HTTPS volvemos a la consola de Amazon y nos dirigimos a la parte de Endpoint en Default Region y añadimos nuestra dirección Ngrok más la ruta del Trigger teniendo algo similar a:
https://2dba-200-87-92-9.ngrok.io/api/PotatoNoticiasTrigger
Seleccionamos la opción para el certificado SSL “My development endpoint is a sub-domain of a domain that has a wildcard certificate from a certificate authority”
Guardamos el Endpoint, ahora tenemos que compilar para lo cual nos dirigimos hacia el menú principal y seleccionamos la Opción Build Model para construir el modelo de la Skill.
Esperamos unos minutos a que termine la construcción del modelo.
Pruebas de funcionamiento de la Skill integrada con el Endpoint Local
Desde la pestaña de Test podemos probar nuestra Skill apuntando hacia el Backend local en nuestro equipo. Habilitamos la Skill en modo Development y ya podemos probar
Hemos logrado hacer funcionar la Skill y que nos responda desde Local ahora podemos publicarlo en Azure :D y actualizar el EndPoint y volver a compilar nuestro modelo.
Desde VS Code seleccionamos la Opción Deploy to Function App
Seleccionamos la Suscripción de Azure que tengamos disponible, para usuarios nuevos pueden habilitar una suscripción de prueba y para estudiantes habilitar una suscripción de tipo estudiantil si se los permite sus cuentas institucionales.
Seleccionar una nueva función en Azure
Ingresamos el nombre de nuestra nueva función
Seleccionamos la pila de ejecución que se recomienda que sea coincidente con la que tenemos en local
Seleccionamos la región donde se aloja nuestra función, en nuestro caso WEST US y se procede a crear nuestra función, esperamos unos minutos mientras se generan los servicios.
Desde las configuraciones en Azure Añadimos 3 Configuraciones de Aplicación con el mismo nombre de nuestras variables de entorno
Una vez adicionado nuestras variables de entorno procedemos a guardar donde se reiniciará la instancia de la función
Ahora desde la parte de funciones podemos obtener la URL de nuestro nuevo ENDPOINT similar a
https://potatonoticias.azurewebsites.net/api/PotatoNoticiasTrigger
el cual actualizaremos en el Endpoint en el portal de Alexa y volveremos a construir el modelo esperemos que termine y podemos probar nuestra Skill
De esta manera podemos concluir el contenido, consumir un canal de Discord, pasar por una Skill de Alexa y publicar el backend de nuestra función en Azure, todo un conjunto de elementos integrados para automatizar y permitir a las personas escuchar potato noticias desde cualquier dispositivo con Alexa.
Muchas gracias!!
Un agradecimiento a la comunidad de Python La Paz por permitirme dar una charla sobre esto, síganlos en sus redes sociales Facebook y Twitter y pregunten por su servidor de Discord, para conocer más sobre futuras charlas y eventos, también pueden ser expositores y enseñarnos su conocimiento para crecer con esta bonita comunidad.
El repositorio público en Github lo pueden encontrar aqui del proyecto Potato Noticias
La presentación se encuentra disponible en el siguiente enlace Presentación
Algunos enlaces en cuanto a contenido sobre todo lo expuesto.
32