Aprende, practica, repite.

Introducción

Todo lo que hoy es fácil, en algún momento fue difícil.

Todas las mañanas acostumbraba a echarle un ojo al mercado laboral con el fin de ver cómo andaba la oscilación de salarios, requisitos de posiciones y tendencias en el mundo de la tecnología. Esta acción, por simple que fuera siempre me tomaba al rededor de 10 minutos completarla. A pesar de que no es mucho tiempo, ayer me pregunté cómo podría automatizar este proceso. Consideré construir un bot de Telegram que hiciera esto por mi. Ahora en 20 segundos tengo las últimas 10 ofertas de una categoría en específico, ¿gran cambio no?. Veamos cómo lo logre.

Objetivo

En este artículo, aprenderemos cómo crear un bot de Telegram que se comunique con una función de Azure a través de un webhook, consulte por RSS el portal de empleos de StackOverflow y de una manera elegante nos muestre las últimas ofertas de empleos filtradas por término de búsqueda.

Requisitos

  1. Una cuenta de Azure con una suscripción activa. Cree una cuenta gratuita.
  2. Node.js instalado en su computador.

Primeros pasos

Instalar Azure CLI

Azure CLI está disponible para instalar en entornos Windows, macOS y Linux.

Windows

Descargue e instale la versión actual de Azure CLI.

macOS

Para la plataforma macOS, puede instalar Azure CLI con el administrador de paquetes homebrew.

brew update && brew install azure-cli
Linux

El equipo de Azure CLI mantiene un script para ejecutar todos los comandos de instalación en un solo paso. Este script se descarga a través de curl:

curl -sL https://aka.ms/InstallAzureCLIDeb | sudo bash

Instalar Azure Functions Core Tools

Windows

Descargue y ejecute el instalador de Core Tools según su versión de Windows:

macOs

Al tener homebrew instalado, lo aprovechamos para instalar Core Tools:

brew tap azure/functions && brew install azure-functions-core-tools@3
Linux

Los siguientes pasos usan APT para instalar Core Tools en su distribución Linux Ubuntu/Debian.

  1. Instale la clave GPG del repositorio de paquetes de Microsoft, para validar la integridad del paquete:
curl https://packages.microsoft.com/keys/microsoft.asc | gpg --dearmor > microsoft.gpg && sudo mv microsoft.gpg /etc/apt/trusted.gpg.d/microsoft.gpg
  1. Configure la lista de fuentes APT antes de realizar una actualización:
sudo sh -c 'echo "deb [arch=amd64] https://packages.microsoft.com/repos/microsoft-ubuntu-$(lsb_release -cs)-prod $(lsb_release -cs) main" > /etc/apt/sources.list.d/dotnetdev.list'
  1. Inicie la actualización de la fuente APT:
sudo apt-get update
  1. Instale el paquete Core Tools:
sudo apt-get update && sudo apt-get install azure-functions-core-tools-3

Desarrollo del proyecto

Creación proyecto de funciones

Para lograr una interacción con Azure a través del CLI debemos primero estar autenticados, así que hagamos login con el siguiente comando:

az login

Una vez autenticados, procedemos a crear un Grupo de Recursos (Resource Group), que no es más que un contenedor que almacena los recursos relacionados con una solución en Azure:

az group create --name rg-telegrambotjobs --location eastus2

Luego, creamos una Cuenta de Almacenamiento (Storage Account), un espacio de nombre único para los datos de nuestra función. Aquí se almacenarán los releases, logs e información general de nuestro proyecto de funciones.

az storage account create --name satelegramjobbot --location eastus2 --resource-group rg-telegrambotjobs --sku Standard_LRS

Es momento de crear nuestro proyecto de funciones:

az functionapp create --name func-telegramjobbot --storage-account satelegramjobbot --consumption-plan-location eastus2 --resource-group rg-telegrambotjobs --os-type Linux --runtime node --runtime-version 14 --functions-version 3

Nótese que se han utilizado los argumentos --storage-account y --resource-group con el fin de indicar los nombres de nuestra cuenta de almacenamiento y grupo de recursos que pertenecerá nuestro proyecto de funciones.

Hasta el momento, todo lo hecho ha sido reflejado en el portal de Azure, sin embargo, aún no hemos creado ningún proyecto de forma local. Para ello, nos auxiliaremos del Core Tools para crear y deplegar nuestro proyecto de funciones.

Iniciemos el proyecto de funciones:

func init func-telegramjobbot --javascript && cd func-telegramjobbot

El argumento --javascript indica el tipo de lenguage de programación con el que se desarrollarán las funciones. Puede elegir cualquiera de su preferencia.

Como resultado, este comando creará un directorio con el nombre de func-telegramjobbot que contiene los archivos básicos de un proyecto de funciones de Azure.

Agreguemos una función a nuestro proyecto:

func new --name telegram-hook --template "HTTP trigger" --authlevel "anonymous"

La función telegram-hook, como su nombre lo indica, tendrá la responsabilidad de recibir las peticiones HTTP de nuestro bot de Telegram que crearemos en un momento.

Los argumentos --template y --authlevel indican el tipo de disparador que desencadenará nuestra función y su nivel de acceso, en este caso el trigger es HTTP y es de tipo anónima, lo cual significa que cualquier persona podrá tener acceso.

Si navegamos al directorio de nuestro proyecto de funciones func-telegramjobbot, notaremos que se ha creado un nuevo directorio telegram-hook que contiene los archivos básicos de una función. En este caso, nos enfocaremos en el archivo index.js que debería lucir de la siguiente manera:

module.exports = async function (context, req) {
    context.log('JavaScript HTTP trigger function processed a request.');

    const name = (req.query.name || (req.body && req.body.name));
    const responseMessage = name
        ? "Hello, " + name + ". This HTTP triggered function executed successfully."
        : "This HTTP triggered function executed successfully. Pass a name in the query string or in the request body for a personalized response.";

    context.res = {
        // status: 200, /* Defaults to 200 */
        body: responseMessage
    };
}

Se trata de una simple función que retorna un mensaje cuando es invocada a través de HTTP (POST o GET). Para que esto quede más claro, despleguemos y probemos la función:

func azure functionapp publish func-telegramjobbot

Como resultado, este comando irá logueando los pasos del proceso de despliegue de nuestra función:

Getting site publishing info...
Uploading package...
Uploading 1.31 KB [#####################################################################]
Upload completed successfully.
Deployment completed successfully.
Syncing triggers...
Functions in func-telegramjobbot:
    telegram-hook - [httpTrigger]
        Invoke url: https://func-telegramjobbot.azurewebsites.net/api/telegram-hook

Nótese que la última línea del log Invoke url corresponde a la URL de la función de Azure una vez desplegada.

Para garantizarnos que todo está funcionando correctamente, haremos una prueba con Curl enviando una petición HTTP a la URL de nuestra función:

curl --request GET --url "https://func-telegramjobbot.azurewebsites.net/api/telegram-hook?name=Jhon"

Como resultado de esta acción notaremos el mensaje que nos afirma que nuestra función está funcionando correctamente.

Hello, Jhon. This HTTP triggered function executed successfully.

Creación del bot de Telegram

Para crear un bot en Telegram, simplemente escriba un mensaje a BotFather y siga los pasos. Puede iniciar una conversación con dicho bot enviando el comando /newbot y siguiendo las instrucciones hasta obtener el API KEY.

Es necesario asignar un nombre único al bot. Una vez creado, recibirá un token de autorización parecido a este 1402306410:ABHNO-U9lkq-knkT0j9I6p2l8Mv2sBzfgdY. Cópielo y téngalo en un lugar seguro para su posterior uso.

Volvamos al directorio de nuestro proyecto de funciones func-telegramjobbot e instalemos algunas dependecias que nos serán útiles para el desarrollo:

npm install axios rss-parser

axios nos ayudará a realizar peticiones HTTP al API de Telegram con el fin de comunicarnos con nuestro bot, mientras que rss-parser nos servirá para hacer peticiones a portales que publican ofertas de empleos a través de RSS, como es el caso de StackOverflow.

A continuación, hagamos algunas modificaciones a nuestro index.js de la función telegram-hook:

const axios = require("axios").default;

const httpService = axios.create({
  baseURL: `https://api.telegram.org/bot${process.env.TELEGRAM_BOT_TOKEN}`,
});

async function sendWelcomeMessage(chatId) {
  await httpService.post(
    "/sendMessage",
    JSON.stringify({
      chat_id: chatId,
      text: "Hello from Azure 🚀",
    }),
    { headers: { "Content-Type": "application/json" } }
  );
}

module.exports = async function (context, req) {
  const chatId = req.body.message.chat.id;
  await sendWelcomeMessage(chatId);

  context.res = {
    ok: true,
  };
};

A través del método create creamos una nueva instancia de axios con una URL base, en este caso, corresponde al API de Telegram acompañado de la variable de entorno TELEGRAM_BOT_TOKEN la cual contendrá el token de nuestro bot. Por otro lado, la función sendWelcomeMessage se encarga de hacer una petición al endpoint sendMessage para enviar un mensaje desde el bot de Telegram.

Usar variables de entorno nos ayuda a mantener el código de nuestra función libre de exposición de credenciales. Para configurar dicha variable, la colocaremos en el archivo local.settings.json que se encuentra en la raíz de nuestro proyecto:

{
  "IsEncrypted": false,
  "Values": {
    "FUNCTIONS_WORKER_RUNTIME": "node",
    "AzureWebJobsStorage": "",
    "TELEGRAM_BOT_TOKEN": "1402306410:ABHNO-U9lkq-knkT0j9I6p2l8Mv2sBzfgdY"
  }
}

Publiquemos nuestra función nuevamente, pero esta vez utilizando el argumento --publish-local-settings para que el despliegue tome en cuenta nuestra variable de entorno.

func azure functionapp publish func-telegramjobbot --publish-local-settings

Como resultado de este comando, veremos el siguiente mensaje:

Getting site publishing info...
Uploading package...
Uploading 727.06 KB [#############################################################################]
Upload completed successfully.
Deployment completed successfully.
Setting FUNCTIONS_WORKER_RUNTIME = ****
App setting AzureWebJobsStorage is different between azure and local.settings.json
Would you like to overwrite value in azure? [yes/no/show]
no
Setting TELEGRAM_BOT_TOKEN = ****
Syncing triggers...
Functions in func-telegramjobbot:
    telegram-hook - [httpTrigger]
        Invoke url: https://func-telegramjobbot.azurewebsites.net/api/telegram-hook

Core Tools preguntará si deseamos sobreescribir la configuración de AzureWebJobsStorage, elegimos no.

Con la nueva función desplegada, es momento de indicarle a Telegram que nuestra función estará recibiendo las peticiones HTTP de nuestro bot. Esto se conoce como configuración del webhook. Para esto utilizaremos nuevamente Curl:

curl --request POST --url https://api.telegram.org/bot1402306410:ABHNO-U9lkq-knkT0j9I6p2l8Mv2sBzfgdY/setWebhook --header 'content-type: application/json' --data '{"url": "https://func-telegramjobbot.azurewebsites.net/api/telegram-hook"}'

Si la solicitud fue exitosa, deberá ver una respuesta como la siguiente:

{ "ok": true, "result": true, "description": "Webhook was set" }

¡Felicidades! Ya tienes un bot de Telegram totalmente serverless. Para probar lo que hemos hecho hasta el momento puedes enviarle un mensaje a tu bot y notarás que te responderá con el mensaje: Hello from Azure 🚀.

Ya lo único que nos queda es implementar la búsqueda de ofertas de empleos a través de RSS. Para ello, modifiquemos el código nuevamente:

const axios = require("axios").default;
const Parser = require("rss-parser");

const httpService = axios.create({
  baseURL: `https://api.telegram.org/bot${process.env.TELEGRAM_BOT_TOKEN}`,
});

async function sendWelcomeMessage(chatId) {
  await httpService.post(
    "/sendMessage",
    JSON.stringify({
      chat_id: chatId,
      text: "Please, select your preference programming language.",
      parse_mode: "markdown",
      reply_markup: {
        inline_keyboard: [
          [
            {
              text: "JavaScript",
              callback_data: "javascript",
            },
            {
              text: "Golang",
              callback_data: "golang",
            },
            {
              text: "Python",
              callback_data: "python",
            },
          ],
          [
            {
              text: "AWS",
              callback_data: "aws",
            },
            {
              text: "Serverless",
              callback_data: "serverless",
            },
            {
              text: "Azure",
              callback_data: "azure",
            },
          ],
        ],
      },
    }),
    { headers: { "Content-Type": "application/json" } }
  );
}

async function sendJobOffers(chatId, messageId, term) {
  const stackOverFlowRSS = `https://stackoverflow.com/jobs/feed?q=${term}&sort=pubDate`;
  const parser = new Parser();
  const feed = await parser.parseURL(stackOverFlowRSS);

  const jobs = feed.items.slice(0, 10);

  await httpService.post(
    "/editMessageText",
    JSON.stringify({
      chat_id: chatId,
      message_id: messageId,
      text: `Your search term selected was: ${term}.`,
    }),
    { headers: { "Content-Type": "application/json" } }
  );

  for (const job of jobs) {
    job.string = `**${job.title}**\n\nCategories: ${job.categories
      .map((j) => "#" + j)
      .join(" ")}\n\n${job.link}`;

    await httpService.post(
      "/sendMessage",
      JSON.stringify({
        chat_id: chatId,
        text: job.string,
        parse_mode: "markdown",
        reply_markup: {
          inline_keyboard: [
            [
              {
                text: "Apply now",
                url: job.link,
              },
            ],
          ],
        },
      }),
      { headers: { "Content-Type": "application/json" } }
    );
  }
}

async function sendErrorMessage(chatId) {
  await httpService.post(
    "/sendMessage",
    JSON.stringify({
      chat_id: chatId,
      text: "Sorry, I can't search jobs offers right now 😥",
    }),
    { headers: { "Content-Type": "application/json" } }
  );
}

module.exports = async function (context, req) {
  try {
    const { callback_query } = req.body;
    if (callback_query) {
      const chatId = callback_query.message.chat.id;
      const messageId = callback_query.message.message_id;
      const term = callback_query.data;

      await sendJobOffers(chatId, messageId, term);
    } else {
      const chatId = req.body.message.chat.id;
      await sendWelcomeMessage(chatId);
    }
  } catch (ex) {
    await sendErrorMessage();
    context.log.error(ex);
  } finally {
    context.res = {
      ok: true,
    };
  }
};

En nuestro código tenemos 3 funciones principales: sendWelcomeMessage, sendJobOffers y sendErrorMessage.

sendWelcomeMessage se encarga de enviar el mensaje de bienvenida y sobre todo dar las opciones del término de búsqueda con el que se buscarán las ofertas de empleo.

sendJobOffers se encarga de buscar las ofertas de empleo a través del portal de empleos de StackOverflow https://stackoverflow.com/jobs/feed.

sendErrorMessage Envía un mensaje en caso de que algún error ocurra.

Desplegamos la función nuevamente:

func azure functionapp publish func-telegramjobbot --publish-local-settings

¡Al fin! Ya tenemos nuestro bot busca empleos. Sólo queda probar...

Menú de opciones del bot

Ofertas de empleos

Conclusión

Hemos visto cómo en unos pocos minutos podemos crear un bot de Telegram para buscar ofertas de empleos utilizando Azure Functions y Node.js.

En un próximo artículo haremos mejoras a este proyecto:

  1. Configurar CI/CD con GitHub Actions
  2. Configurar logger
  3. Agregar más opciones de búsquedas y otros portales adicionales.

Puedes echarle un ojo al código fuente del bot en GitHub.