perri.to: Un méli-mélo de choses

Créer un bot Telegram avec GoSparta

  2018-08-13


Vue d’ensemble

Quand j’ai commencé, mon objectif principal était d’apprendre AWS Lambda. J’ai fait quelques recherches sur la meilleure approche : si vous vouliez utiliser go, c’était d’utiliser un framework, ce qui m’a donné une occasion supplémentaire, celle d’essayer go sparta de mweagle, que je voulais tester depuis un bon moment (et pas seulement parce qu’il travaille avec moi).

Le choix du sujet pour ce court exercice était aléatoire ; j’ai estimé que l’API webhook de Telegram convenait bien à un exemple piloté par une fonction lambda et je voulais faire un bot idiot pour voir à quel point ce serait difficile, donc une bonne occasion d’essayer les deux.

Considérations

C’est basique, cela crée un bot qui répond de manière très simple à des requêtes simples ; il n’entre pas dans le cadre de l’exercice de faire en sorte que le bot remplisse toute la fonctionnalité que j’avais initialement prévue. Des articles ultérieurs traiteront d’une fonctionnalité plus complète. Complètement hors du champ se trouve un bot capable d’envoyer réellement des messages sans être déclenché par une requête ; cet ensemble d’exemples ne contiendra qu’un bot réactif.

Une certaine connaissance des divers services aws et de leur configuration est supposée ; cela pourrait se faire sans, mais les parties non expliquées pourraient s’avérer un petit casse-tête.

Construction du bot

Configuration d’AWS

Nous pouvons commencer par la configuration d’AWS, puisque les parties Telegram ne sont pas nécessaires jusqu’à ce que les tests aient effectivement lieu.

Pour cela, vous aurez besoin d’un bucket s3 ; il peut être privé.

Nous aurons également besoin de créer un rôle AWS avec certaines permissions pour cet exercice ; en nous basant sur la FAQ de go sparta, nous pouvons déterminer quelles permissions sont requises. Voici une politique qui fonctionne ; vous pourriez très probablement l’affiner un peu plus pour qu’elle ne fonctionne que sur certains objets, mais pour moi c’était suffisant (remplacez YOUR_BUCKET_HERE par le nom de votre 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"
            ]
        }
    ]
}

Cela devrait fournir tous les réglages dont nous avons besoin pour exécuter l’exercice avec succès.

Variables d’environnement

Pour faciliter le développement et garder les secrets secrets, nous utiliserons quelques variables d’environnement pour stocker les données de connexion AWS et d’autres artefacts dont notre fonction lambda aura besoin au moment de la compilation mais qui n’ont pas à être stockés dans le code.

J’ai un petit fichier bash que je charge avec source avant de lancer gosparta et qui définit tout ce dont j’ai besoin ; les variables AWS seront récupérées par sparta et le reste par notre code une fois que nous l’aurons écrit.

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>

Création du code du bot.

Avant de commencer, voici l’exemple de code utilisé ici avec quelques extras

Le message

La première chose à prendre en compte est le type de message que nous obtiendrons de telegram et la façon dont il va être emballé.

Une fonction lambda est essentiellement un morceau de code qui s’exécute dans l’infrastructure et le contexte de quelqu’un d’autre. Afin de pouvoir faire en sorte que notre fonction lambda se comporte comme un endpoint HTTP, nous devrons utiliser un APIGateway, essentiellement une passerelle entre la périphérie de l’infrastructure et vos fonctions (et autres objets) ; plus de détails sur la façon de configurer cela suivront, mais pour l’instant, gardons simplement cette idée dans un coin de la tête.

La Gateway va emballer le message obtenu de la requête HTTP (Telegram envoie un POST) et nous le donner désérialisé à l’intérieur d’un objet, en supposant que notre fonction lambda ait le bon destinataire.

Pour recevoir le message, nous allons composer un Mixin de github.com/mweagle/Sparta/aws/events.APIGatewayEnvelope et de gopkg.in/telegram-bot-api.v4 tgbotapi.Update comme dans l’exemple suivant :

type TelegramRequest struct {
	spartaEvents.APIGatewayEnvelope
	Body tgbotapi.Update `json:"body"`
}
Remarquez que le tgbotapi.Update est assigné au champ de struct Body et qu’il possède un champ de sérialisation correspondant. Idéalement, vous assignerez à body n’importe quel type désérialisable capable de contenir ce qui est envoyé dans le body ; pour ce cas, nous avons de la chance, puisque Telegram envoie toujours des objets tgbotapi.Update et que ceux-ci sont très bien annotés dans la bibliothèque.

La fonction lambda elle-même

Notre fonction lambda sera une fonction go classique qui prend un context.Context et un TelegramRequest comme paramètres et renvoie un string et une erreur. Le string sera ignoré par telegram mais est utile lors des tests avec curl, et l’erreur sera utile puisque Telegram utilisera l’échec comme indicateur de nouvelle tentative.

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

la première chose à faire est d’essayer d’obtenir un logger ; les logs seront envoyés vers CloudWatch et, pour notre cas didactique, également très utiles pour jeter un œil à ce qui est envoyé.

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

Ensuite, nous avons besoin d’un client de l’API du bot telegram pour pouvoir répondre ; contrairement à ce que l’on pourrait penser, la réponse à telegram est complètement ignorée par lui, donc si vous voulez répondre à l’utilisateur, vous devrez instancier un nouveau client et envoyer un message.

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

Et maintenant le cœur de la fonctionnalité : nous extrayons l’Update du Body du message et essayons de déterminer ce que c’est (les commandes et les messages semblent être une convention dans la bibliothèque telegram).

Vous pouvez explorer un peu plus ce qui est fait ici dans le dépôt, mais en gros nous essayons de parser le message, d’agir en conséquence s’il s’agit d’une commande et de donner un exemple rapide de la différence entre un message et une Réponse (Reply) ; il y a plus de choses possibles, comme identifier si le canal est effectivement un chat de groupe ou un chat personnel et lequel, ainsi que quelques autres choses.

	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

Téléversement du bot vers AWS

Maintenant la partie amusante ; le mieux, c’est que, grâce à go-sparta, nous déclarons tout le Téléversement en utilisant go.

Les commentaires de cette section sont tous de @mweagle ; vous pouvez trouver le fichier complet ici

func main() {

Créer un nouveau stage d’API Gateway éligible à un déploiement. Un stage est un instantané des routes publiques disponibles pour un déploiement d’API-G

	apiStage := sparta.NewStage("v1")

Créer une ressource RestAPI d’API Gateway et l’associer au stage déployable

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

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

Cela permet d’accéder aux URLs via des Requêtes 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": "*",
		},
	}

Transformer une signature de lambda conforme à go d’AWS en un struct Sparta déployable https://godoc.org/github.com/mweagle/Sparta#LambdaAWSInfo. Ce struct nous permet d’associer la fonction lambda à la ressource URL de l’API Gateway

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

Créer une ressource d’API Gateway qui route /v1/chat vers notre fonction lambda. Cela associe une requête d’Intégration d’API Gateway à la fonction lambda cible.

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

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

Une fois la requête d’intégration établie, définir les méthodes HTTP spécifiques disponibles sur ce chemin de requête. Notre bot ne répond qu’à POST. Il ne renvoie également que deux codes de statut différents (200, 500). Réduire l’ensemble des codes de statut HTTP éligibles renvoyés par l’appel de la fonction réduit l’ensemble des expressions régulières appliquées au corps de la réponse. Cela améliore la performance, réduit le temps de provisionnement et minimise la taille globale du stack 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())
	}

Pour minimiser le nombre de templates de Mapping de l’API Gateway ainsi que la taille globale et le temps de provisionnement de notre stack, nous limiterons la route de l’API Gateway à n’accepter que des données application/json fournies via un HTTP POST

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

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

Créer le slice de fonctions lambda qui définissent ce service

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

Créer un stack CloudFormation au nom unique pour ce service. Cette fonction utilitaire permet à plusieurs développeurs de provisionner le même service dans un seul compte AWS

	stackName := spartaCF.UserScopedStackName("NianculBot")

Déléguer à Sparta la gestion de la compilation croisée, de l’empaquetage et de la gestion du service.

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

La partie telegram

Pour créer un bot, suivez simplement les instructions ici puis saisissez le token de l’API dans la variable de shell mentionnée précédemment.

Pour faire entrer cette variable dans notre code sans qu’elle soit committée avec lui, nous utiliserons ldflags, qui sont passés à go-sparta lors de l’exécution de l’étape de provisionnement.

Dans notre code, nous créerons simplement une variable string var TelegramBotAPI = "" et le reste se fera dans l’invocation

Assembler le tout

Pour faciliter les choses, nous ajouterons toute l’invocation à un Makefile, mais vous pourriez tout aussi bien utiliser un fichier shell ou faire l’invocation depuis le shell vous-même.

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

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

En gros, nous utilisons go run et passons le fichier main.go (et d’autres s’il y en a) puis les paramètres pour go-sparta qui, si tout est correct, téléverseront la fonction lambda vers votre compte aws. Remarquez comment -X est passé à --ldflags pour remplacer la variable que nous avons définie plus tôt par le contenu de celle du Shell.

Prévenir telegram.

Si vous avez réussi l’étape précédente, vous devriez avoir vu, parmi d’autres informations, la ligne suivante :

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

Copiez le Value indiqué là et utilisez-le pour la commande suivante

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

Remarquez que nous avons ajouté l’endpoint /chat, qui est la ressource que nous avons configurée pour notre lambda dans l’API Gateway.

Nous sommes maintenant prêts, soit à parler directement au bot, soit à l’ajouter à un groupe et à prendre plaisir à ajouter davantage de commandes et à reprovisionner.