Johnny5 - XMPP Whisper bot

"Attractive. Nice software!"

Johnny5

Jhonny5 nos da una mano transliterando en Lost in Cyberspace, es un proyecto que demuestra lo rápido y simple que puede ser desarrollar en Python un bot para XMPP, de hecho, ue un desarrollo que nos tomo un par de días y pudimos publicarlo a tiempo para homenajear a ley de lenguaje de señas argentino.

Con este post vamos a iniciar una serie de posts relacionados a desarrollo de herramientas y soluciones basadas en XMPP, la idea es empezar con Python y Slixmpp para luego ir explorando otras opciones, dependiendo de lo que vayamos desarrollando para Cyberdelia. Para arrancar es ideal tener una idea de lo que es XMPP, el funcionamiento básico del protocolo, que son y que tipos de stanzas existen, como así también tener conocimientos de Python (al menos mientras estemos hablando de librerías o frameworks basados en esta tecnología).

Bots en XMPP

Existen varias formas de hacer bots en XMPP, se pueden usar frameworks orientados a chatops como errBot o librerías como slixmpp, incluso podríamos incluir Spade como framework, ya que al fin y al cabo un agente no es ni mas ni menos que una porción de software que hace algo en base a mensajes, estos podrían ser tanto mensajes estándar como mensajera pub/sub. En este post nos vamos a concentrar en mensajería instantánea, pero más adelante veremos como podemos utilizar los mecanismos pub/sub de XMPP.

XMPP

Elegir SliXMPP para desarrollo de bots no es casual, decidimos usarla por que tanto el lenguaje como la librería son una excelente opción para iniciarse en el desarrollo de XMPP por la simpleza y agilidad que adquirimos desarrollando en Python, sumado a la posibilidad de acompañar el uso de la librería SliXMPP con el libro "XMPP:The Definitive Guide publicado por O'Reilly junto a otros detalles que seria mejor detallarlos a continuación.

Slixmpp

Esta librería es un fork de SleekXMPP, que fue utilizada para los ejemplos del libro que mencionamos anteriormente. Pero con la obsolescencia de Python 2.6 el equipo de Poezio decidió tomar el toro por las astas y forkear el proyecto para migarlo a asyncio y darle soporte a Python 3.7+ (al momento de escribir el post).

Bajo la misma filosofía y los mismos principios de diseño de SleekXMPP, Slixmpp esta diseñado para tener un bajo numero de dependencias, dar soporte a XEPs bajo un esquema de plugins y abstraer lo suficiente al protocolo como para no tener que manejar XML, haciéndolo muy sencillo de usar.

En serio... es sencillo y su abstracción lo hace notablemente simple de utilizar, el siguiente código boilerplate nos permite procesar mensajes individuales y grupales.

Slixmpp Boilerplate code

#!/usr/bin/env python3

import slixmpp


class XMPPBot(slixmpp.ClientXMPP):

    def __init__(self, jid, password, room, nick):
        slixmpp.ClientXMPP.__init__(self, jid, password)
        self.room = room
        self.nick = nick

        self.add_event_handler("session_start", self.start)
        self.add_event_handler("message", self.message_handler)
        self.add_event_handler("groupchat_message", self.muc_message_handler)
        self.add_event_handler("disconnected",self.disconnected)

    async def start(self, event):
        await self.get_roster()
        self.send_presence()
        self.plugin['xep_0045'].join_muc(self.room,
                                         self.nick)

    def disconnected(self):
        self.reconnect()


    def message_handler(self, msg):
    """
    Message handler
    """


    def muc_message_handler(self, msg):
    """
    MUC Message handler
    """


if __name__ == '__main__':

    jid="user@server.ltd"
    password="somerandomfakepassword"
    room="arandom@room.server.ltd"
    nick="xmppbotnick"

    xmpp = XMPPBot(jid, password, room, nick)
    xmpp.register_plugin('xep_0030') # Service Discovery
    xmpp.register_plugin('xep_0045') # Multi-User Chat

    xmpp.connect()
    asyncio.get_event_loop().run_forever()

Bien, vayamos por partes dijo Jack... Lo primero que nos debe interesar es la clase que estamos heredando cuando definimos XMPPBot, slixmpp.ClientXMPP:

ClientXMPP hereda de BaseXMPP que a su vez hereda de XMLStream, una subclase de asyncio. BaseProtocol. XMLStream tiene la responsabilidad de gestionar la conexión y despachar eventos. XMLStream es una clase genérica que abstrae los detalles de establecer la conexión con el servidor como así también el envío y recepción de stanzas XML. Por su lado, BaseXMPP extiende XMLStream para usarla con XMPP proveyendo el mecanismo de plugins para extender y agregar soporte a nuevas características del protocolo XMPP. ClientXMPP, como decíamos antes hereda de BaseXMPP y agrega la funciones especificas para gestionar conexiones de clientes y abstraer el concepto cliente, como decíamos, para encapsular el protocolo facilitándonos mucho la interacción con XMPP.

Sabiendo cual es la cadena de responsabilidades que termina recibiendo nuestra clase es tan importante como conocer los mecanismos básicos para que nuestro bot funcione. Del código anterior podemos ver que tenemos dos secciones importantes. La primera es la definición de la clase XMPPBot y la segunda es el condicional de ejecución del hilo principal, donde instanciamos la clase y hacemos un par de cosas más que vamos a detallar a continuación, pero primero veamos los principales bloques de nuestra clase.

El método constructor del bot es donde ocurre la mayor parte de la magia de slixmpp, aquí inicializamos una instancia pasándole al constructor de la clase heredada los datos de la cuenta que vamos a utilizar, es decir el jid y sus credenciales para luego empezar a inicializar las propiedades de nuestra clase:

    def __init__(self, jid, password, room, nick):
        slixmpp.ClientXMPP.__init__(self, jid, password)
        self.room = room
        self.nick = nick

        self.add_event_handler("session_start", self.start)
        self.add_event_handler("message", self.message_handler)
        self.add_event_handler("groupchat_message", self.muc_message_handler)
        self.add_event_handler("disconnected",self.disconnected)

Por ultimo el constructor agrega los manejadores de eventos, es decir, los métodos que serán llamados cuando ocurra determinado evento. Aquí vemos solo cuatro eventos:

  • session_start
  • message
  • groupchat_message
  • disconnected

A los cuales se les asignan los métodos definidos en la clase según la tabla debajo:

Evento Método
session_start start(event)
message message_handler(xmpp_msg)
groupchat_message muc_message_handler(xmpp_msg)
disconnected disconnected()

El método start(event) es llamado cuando obtenemos el evento de inicio de sesión, es decir tenemos una conexión exitosa. Este método es bastante sencillo para un caso de uso simple, pero deberá modificarse para atender los requerimientos del caso de uso que estemos satisfaciendo:

    async def start(self, event):
        await self.get_roster()
        self.send_presence()
        self.plugin['xep_0045'].join_muc(self.room,
                                         self.nick)

Lo primero que hacemos es obtener el roster del jid usado y luego enviamos nuestra presencia al servidor para que la propague a todos los usuarios de nuestro roster. En este caso, como queremos ingresar a una sala de chat, usando el plugin correspondiente al estándar xep_0045 ingresamos a la sala definida en el atributo room con el nick definido en nick.

Manejar desconexiones es muy importante, existen varias razonas por las cuales nuestro programa puede perder la conexión y habrá que analizar cada caso, en principio vamos a atacar solo el evento disconnected intentando reconectarnos como lo hicimos en el método:

    def disconnected(self):
        self.reconnect()

Por ultimo, los métodos que suelen interesarnos más porque es por donde va a iniciarse todas nuestras interacciones, son los métodos asociados a los eventos de mensajes:

    def message_handler(self, msg):
    """
    Message handler
    """
    def muc_message_handler(self, msg):
    """
    MUC Message handler
    """

Como podemos ver ambos necesitan como parámetro el mensaje pero vamos a necesitar tener en cuenta varias cosas para evitar algunos problemas que se dan cuando nuestro bot envía respuestas, sobre todo en salas de chat. Antes de ponernos a ver estos detalles veamos la segunda parte de nuestro bot y luego analicemos un poco mas en profundidad nuestro caso de uso.

if __name__ == '__main__':

    jid="user@server.ltd"
    password="somerandomfakepassword"
    room="arandom@room.server.ltd"
    nick="xmppbotnick"

    xmpp = XMPPBot(jid, password, room, nick)
    xmpp.register_plugin('xep_0030') # Service Discovery
    xmpp.register_plugin('xep_0045') # Multi-User Chat

    xmpp.connect()
    asyncio.get_event_loop().run_forever()

En nuestro bloque de ejecución principal, simplemente creamos una instancia de nuestra subclase de XMPPClient, en este ejemplo sería XMPPBot, pasándole las credenciales del usuario que estará usando nuestro bot y el resto de opciones que puede variar dependiendo de nuestras necesidades.

Con el objeto instanciado, luego registramos los plugins que vamos a estar usando, en XMPPBot solo usamos XEP-0030 que provee descubrimiento de servicios y XEP-0045 para chat multiusuario. Por ultimo llamamos al método connect() e instruimos a ayncio para que ejecute el loop de eventos para siempre, esto implica que nuestro bot estará manejando eventos hasta que cancelemos la ejecución del bot. Vamos a detenernos un poco en el tema del event loop, entender el funcionamiento de asyncio y el event loop es de vital importancia para comprender como funciona nuestro bot.

Async.io

asyncio es un modulo Python que nos brinda un marco de trabajo para desarrollar programas asíncronos mediante el uso de coroutines, event loops y tasks. Permitiéndonos escribir código concurrente que es más eficiente y responsivo, especialmente cuando estamos hablando de operaciones de I/O.

El concepto de corrutinas (coroutines) es el corazón de asyncio, estas son funciones que pueden ser detenidas y resumidas. Cuando una corrutina se encuentra con un objeto awaitable (como una operación I/O), esta suspende su ejecución y permite la ejecución de la otra tarea. Esto permite que puedan planificarse y ejecutarse multiples tareas de forma concurrente, sin bloquear la ejecución del programa.

El coordinador central, que ejecuta estas corrutinas es el event loop, quien tiene la responsabilidad de gestionar y ejecutar operaciones asíncronas. El loop de eventos corre continuamente y planifica las corrutinas, realiza operaciones I/O y maneja los callbacks cuando algun objeto awaitable esta listo.

Algunos casos de uso que podemos resolver mediante el uso de asyncio son operaciones que requieran entra/salida asincronica, ejecución de callbacks o aplicaciones que requieran timers o deplays.

Transliteración de audio en salas de chat de múltiples usuarios (a.k.a: MUC)

Desde hace algunos años a esta parte, estamos viendo gran cantidad de usuarios que, comportándose como usuarios de NexTel, les encanta enviar mensajes de audio tanto en salas de chat como en chat uno a uno.

Esto implica que dejan afuera a gran cantidad de usuarios que carecen de los medios necesarios para escuchar mensajes de audio. Sea por una imposibilidad física, por que no cuentan con dispositivos, no pueden escuchar audio por el entorno donde están o simplemente prefieren leer a escuchar mensajes de audio... muchas veces interminables.

Con el surgimiento de los modelos de lenguaje masivos, han salido a la luz varias herramientas que resuelven muchos problemas que antes requerían largas horas de esfuerzo de desarrollo para tener resultados al menos pobres, siendo generoso.

Machine Learning

Whisper AI

Dentro de estos modelos Whisper AI resuelve justamente el caso de uso de transliteración. Su uso es muy simple, si queremos que sea simple ... sino, como todo, podemos hacerlo tan complejo como queramos. Pero, manteniendo la filosofía KISS del desarrollo de software (y diseño de sistemas de información en general) vamos a tratar de simplificar al máximo el uso de Whisper.

Si bien el modelo, como decíamos antes, nos brinda herramientas para complejizar un poco mas nuestro código. Por ejemplo, configurar de antemano el idioma o acortar el audio a una duración determinada. No vamos a usar estas características del modelo ya que como decíamos antes, nuestro objetivo es que sea lo mas simple posible. El siguiente bloque de código no es, ni más ni menos, que todo lo requerido para transliterar un audio:

import whisper
    async def transcribe(self, audio_file):
        """
        Returns audio transcription text
        """
        model = whisper.load_model(self.model)
        result = model.transcribe(audio_file)
        logging.info('File %s transliterated' % audio_file)
        return result["text"]

Habiendo importado el modulo whisper, lo primero que hacemos en nuestro método es cargar el modelo en memoria. Si el modelo no esta disponible, va a intentar descargarlo de internet, así que nos tenemos que asegurar que nuestro bot pueda alcanzar internet.

Luego, con el modelo cargado en memoria, llamamos al método transcribe que toma como parámetro el archivo de audio a transliterar y nos retorna un vector que tiene el texto resultante de la transliteración, entre otras cosas como el idioma detectado.

Ya prácticamente sabemos todo lo necesario para construir nuestro bot.

Johnny5

Johnny5

Primero veamos el código completo para luego analizarlo por partes:

#!/usr/bin/env python3

import logging
from getpass import getpass
from argparse import ArgumentParser
from slixmpp import ClientXMPP
from slixmpp.exceptions import IqError, IqTimeout
import asyncio
import whisper
import re
import os
import urllib.request
import time
import random



class WhisperBot(ClientXMPP):

    """
    A simple XMPP that transliterates audios with Whisper
    """

    def __init__(self, jid, password, room, nick, quotes, about, model):
        ClientXMPP.__init__(self, jid, password)

        self.room = room
        self.nick = nick

        self.quotes = quotes
        self.about = about
        self.model = model

        self.add_event_handler("session_start", self.start)
        self.add_event_handler("groupchat_message", self.muc_message)
        self.add_event_handler("disconnected",self.disconnected)

    async def start(self, event):
        await self.get_roster()
        self.send_presence()
        self.plugin['xep_0045'].join_muc(self.room,
                                         self.nick,
                                         )

    def disconnected(self):
        logging.info("reconnecting...")
        self.reconnect()


    def muc_message(self, msg):
        print("Got a message")
        if self.is_audio_msg(msg):
            logging.info('Got an audio from %s' % msg['mucnick'])
            try:
                loop = asyncio.get_running_loop()
            except RuntimeError:
                loop = None

            if loop and loop.is_running():
                print('Async event loop already running. Adding coroutine to '\
                      'the event loop.')
                tsk = loop.create_task(self.sendtranscription(msg))
                tsk.add_done_callback(
                        lambda t: print(f'Task done with result={t.result()}'))
        elif msg['mucnick'] != self.nick and self.nick in msg['body'] \
                               and "about" in msg['body']:
                self.send_message(mto=msg['from'].bare,
                                  mbody="%s" % self.abouttext(self.about),
                                  mtype='groupchat')
        elif msg['mucnick'] != self.nick and self.nick in msg['body']:
                self.send_message(mto=msg['from'].bare,
                                  mbody="%s" % self.randomtext(self.quotes),
                                  mtype='groupchat')

        print("Done")


    def abouttext(self, file_name):
        retval = "Number 5... is alive! and here to help with audio transliteration 🤖️"
        with open(file_name, 'r') as file:
            retval = file.readlines()
        return "🤖️ %s" % ' '.join(retval)


    def randomtext(self, file_name):
        retval = "Ops!"
        with open(file_name, 'r') as file:
            textlines = file.readlines()
            retval = random.choice(textlines)
        return "🤖️ %s" % retval


    async def sendtranscription(self, msg):
        rcv_time = time.strftime("%H:%M", time.localtime())
        audio_msg_file = self.get_audio(msg['body'])
        transcript = await self.transcribe(audio_msg_file)
        self.send_message(mto=msg['from'].bare,
                          mbody="💬 A las %s hrs., %s dijo: %s" \
                                  % (rcv_time, msg['mucnick'], transcript),
                          mtype='groupchat')
        self.rm_audio(audio_msg_file)
        logging.info('Transcription sent')


    def is_audio_msg(self, msg):
        body = msg['body']
        match = re.match(r'^https?://.+\.(m4a|mp3|ogg|opus)$', body)
        if not match:
            return False
        audio_url = match.group(0)

        # Check if the URL points to a valid audio file
        if audio_url.endswith(('.mp3', '.m4a', 'ogg', 'opus')):
            logging.info('Got an audio url!')
            return True

        return False


    def get_audio(self, url):
        save_directory = "/srv/downloads"

        if not os.path.exists(save_directory):
            os.makedirs(save_directory)

        filename = os.path.basename(url)
        file_path = os.path.join(save_directory, filename)
        urllib.request.urlretrieve(url, file_path)
        logging.info('File %s downloaded' % file_path)
        return file_path


    def rm_audio(self, file_path):
        try:
            os.remove(file_path)
            print(f"{file_path} has been deleted successfully!")
        except OSError as e:
            print(f"Error: {file_path} - {e.strerror}.")


    async def transcribe(self, audio_file):
        model = whisper.load_model(self.model)
        result = model.transcribe(audio_file)
        logging.info('File %s transliterated' % audio_file)
        return result["text"]



if __name__ == '__main__':
    parser = ArgumentParser()
    parser.add_argument("-q", "--quiet", help="set logging to ERROR",
                        action="store_const", dest="loglevel",
                        const=logging.ERROR, default=logging.INFO)
    parser.add_argument("-d", "--debug", help="set logging to DEBUG",
                        action="store_const", dest="loglevel",
                        const=logging.DEBUG, default=logging.INFO)
    parser.add_argument("-j", "--jid", dest="jid",
                        help="JID to use")
    parser.add_argument("-p", "--password", dest="password",
                        help="password to use")
    parser.add_argument("-r", "--room", dest="room",
                        help="MUC room to join")
    parser.add_argument("-n", "--nick", dest="nick",
                        help="MUC nickname")
    parser.add_argument("-u", "--quotes", dest="quotes",
                        help="quotes text file")
    parser.add_argument("-a", "--about", dest="about",
                        help="about text file")
    parser.add_argument("-m", "--model", dest="model",
                        help="Whisper model")
    args = parser.parse_args()
    logging.basicConfig(level=args.loglevel,
                        format='%(levelname)-8s %(message)s')
    if args.jid is None:
        args.jid = input("Username: ")
    if args.password is None:
        args.password = getpass("Password: ")
    if args.room is None:
        args.room = input("MUC room: ")
    if args.nick is None:
        args.nick = input("MUC nickname: ")

    xmpp = WhisperBot(args.jid, args.password, args.room,
                      args.nick, args.quotes, args.about, args.model)
    xmpp.register_plugin('xep_0030') # Service Discovery
    xmpp.register_plugin('xep_0045') # Multi-User Chat
    xmpp.register_plugin('xep_0199') # XMPP Ping
    xmpp.connect()
    asyncio.get_event_loop().run_forever()

Esta vez vamos a empezar analizando nuestro bloque principal de ejecución:

if __name__ == '__main__':
    parser = ArgumentParser()
    parser.add_argument("-q", "--quiet", help="set logging to ERROR",
                        action="store_const", dest="loglevel",
                        const=logging.ERROR, default=logging.INFO)
    parser.add_argument("-d", "--debug", help="set logging to DEBUG",
                        action="store_const", dest="loglevel",
                        const=logging.DEBUG, default=logging.INFO)
    parser.add_argument("-j", "--jid", dest="jid",
                        help="JID to use")
    parser.add_argument("-p", "--password", dest="password",
                        help="password to use")
    parser.add_argument("-r", "--room", dest="room",
                        help="MUC room to join")
    parser.add_argument("-n", "--nick", dest="nick",
                        help="MUC nickname")
    parser.add_argument("-u", "--quotes", dest="quotes",
                        help="quotes text file")
    parser.add_argument("-a", "--about", dest="about",
                        help="about text file")
    parser.add_argument("-m", "--model", dest="model",
                        help="Whisper model")
    args = parser.parse_args()
    logging.basicConfig(level=args.loglevel,
                        format='%(levelname)-8s %(message)s')
    if args.jid is None:
        args.jid = input("Username: ")
    if args.password is None:
        args.password = getpass("Password: ")
    if args.room is None:
        args.room = input("MUC room: ")
    if args.nick is None:
        args.nick = input("MUC nickname: ")

Agregamos al ejemplo anterior el procesamiento de argumentos enviados por linea de comandos y en vez de hardcodear los valores de las variables, asignamos lo que nos pasen como argumentos.

Lo próximo es instanciar nuestro bot pasándole como parámetros del constructor las variables que corresponden a nuestra clase, tal como lo hicimos en el ejemplo anterior:

    xmpp = WhisperBot(args.jid, args.password, args.room,
                      args.nick, args.quotes, args.about, args.model)
    xmpp.register_plugin('xep_0030') # Service Discovery
    xmpp.register_plugin('xep_0045') # Multi-User Chat
    xmpp.connect()
    asyncio.get_event_loop().run_forever()

Ahora a lo importante ... a nuestra clase WhisperBot, la cual expone la siguiente interface:

WhisperBot Class
Diagram

Empecemos viendo primero el constructor de la clase, así lo usamos de hilo conductor de nuestro análisis:

    def __init__(self, jid, password, room, nick, quotes, about, model):
        ClientXMPP.__init__(self, jid, password)

        self.room = room
        self.nick = nick

        self.quotes = quotes
        self.about = about
        self.model = model

        self.add_event_handler("session_start", self.start)
        self.add_event_handler("groupchat_message", self.muc_message)
        self.add_event_handler("disconnected",self.disconnected)

Esta vez, a diferencia del código boilerplate que usamos para introducirnos en el tema, nuestro constructor requiere mas parámetros, estos son:

  • jid y password:Credenciales de nuestro boot
  • room: jid de la sala MUC donde entrará nuestro bot
  • nick: Nickname que usará nuestro bot en la sala MUC
  • quotes: Path al archivo de texto donde obtener frases de Johnny 5
  • about: Path al archivo de texto que contiene la respuesta al comando about.
  • model: Modelo Whisper a ser usado (tiny, base, small, medium, large).

Asignamos estos parámetros a los atributos de la instancia y luego comenzamos a agregar los callbacks a los eventos que nos interesa manejar. El primero es el callback del evento session_start, que es idéntico al visto anteriormente, no agregamos ninguna lógica extra, lo mismo para el evento disconnected:

    async def start(self, event):
        await self.get_roster()
        self.send_presence()
        self.plugin['xep_0045'].join_muc(self.room,
                                         self.nick,)


    def disconnected(self):
        logging.info("reconnecting...")
        self.reconnect()

La mágia ocurre en el método muc_message, que maneja los eventos groupchat_message de la siguiente forma:

    def muc_message(self, msg):
        print("Got a message")
        if self.is_audio_msg(msg):
            logging.info('Got an audio from %s' % msg['mucnick'])
            try:
                loop = asyncio.get_running_loop()
            except RuntimeError:
                loop = None

            if loop and loop.is_running():
                print('Async event loop already running. Adding coroutine to '\
                      'the event loop.')
                tsk = loop.create_task(self.sendtranscription(msg))
                tsk.add_done_callback(
                        lambda t: print(f'Task done with result={t.result()}'))
        elif msg['mucnick'] != self.nick and self.nick in msg['body'] \
                               and "about" in msg['body']:
                self.send_message(mto=msg['from'].bare,
                                  mbody="%s" % self.abouttext(self.about),
                                  mtype='groupchat')
        elif msg['mucnick'] != self.nick and self.nick in msg['body']:
                self.send_message(mto=msg['from'].bare,
                                  mbody="%s" % self.randomtext(self.quotes),
                                  mtype='groupchat')

        print("Done")

Lo primero que hace es verificar si el mensaje es un mensaje de audio, en caso de no serlo y de que el mensaje contenga al nick del bot, es decir, es nombrado dentro de la sala de chat pueden pasar dos cosas:

  1. Si el mensaje contiene about, llama al metodo abouttext, que retorna el contenido del archivo que contiene el texto sobre el robot.
  2. Si el mensaje no contiene about, llama al método randomtext que retorna una frase al azar del archivo que contiene las frases.

Podemos ver que son métodos muy simples:

    def abouttext(self, file_name):
        retval = "Number 5... is alive! and here to help with audio transliteration 🤖️"
        with open(file_name, 'r') as file:
            retval = file.readlines()
        return "🤖️ %s" % ' '.join(retval)


    def randomtext(self, file_name):
        retval = "Ops!"
        with open(file_name, 'r') as file:
            textlines = file.readlines()
            retval = random.choice(textlines)
        return "🤖️ %s" % retval

Verificar si un mensaje es de audio o no, lo resolvemos usando expresiones regulares y verificando que la url del audio termine en las extensiones necesarias.

    def is_audio_msg(self, msg):
        body = msg['body']
        match = re.match(r'^https?://.+\.(m4a|mp3|ogg|opus)$', body)
        if not match:
            return False
        audio_url = match.group(0)

        # Check if the URL points to a valid audio file
        if audio_url.endswith(('.mp3', '.m4a', 'ogg', 'opus')):
            logging.info('Got an audio url!')
            return True

        return False

Ahora, en el caso de que si sea un mensaje de audio nos topamos con el primer problema, si nosotros obtenemos el archivo y he iniciamos el proceso de transcripción, nuestro bot dejara de responder y no le retornara el control a nuestro blucle de eventos. Perdemos la capacidad de atajar nuevos eventos, un fallo catastrófico cuando estamos hablando de bots.

Para eso vamos a tener que bucear dentro del bucle de eventos para que nuestra tarea de transcripción pueda ser gestionada por el event loop. En caso de estar en el ámbito correcto y que tengamos un event loop ejecutándose, creamos una tarea asíncrona desde el mismo bucle en ejecución:

                tsk = loop.create_task(self.sendtranscription(msg))
                tsk.add_done_callback(
                        lambda t: print(f'Task done with result={t.result()}'))

El método sendtranscripiton es un método asíncrono y es definido de la siguiente forma:

    async def sendtranscription(self, msg):
        rcv_time = time.strftime("%H:%M", time.localtime())
        audio_msg_file = self.get_audio(msg['body'])
        transcript = await self.transcribe(audio_msg_file)
        self.send_message(mto=msg['from'].bare,
                          mbody="💬 A las %s hrs., %s dijo: %s" \
                                  % (rcv_time, msg['mucnick'], transcript),
                          mtype='groupchat')
        self.rm_audio(audio_msg_file)
        logging.info('Transcription sent')

Primero obtenemos la hora y minuto que fue enviado el mensaje para poder darle algo de temporalidad al mensaje de respuesta ya que dependiendo del tamaño del audio enviado el bot tardara mas o menos tiempo y es una forma de poder organizar los mensajes cuando hay muchos mensajes de audio.

Luego descargamos el archivo de audio y llamamos al método transcribe que es bloqueante y es donde efectivamente ocurre la transliteración usando whisper:

    async def transcribe(self, audio_file):
        model = whisper.load_model(self.model)
        result = model.transcribe(audio_file)
        logging.info('File %s transliterated' % audio_file)
        return result["text"]

Una ves devuelta la transcripción del audio, el método sendtranscription envía, de manera completamente asíncrona, el mensaje al canal MUC donde el bot esta participando.

Con esto completamos nuestra primer aproximación al desarrollo de aplicaciones que utilicen XMPP. En el próximo post vamos a ver como solucionar un problema de conexión que se da en algunos escenarios, mientras tanto pueden seguir el avance de este proyecto en nuestro repositorio en Codeberg.

Volver