perri.to: Un mejunje de cosas

Haciendo un bot de Telegram con GoSparta

  2018-08-13


Panorama general

Cuando empecé con esto, mi objetivo principal era aprender sobre AWS Lambda. Investigué un poco cuál era el mejor enfoque: si querías usar go, era usar algún framework, lo cual me dio una oportunidad extra, la de probar go sparta de mweagle, que hacía bastante tiempo que tenía ganas de probar (y no solo porque trabaja conmigo).

La elección del tema para este ejercicio corto fue al azar; consideré que la API de webhook de Telegram encajaba bien para un ejemplo impulsado por una función lambda y quería hacer un bot tonto para ver qué tan difícil sería, así que buena oportunidad para probar ambas cosas.

Consideraciones

Esto es básico, crea un bot que responde de manera muy simple a consultas simples; no está dentro del alcance del ejercicio hacer que el bot cumpla toda la funcionalidad que originalmente había planeado. Publicaciones posteriores tratarán una funcionalidad más completa. Completamente fuera de alcance está un bot que realmente pueda enviar mensajes sin ser disparado por una solicitud; este conjunto de ejemplos solo contendrá un bot reactivo.

Se asume cierto conocimiento sobre los distintos servicios y la configuración de aws; esto se podría hacer sin ese conocimiento, pero las partes no explicadas podrían resultar un pequeño dolor de cabeza.

Construyendo el bot

Configurando AWS

Podemos empezar por la configuración de AWS, ya que las partes de Telegram no son necesarias hasta que efectivamente se hacen las pruebas.

Para esto vas a necesitar un bucket de s3; puede ser privado.

También vamos a necesitar crear un rol de AWS con ciertos permisos para este ejercicio; basándonos en el FAQ de go sparta podemos determinar qué permisos se requieren. Esta es una política que funciona; lo más probable es que puedas afinarla un poco más para que solo funcione sobre ciertos objetos, pero a mí me alcanzó (reemplazá YOUR_BUCKET_HERE con el nombre de tu bucket).

{
    "Version": "2012-10-17",
    "Statement": [
        {
            "Sid": "VisualEditor0",
            "Effect": "Allow",
            "Action": [
                "lambda:CreateFunction",
                "iam:GetRole",
                "lambda:GetFunction",
                "iam:DeleteRole",
                "iam:CreateRole",
                "lambda:GetFunctionConfiguration",
                "cloudformation:CreateChangeSet",
                "iam:PutRolePolicy",
                "cloudformation:DeleteChangeSet",
                "cloudformation:DescribeStacks",
                "iam:PassRole",
                "lambda:AddPermission",
                "cloudformation:DescribeStackEvents",
                "cloudformation:CreateStack",
                "iam:DeleteRolePolicy",
                "cloudformation:DeleteStack",
                "lambda:DeleteFunction",
                "cloudformation:DescribeChangeSet",
                "lambda:RemovePermission",
                "cloudformation:ExecuteChangeSet"
            ],
            "Resource": "*"
        },
        {
            "Sid": "VisualEditor1",
            "Effect": "Allow",
            "Action": [
                "apigateway:DELETE",
                "s3:PutObject",
                "s3:GetObject",
                "apigateway:PUT",
                "apigateway:PATCH",
                "apigateway:POST",
                "s3:GetBucketVersioning",
                "apigateway:GET",
                "s3:DeleteObject"
            ],
            "Resource": [
                "arn:aws:apigateway:*::*",
                "arn:aws:s3:::*/*",
                "arn:aws:s3:::YOUR_BUCKET_HERE"
            ]
        }
    ]
}

Esto debería proveer toda la configuración que necesitamos para correr con éxito el ejercicio.

Variables de entorno

Para facilitar el desarrollo y mantener los secretos en secreto, vamos a usar algunas variables de entorno para almacenar los datos de login de AWS y otros artefactos que nuestra función lambda va a necesitar en tiempo de compilación pero que no hace falta guardar en el código.

Tengo un pequeño archivo bash que ejecuto con source antes de correr gosparta y que setea todo lo que necesito; las variables de AWS las va a tomar sparta y el resto nuestro código una vez que lo escribamos.

export AWS_ACCESS_KEY_ID=<your role access key>
export AWS_SECRET_ACCESS_KEY=<your role secret access key>
export AWS_REGION=<your aws region>
export NIANCULBOTAPI=<your telegram api key once you get one>
export S3BUCKET=<the bucket you just created>

Creando el código del bot.

Antes de empezar, acá está el código de ejemplo usado aquí con algunos extras

El mensaje

Lo primero a tener en cuenta es el tipo de mensaje que vamos a obtener de telegram y cómo va a estar envuelto.

Una función lambda es básicamente una porción de código que corre en la infraestructura y el contexto de otra persona. Para poder hacer que nuestra función lambda se comporte como un endpoint HTTP, vamos a necesitar usar un APIGateway, básicamente una puerta de enlace entre el borde de la infraestructura y tus funciones (y otros objetos); más adelante habrá más detalle sobre cómo configurar esto, pero por ahora dejemos esa idea en la parte de atrás de la cabeza.

El Gateway va a envolver el mensaje obtenido de la solicitud HTTP (Telegram envía un POST) y nos lo va a dar deserializado dentro de un objeto, asumiendo que nuestra función lambda tenga el destinatario correcto.

Para recibir el mensaje vamos a armar un Mixin de github.com/mweagle/Sparta/aws/events.APIGatewayEnvelope y de gopkg.in/telegram-bot-api.v4 tgbotapi.Update como en el siguiente ejemplo:

type TelegramRequest struct {
	spartaEvents.APIGatewayEnvelope
	Body tgbotapi.Update `json:"body"`
}
Notá que el tgbotapi.Update está asignado al campo de struct Body y tiene un campo de serialización correspondiente. Idealmente vas a asignar a body cualquier tipo deserializable que pueda contener lo que sea que se esté enviando en el body; para este caso tenemos suerte, ya que Telegram siempre envía objetos tgbotapi.Update y esos están muy bien etiquetados en la biblioteca.

La función lambda en sí

Nuestra función lambda va a ser una función go común que toma un context.Context y un TelegramRequest como parámetros y devuelve un string y un error. El string va a ser ignorado por telegram, pero es útil al probar con curl, y el error va a ser útil ya que Telegram va a usar la falla como indicador de reintento.

func chatty(ctx context.Context, 	gatewayEvent *TelegramRequest) (string, error) {

lo primero que hay que hacer es intentar obtener un logger; los logs se van a mandar a CloudWatch y, para nuestro caso didáctico, también son muy útiles para espiar qué se está enviando.

	logger, loggerOk := ctx.Value(sparta.ContextKeyLogger).(*logrus.Logger)
	if !loggerOk {
		return "cannot get a logger", nil
	}

Después necesitamos un cliente de la API del bot de telegram para poder responder; contrario a lo que uno pensaría, la respuesta a telegram es completamente ignorada por él, así que, si querés responderle al usuario, vas a necesitar instanciar un nuevo cliente y enviar un mensaje.

	bot, err := tgbotapi.NewBotAPI(TelegramBotAPI)
	if err != nil {
		logger.WithFields(logrus.Fields{
			"Event": gatewayEvent,
		}).Error(err)
		return "cannot create new bot", err
	}

Y ahora el núcleo de la funcionalidad: extraemos el Update del Body del mensaje e intentamos determinar qué es (los comandos y los mensajes parecen ser una convención en la biblioteca de telegram).

Podés explorar un poco más lo que se está haciendo acá en el repo, pero básicamente intentamos parsear el mensaje, actuar en consecuencia si es un comando y dar un ejemplo rápido de la diferencia entre un mensaje y una Respuesta (Reply); hay más cosas que se pueden hacer, como identificar si el canal es efectivamente un chat grupal o uno personal y cuál, junto con algunas otras cosas.

	u := gatewayEvent.Body
	message := fmt.Sprintf("I don't know what to make of: %q 🤷‍♀️", u.Message.Text)
	isReply := true
	if ok, command, args := isCommand(u.Message.Text); ok {
		message, isReply = handle(command, u.Message.Chat.UserName, args)
	}

	msg := tgbotapi.NewMessage(u.Message.Chat.ID, message)

	if isReply {
		msg.ReplyToMessageID = u.Message.MessageID
	}

	bot.Send(msg)
	return "", nil

Subiendo el bot a AWS

Ahora la parte divertida; lo mejor de esto es que, gracias a go-sparta, declaramos toda la Subida usando go.

Los comentarios en esta sección son todos de @mweagle; podés encontrar el archivo completo acá

func main() {

Crear una nueva etapa (stage) de API Gateway que sea elegible para un despliegue. Una etapa es una instantánea de las rutas públicas disponibles para un despliegue de API-G

	apiStage := sparta.NewStage("v1")

Crear un recurso RestAPI de API Gateway y asociarlo con la etapa desplegable

Ref: https://docs.aws.amazon.com/AWSCloudFormation/latest/UserGuide/aws-resource-apigateway-restapi.html

	apiGateway := sparta.NewAPIGateway("NianculBot", apiStage)

Esto permite que las URLs se accedan mediante Solicitudes AJAX

	apiGateway.CORSOptions = &sparta.CORSOptions{
		Headers: map[string]interface{}{
			"Access-Control-Allow-Headers": "Content-Type,X-Amz-Date,Authorization,X-Api-Key",
			"Access-Control-Allow-Methods": "*",
		},
	}

Transformar una firma de lambda go-compatible de AWS en un struct Sparta desplegable https://godoc.org/github.com/mweagle/Sparta#LambdaAWSInfo. Este struct nos permite asociar la función lambda con el recurso URL del API Gateway

	lambdaFn := sparta.HandleAWSLambda("telegram",
		chatty,
		sparta.IAMRoleDefinition{})

Crear un recurso de API Gateway que enrute /v1/chat a nuestra función lambda. Esto asocia una solicitud de Integración del API Gateway con la función lambda de destino.

Ref: https://docs.aws.amazon.com/AWSCloudFormation/latest/UserGuide/aws-resource-apigateway-resource.html

	apiGatewayResource, _ := apiGateway.NewResource("/chat", lambdaFn)

Una vez establecida la solicitud de integración, definir los métodos HTTP específicos disponibles en esa ruta de solicitud. Nuestro bot solo responde a POST. También solo devuelve dos códigos de estado distintos (200, 500). Reducir el conjunto de códigos de estado HTTP elegibles devueltos por la llamada a la función reduce el conjunto de expresiones regulares aplicadas al cuerpo de la respuesta. Esto mejora el rendimiento, reduce el tiempo de aprovisionamiento y minimiza el tamaño total del stack de CloudFormation.

Ref: https://docs.aws.amazon.com/apigateway/latest/developerguide/api-gateway-integration-settings-integration-response.html

	apiMethod, apiMethodErr := apiGatewayResource.NewMethod("POST",
		http.StatusOK,
		http.StatusInternalServerError)
	if nil != apiMethodErr {
		panic("Failed to create /chat resource: " + apiMethodErr.Error())
	}

Para minimizar la cantidad de plantillas de Mapeo del API Gateway y el tamaño total y el tiempo de aprovisionamiento de nuestro stack, vamos a limitar la ruta del API Gateway a aceptar solamente datos application/json provistos a través de un HTTP POST

Ref: https://docs.aws.amazon.com/apigateway/latest/developerguide/api-gateway-mapping-template-reference.html

	apiMethod.SupportedRequestContentTypes = []string{"application/json"}

Crear el slice de funciones lambda que definen este servicio

	lambdaFunctions := []*sparta.LambdaAWSInfo{lambdaFn}

Crear un stack de CloudFormation con nombre único para este servicio. Esta función utilitaria permite que múltiples desarrolladores aprovisionen el mismo servicio en una única cuenta de AWS

	stackName := spartaCF.UserScopedStackName("NianculBot")

Delegar en Sparta el manejo de la compilación cruzada, el empaquetado y la gestión del servicio.

	sparta.Main(stackName,
		"Core of the Niancul Chat Bot for Catering Barbecues",
		lambdaFunctions,
		apiGateway,
		nil)
}

La parte de telegram

Para crear un bot simplemente seguí las instrucciones de acá y después ingresá el token de la API en la variable de shell mencionada antes.

Para que esta variable entre en nuestro código sin ser commiteada junto con él, vamos a usar ldflags, que se pasan a go-sparta al correr el paso de aprovisionamiento.

En nuestro código simplemente vamos a crear una variable string var TelegramBotAPI = "" y el resto se hará en la invocación

Juntando todo

Para hacer las cosas más fáciles vamos a agregar toda la invocación a un Makefile, pero muy bien podrías usar un archivo de shell o hacer la invocación desde el shell vos mismo.

export NIANCULBOTAPI := $(NIANCULBOTAPI)
export S3BUCKET := $(S3BUCKET)

.PHONY provision:
provision:
	go run main.go provision --ldflags "-X main.TelegramBotAPI=$(NIANCULBOTAPI)" --s3Bucket $(S3BUCKET)

Básicamente usamos go run y pasamos el archivo main.go (y otros si están involucrados) y después los parámetros para go-sparta que, si todo está correcto, van a subir la función lambda a tu cuenta de aws. Notá cómo a --ldflags se le pasa -X para reemplazar la variable que configuramos antes con el contenido de la del Shell.

Avisándole a telegram.

Si tuviste éxito en el paso anterior, deberías haber visto, entre otra información, la siguiente línea:

APIGatewayURL Description="API Gateway URL" Value="https://someamazonurl.amazonaws.com/v1"

Copiá el Value de ahí y usalo para el siguiente comando

curl --request POST --url https://api.telegram.org/bot${NIANCULBOTAPI}/setWebhook --header 'content-type: application/json' --data '{"url": "https://someamazonurl.amazonaws.com/v1/chat"}'

Notá que agregamos el endpoint /chat, que es el recurso que configuramos para nuestra lambda en el API Gateway.

Ahora estamos listos ya sea para hablar directamente con el bot o para agregarlo a un grupo y disfrutar de agregar más comandos y volver a aprovisionar.