Franklin Pezzuti Dyer

Home     Posts     CV     Contact     People

Abstracción de chatbots en Discord, Slack y Telegram

(Psst! Don't want to read this in Spanish? Here's a translation.)

En esta entrada me pongo a detallar un pequeño proyecto personal con el que he quedado ocupado en los últimos días. Conozco y uso varias plataformas de mensajería instantánea, siendo Discord la que uso con más frecuencia. También he participado en algunos grupos de Slack para asignaturas y reuniones en el pasado, ahora que me encuentro en España para un año de intercambio tengo cuenta de Telegram, plataforma que se usa más aquí que en los EEUU. Cada una de ellas tiene una API que se puede usar para desarrollar bots, o sea "usuarios" falsos cuyo comportamiento es automatizado para desempeñar algún papel como proporcionarles información a los usuarios reales, formatear los mensajes de los demás usuarios de manera automatizada, etcétera. En una entrada anterior he usado la API de Discord para desarrollar un bot que imita los mensajes de los usuarios de un servidor mediante un modelo de aprendizaje de lenguaje (y como esperaba fueron ridículos los resultados).

Se me ha ocurrido que sería interesante bosquejar una aplicación que abstrae las interfaces de las distintas plataformas, para permitir que uno escriba un bot con código muy general que funcione más o menos igual en cada plataforma. Así uno podría escribir una variedad de aplicaciones sin tener que hurgar por la documentación de cada una de las API ni repetir mucho código. Me ha parecido un buen ejercicio por varias razones:

Al principio iba intentando poner en marcha mi idea con Python, pero el manejo de asincronicidad con la biblioteca asyncio me ha resultado muy difícil y al final no ha salido bien el proyecto. Por otro lado, salió todo muy elegante al intentarlo con Node.js por su paradigma asíncrono. Una cosa más que vale apuntar es que aunque pretendo presentar el diseño de esta aplicacioncita muy sistemáticamente, mi propio proceso de diseño ha sido muy exploratorio y no tan lineal.

Al lío!

Diseño de la interfaz

Cada una de las tres plataformas que consideramos proporciona funcionalidades levemente distintas, por ejemplo tratan contenido multimedia de maneras distintas, funcionan un poco distinto las "reacciones" a los mensajes, etcétera. Además, quiero que lo que diseña sea fácil de extender en el futuro a casi cualquiera plataforma de mensajería que a uno le de la gana, suponiendo que ésta disponga de una API pública. Por tanto, nuestros bots tendrán sólo los siguientes requisitos funcionales muy limitados:

También, sólo diseñaremos nuestros bots para que funciones en mensajes directos y no en canales con varios usuarios (para no liarnos demasiado). Para preservar la independencia de nuestros servicios de las plataformas concretas en las que se lanzan, encapsularemos la lógica de los servicios y la comunicación por red de los mismos en clases distintas. En concreto, crearemos una clase llamada ChatService que encapsula la lógica de un servicio y otra clase con el nombre ChatBot que encapsula la comunicación por red y la interacción con las APIs. Por tanto ChatBot tendrá que tener varias subclases - una para cada plataforma. Aquí está un diagrama de clases:

Fig 1

El contenido de una subclase concreta de ChatBot debe ser lo más mínimo necesario para interactuar con la API de la plataforma a la que corresponde. Por eso, al recibir un mensaje, debe de producirse una llamada a handle_msg, que hará precisamente lo mismo para todas las subclases de ChatBot, en concreto llamar al método correspondiente del ChatService a que pertenece ese bot. El ChatService puede hacer lo que quiera con el contenido de ese mensaje según lo que pretende cumplir el servicio concreto, pero el ChatBot no debe hacer nada salvo pasárselo el mensaje que recibe (además de un nombre que identifica el bot, por si acaso el servicio involucra múltiples bots). A propósito, habiendo diseñado las interfaces, ya podemos escribirlas como clases abstractas en Javascript. Aquí está un esqueleto para la clase ChatService:

class ChatService {

    constructor() {}

    handle_msg(botname, userid, msg) {}

    run() {}

}

y aquí un esqueleto para la clase ChatBot:

const creds = require("./my_credentials.json")

class ChatBot {

    constructor(credkey, name) {
        this.creds = creds[credkey]
        this.name = name
    }

    set_service(service) {
        this.service = service;
    }

    handle_msg(userid, msg) {}

    send_msg(userid, msg) {}

    run() {}

}

Durante la construcción de un chatbot cualquiera, se obtendrá los credenciales necesarios para acceso a la API extrayéndolos de un fichero JSON. El número y tipo de los credenciales puede variar según la API específica de la plataforma así que no asumimos nada sobre la estructura de los credenciales, sólo los asignamos a un atributo para que las subclases no tienen que repetir la tarea de extraerlos del objeto JSON que contiene todos los credenciales.

Fíjate que dada esta interfaz ya podemos escribir una aplicación muy sencilla - aunque no podremos desplegarla hasta que implementemos a lo menos un bot concreto. Aquí está el código para un servicio llamado EchoService que describe una aplicación de un solo bot que simplemente repite lo que un usuario le diga:

const ChatService = require("./ChatService.js")

class EchoService extends ChatService {

    constructor(bot) {
            super();
        this.bot = bot;
        this.bot.set_service(this);
    }

    handle_msg(bot, userid, msg) {
        this.bot.send_msg(userid, msg);
    }

    run() {
        this.bot.run();
    }

}

Así se puede visualizar el comportamiento del EchoService cuando, por ejemplo, la plataforma es Discord:

Fig 2

pero para Slack y Telegram la lógica sería exactamente igual a pesar de las distintas APIs - y de eso la ventaja de la encapsulación y del uso de interfaces muy generales. Fíjate que según nuestra implementación de EchoService, recibirá un bot como argumento a su constructor, así que el tipo concreto del bot que se pasa determinará la plataforma del servicio que se pone en marcha.

Registro de los bots

Antes de escribir el código hay que registrar un bot con cada plataforma. Para Discord, hay que meterse en el portal de desarrolladores. Allí se puede crear una nueva aplicación con un bot con facilidad. Lo esencial es generar un token para el bot, el cual se usará para autenticarnos y controlarlo luego. El token aparecerá aquí (como ya he generado el mío, ya no está):

Fig 3

Para Slack hay que ingresar en el portal de gestión de aplicaciones y crear una nueva app. Nosotros usaremos Socket Mode para nuestro bot de Slack, y para ello hace falta entrar en esta sección y activarlo. Entonces hay que especificar el "ámbito" (scope) de la aplicación, o sea el conjunto de operaciones que Slack le permitirá realizar, y se generará un token de la aplicación que corresponde a los permisos específicos que requiere. Nosotros sólo necesitaremos los permisos connections:write y authorizations:read para leer y escribir mensajes:

Fig 4

También hay que entrar en la página Event Subscriptions y activar el uso de eventos, para que se dispare un nuevo evento en nuestro bot cuando un usuario le envíe un mensaje. Dentro de la sección Subscribe to events on behalf of users, tenemos que añadir el evento message.im:

Fig 5

Una vez hecho este paso, hay que instalar el bot al "espacio de trabajo" dentro del que quieres interactuar con él. Una diferencia entre los "espacios de trabajo" de Slack y los "servidores" de Discord es que, si no me equivoco, toda interacción en Slack ocurre en algún espacio de trabajo, incluso los mensajes directos, mientras que en Discord se puede enviar mensajes directos que no pertenecen a ningún servidor.

Finalmente, hay que obtener dos tokens para poder usar el bot de Slack: un token a nivel de bot y otro token a nivel de aplicación. Se encuentra el token a nivel de aplicación en la página de Basic Information bajo la cabecera App-Level Tokens, donde anteriormente especificamos el ámbito de la aplicación. Hay que pulsar en el nombre del token para mostrar el token en sí, que empieza con los caracteres xapp-:

Fig 6

Para el token a nivel de bot hay que entrar en la página OAuth & Permissions y copiar el Bot User OAuth Token que aparece aquí, el cual empieza con los caracteres xoxb-:

Fig 7

Finalmente creamos un bot de Telegram, el cual proceso seguramente será el más fácil de los tres por mucho. Sólo hace falta enviarle un mensaje al usuario especial de Telegram que se llama el BotFather:

Fig 8

y entonces elegir un nombre para el nuevo bot:

Fig 9

y ya está, así de fácil!

Finalmente recopilamos todas las credenciales en un fichero de JSON para poder acceder a ellas con facilidad desde un ChatBot:

{
    "DISCORD_TOKEN" : "...",

    "SLACK_CREDS" : {
        "token" : "xoxb-...",
        "apptoken" : "xapp-...
    },

    "TELEGRAM_TOKEN" : "...
}

Código de los ChatBot

Aprovecharemos de los siguientes tres módulos de Node.js para escribir las tres subclases concretas de ChatBot:

A propósito, estos tres módulos se encargan de interactuar con la API, así que nosotros ni siquiera tendremos que escribir el código que interactúe directamente. Lo que nosotros pretendemos diseñar es más bien una fachada para clases análogas de estos tres módulos respectivos que trabajan con las APIs respectivas de Discord, Slack y Telegram. Lo único que me ha hecho falta para implementar las tres clases DiscordBot, SlackBot y TelegramBot es mucho indagar en la documentación de los respectivos módulos para averiguar su uso correcto. Por tanto no describo aquí el proceso de escribir estas clases paso a paso, pero aquí se puede ver el código por si te interesa:

Algunos servicios sencillos

La primera prueba será el despliegue del EchoBot. En principio, si nos hemos conformado bien con la interfaz que describimos al principio, el código que ya escribimos para el EchoBot debe de funcionar igual en Discord, en Slack y en Telegram, siendo la única diferencia entre las implementaciones el tipo del bot que se pasa como argumento al constructor de la instancia de EchoBot. El código siguiente nos permitirá probar el servicio en las tres plataformas a la vez:

db = new DiscordBot("discord-bot");
sb = new SlackBot("slack-bot");
tb = new TelegramBot("telegram-bot");

echoDiscord = new EchoService(db);
echoSlack = new EchoService(sb);
echoTelegram = new EchoService(tb);

echoDiscord.run()
echoSlack.run()
echoTelegram.run()

Y se ve que sí funciona:

Fig 10

Probaremos una aplicación un poco (muy poco) más compleja, que detecta los nombres de ciertas frutas y verduras en los mensajes de los usuarios y les informa de si se refiere cada uno a fruta o verdura. La subclase de ChatService se llamará FruitVeggieService y su método handle_msg será el siguiente:

handle_msg(bot, userid, msg) {
    msg.split(" ").forEach(word => {
        if (this.fruits.includes(word)) {
            this.bot.send_msg(userid, `${word} is a fruit`);
        } else if (this.veggies.includes(word)) {
            this.bot.send_msg(userid, `${word} is a veggie`);
        }
    })
}

donde los arrays fruits y veggies serán los siguientes:

this.fruits = [
    "apple",
    "banana",
    "pear
]
this.veggies = [
    "carrot",
    "cabbage",
    "bokchoy
]

Lanzamos el nuevo servicio en las tres plataformas a la vez con el siguiente código. Fíjate que funciona igual que el código de lanzamiento de los servicios que usamos para el EchoService excepto que esta vez evitamos un poco de repetición de código repetitivo usando el forEach de Javascript:

db = new DiscordBot("discord-bot");
sb = new SlackBot("slack-bot");
tb = new TelegramBot("telegram-bot");

[db, sb, tb].forEach(b => {
    new FruitVeggieService(b).run();
})

y podemos verificar que los bots funcionan en las tres plataformas:

Fig 11

Lo chulo es que también podemos utilizar nuestro diseño para crear aplicaciones que involucran bots en varias plataformas simultáneamente que interactúan entre sí. Por ejemplo, imagínate que hay un usuario de Discord que no quiere crear cuenta de Telegram y un usuario de Telegram que no quiere cuenta de Discord, pero que quieren comunicar entre sí de manera que le conviene a cada uno. Para ello se puede utilizar un servicio escrito con nuestro andamiaje que dispone de dos bots - uno de Discord y uno de Telegram - que simula un "tubo acústico" con un extremo en Discord y el otro en Telegram, de tal manera que cada mensaje que recibe en una plataforma se replica en la otra plataforma. En concreto, cuando nuestro servicio recibe un mensaje mediante uno de sus dos bots de algún usuario o canal en su plataforma correspondiente, entonces le reenviará el mismo al usuario o canal desde el que recibió un mensaje más recientemente en la otra plataforma. El código es bastante sencillo:

class TalkTubeService {

    constructor(bot1, bot2) {
        this.bot1 = bot1;
        this.bot2 = bot2;
        this.bot1.set_service(this);
        this.bot2.set_service(this);

        this.user1 = null;
        this.user2 = null;
    }

    handle_msg(botname, userid, msg) {

        if (botname === this.bot1.name) {
            this.user1 = userid;
            if (this.user2 != null) {
                this.bot2.send_msg(this.user2, msg);
            }
        } else if (botname === this.bot2.name) {
            this.user2 = userid;
            if (this.user1 != null) {
                this.bot1.send_msg(this.user1, msg);
            }
        } 
    }

    run() {
        this.bot1.run();
        this.bot2.run();
    }

}

y se puede desplegar la aplicación así:

db = new DiscordBot("discord-bot");
tb = new TelegramBot("telegram-bot");

new TalkTubeService(db, tb).run()

y se ve que funciona tal y como hemos descrito:

Fig 12

Así se puede visualizar la acción del TalkTubeBot, una vez que los usuarios de ambas plataformas ya se han puesto en contacto con el bot por primera vez:

Fig 13

Interceptores de los ChatBot

Quiero comentar un truco más. Si usamos el patrón interceptor para crear entidades intermediarias entre los ChatBot y ChatService para modificar el flujo de texto entre los dos, podremos crear un abanico de distintas aplicaciones con comportamientos levemente distintos combinando los bots, los servicios y los interceptores de distintas maneras. En concreto, crearemos una clase que se llama ChatBotInterceptor cuya interfaz es tal que los ChatService podrán interactuar con ella como si fuera un ChatBot, y los ChatBot podrán interactuar con ella como si fuera un ChatService.

Fig 14

En concreto, aquí está la interfaz de ChatBotInterceptor:

class ChatBotInterceptor {

    constructor(name, bots) {
        this.bots = bots;
        this.name = name
        this.bots.forEach(b => b.set_service(this));
    }

    set_service(service) {
        this.service = service;
    }

    handle_msg(name, userid, msg) {}

    send_msg(userid, msg) {}

    run() {
        this.bots.forEach(b => b.run());
    }

}

Al construir un ChatBotInterceptor hay que pasar, además del nombre, una lista de bots cuyo tráfico el interceptor se encargará de recibir, tramitar y pasarle al servicio. Poner en marcha el interceptor equivale a poner en marcha cada uno de sus bots. Veremos ahora un ejemplo sencillo de un ChatBotInterceptor que no le pasa al servicio los mensajes de su único bot inmediatamente sino en lotes, es decir, espera hasta haber recibido un número fijo n de mensajes (que va almacenando) y entonces los concatena y se los pasa al servicio como un único mensaje grande. Su implementación es así:

class BatchBotInterceptor extends ChatBotInterceptor {

    constructor(bot, n) {
        super(bot.name.concat("-int"), [bot]);

        this.limit = n;
        this.lastuser = null;
        this.queue = [];
    }

    handle_msg(name, userid, msg) {
        if (userid != this.lastuser) {
            this.lastuser = userid;
            this.queue = [];
        }

        this.queue.push(msg)

        if (this.queue.length == this.limit) {
            this.service.handle_msg(this.name, this.lastuser, this.queue.join("\n"));
            this.queue = [];
        }
    }

    send_msg(userid, msg) {
        this.bot.send_msg(userid, msg);
    }

}

Ahora podemos modificar cualquier una de nuestras aplicaciones anteriores metiendo entre el servicio y el bot un interceptor de este tipo. Por ejemplo, la aplicación de "tubería acústica":

sb = new SlackBot("slack-bot");
tb = new TelegramBot("telegram-bot");

new TalkTubeService(sb, new BatchBotInterceptor(tb, 3)).run()

y se ve que ya tenemos una tubería entre Slack y Telegram que es tal que los mensajes desde Telegram se transmiten sólo en lotes de tres mensajes, y no se envía ninguno hasta que se han acumulado tres mensajes por enviar.

Fig 15

Aunque no expondré más interceptores aquí, se me ocurren varias ideas más que serían bastante fáciles de implementar:

También se puede considerar los efectos emergentes que se dan al ensamblar varios interceptores en una cadena entre un bot y una aplicación. ¿Puedes predecir el comportamiento de la aplicación modificada que tendríamos al envolver el bot de Telegram dos veces en un interceptor por lotes, es decir, la aplicación que se lanza así?

sb = new SlackBot("slack-bot");
tb = new TelegramBot("telegram-bot");

new TalkTubeService(sb, 
    new BatchBotInterceptor(
        new BatchBotInterceptor(tb, 3), 
        3
    )
).run()

Mejoras adicionales

Hasta ahora lo que hemos diseñado es bastante sencillo, pero creo que sirve como buena herramienta para juguetear con los chatbots multiplataforma sin tener que liarse con los detalles de las APIs ni las implementaciones de los módulos concretos que existen para interactuar con cada una. Sin embargo se me ocurren varias maneras de mejorar este andamiaje en el futuro, que me quedan por implementar todavía:


back to home page