Kabosu - Creando cosas

Logo de la página. Gato esférico con colores verdoso.

Bot de traducción para Telegram

Publicado: 2024-05-18 (actualizado 2024-08-2)

Etiquetas: IA, Proyectos, Python, Telegram


Hace un año me encontré en la situación de que se habían juntado en un canal de Telegram dos grupos de familiares que no hablaban ningún idioma común. Algunas dominaban el español y otras el inglés por lo que no podían comunicarse directamente entre ellas.

Se me ocurrió crear un bot en Python y meterlo en el canal que hiciera de traductor simultáneo: si alguien escribía en español el bot añadiría la traducción al inglés y viceversa. Ha pasado ya un año y se ha usado bastante. He estado actualizando las dependencias del proyecto a las últimas versiones así que aprovecho para explicar sus entresijos aquí en el blog.

En la siguiente sección de este artítulo voy a explicar cómo crear un bot de Telegram que procese todos los mensajes de uno o más chats. Si solo te interesa la parte de Telegram puedes saltarte la sección "Traductor con OpenAI" pero echa un vistazo a la sección "Mejorando el bot" que viene después.

Bot de Telegram

Crear un bot de Python es relativamente sencillo. Hay librerías para multitud de lenguajes. En mi caso yo utilizo python-telegram-bot que, como su nombre indica, permite crear bots en Python.

Definir el bot

Para poder integrar un bot en Telegram necesitamos darle un nombre y configurarlo. Esto se hace interactuando con un bot oficial que tiene la plataforma llamado @BotFather. Tenemos que abrir la aplicación, escribir BotFather en el buscador y empezar a hablarle.

Para crear un bot nuevo hay que enviarle el comando:

/newbot

Tras esto nos preguntará el nombre del bot:

Alright, a new bot. How are we going to call it? Please choose a name for your bot.

Le damos un nombre:

TraducionBot

Ahora pide el nombre de usuario del bot:

Good. Now let's choose a username for your bot. It must end in bot. Like this, for example: TetrisBot or tetris_bot.

Por lo que sé, no es posible crear bots privados en Telegram. Se puede acceder a cualquier bot a través del buscador de contactos si conoces su nombre y enviarle mensajes. Es por eso que cuando creo uno suelo ponerle un nombre que tenga caracteres aleatorios para minimizar la probabilidad de que alguien lo encuentre. Por ejemplo:

hilewurqoiweoiweBot

Con esto se creará el bot y nos dará el token secreto. Hay que escribirlo en un lugar seguro para poder usarlo luego.

Done! Congratulations on your new bot. You will find it at t.me/lewurqoiweoiweBot. You can now add a description, about section and profile picture for your bot, see /help for a list of commands. By the way, when you've finished creating your cool bot, ping our Bot Support if you want a better username for it. Just make sure the bot is fully operational before you do this.

Use this token to access the HTTP API:

EL TOKEN SECRETO

Keep your token secure and store it safely, it can be used by anyone to control your bot.

For a description of the Bot API, see this page: https://core.telegram.org/bots/api

Un bot por defecto solo recibe mensajes que empiecen por "/". Esto está pensado para bots que procesan comandos. Mi objetivo era hacer un bot que todos tradujera los mensajes de un chat de grupo así que tuve que configurarlo para que recibiera todos los mensajes con

/setprivacy

Nos pide el nombre del bot que queremos configurar:

Choose a bot to change group messages settings.

En este ejemplo sería:

@lewurqoiweoiweBot

BotFather nos escribe las opciones que tiene el comando /setprivacy y qué hace cada una.

'Enable' - your bot will only receive messages that either start with the '/' symbol or mention the bot by username.

'Disable' - your bot will receive all messages that people send to groups.

Current status is: ENABLED

Yo quiero que mi bot reciba todo los mensajes así que tengo que contestarle:

Disable

Y con esto ya está.

Success! The new status is: DISABLED. /help

Ahora ya podemos empezar a escribir el código del bot e integrarlo en la plataforma de Telegram utilizando el token que nos ha proporcionado BotFather.

El código básico del bot

Hacer un bot que escuche todos los mensajes de un chat es muy sencillo utilizando la librería python-telegram-bot. Solo hay que definir una función que se va a ejecutar para cada mensaje que se escriba. Yo la llamé start como en la documentación:

async def start(update: Update, context: ContextTypes.DEFAULT_TYPE):
	print(update)

Para instanciar el bot hay que crear un objeto de la clase Application al que le pasaremos el token. Luego crear un MessageHandler para decirle al bot "oye, para cada mensaje que te llegue ejecuta la función start". Finalmente ponerse escuchar mensaje.

TOKEN = '.....el token que nos ha dado BotFather...'
application = ApplicationBuilder().token(TOKEN).build()

message_handler = MessageHandler(filters=None, callback=start)
application.add_handler(message_handler)

application.run_polling()

No es imprescindible pero para poder ver mensajes de log del bot mientras se está ejecutando tenemos que añadir esta configuración al principio del script de Python.

import logging

logging.basicConfig(
	format='%(asctime)s - %(name)s - %(levelname)s - %(message)s',
	level=logging.INFO
)

Tras esto, al ejecutar el script de Python veremos como aparece el mensaje "Application started":

(python_env) x@lenovo:~/proj/telegram$ python3 bot_example.py
2024-05-10 16:46:11,248 - httpx - INFO - HTTP Request: POST https://api.telegram.org/.../getMe "HTTP/1.1 200 OK"
2024-05-10 16:46:11,284 - httpx - INFO - HTTP Request: POST https://api.telegram.org/.../deleteWebhook "HTTP/1.1 200 OK"
2024-05-10 16:46:11,287 - telegram.ext.Application - INFO - Application started

Para probar el bot hay que abrir el cliente de Telegram, buscar el bot por su nombre de usuario e iniciar un chat con él. La primera vez hay que decirle "/start" o pulsar el botón para activarlo.

En la terminal que está ejecutando el bot vemos que aparecerá un objeto Update con mucha información. He borrado casi toda la información para que sea más claro. Básicamente Update tiene dentro un objeto Message y este a su vez tiene un campo text que incluye el mensaje que ha recibido el bot.

Update(message=Message(text='/start', ....), ...)

Podemos enviarle más mensajes y para cada uno aparecerá un objeto Update nuevo en la terminal. Gracias a imprimir por pantalla la actualización recibida podemos ver qué información contiene. Como he comentado, el mensaje que recibe el bot está en update.message.text.

Para que el bot pueda contestar hay que usar la funcion send_message. Dado que un bot puede estar hablando con varias personas y estar en múltiples chats de grupo al mismo tiempo hay que indicar el chat_id para identificar a quién está contestando. Analizando cualquier objeto Update que haya recibido el bot vemos que el chat_id se encuentra en update.message.chat.id. Hay que añadir estas dos líneas a la función start para que conteste cada mensaje recibido con un "Hola, soy el bot".

msg = 'Hola, soy el bot'
await context.bot.send_message(chat_id=update.message.chat.id, text=msg,
        	parse_mode=ParseMode.MARKDOWN_V2,)

Con esto ya tenemos un bot funcional que recibe mensajes pero que siempre contesta con el mismo mensaje. Ahora la gracia está en que en vez de un mensaje fijo pueda contestar distintas cosas. En mi caso yo quiero que traduzca el mensaje.

Traductor con OpenAI

Mi idea inicial era usar un software de traducción como LibreTranslate como parte del bot pero parecía que requería más potencia de la que la Raspberry Pi que uso de servidor puede ofrecer así que empecé a mirar APIs de traducción.

Hay varias APIs específicas de traducción como DeepL API, Google Cloud Translation API o Amazon Translate que consideré usar para este proyecto. Las dos primeras pueden traducir cierta cantidad de texto de forma gratuita cada mes así que descarté rápidamente la opción de Amazon.

Lo que había que hacer era sencillo:

Identificar el idioma en que está escrito un texto no es excesivamente complicado. Lo he implementado manualmente varias veces en el pasado y estoy seguro de que librerías como NLTK permiten hacerlo con muy pocas líneas de código.

Al final, para terminar antes (este bot me costó 1 o 2 horas de hacer), decidí usar un modelo GPT de la API de OpenAI. Este tipo de modelos de lenguaje no son propiamente modelos de traducción pero gracias a que han sido entrenado con ingentes cantidades de datos son capaces de traducir textos medianamente bien. He de decir que desde que creé este bot estoy bastante desencantado con las acciones de OpenAI y sus dirigentes así que en cuanto se termine el dinero que puse para probar es muy posible que implemente la traducción de otra forma.

Integrando las APIs

Para usar la API de OpenAI hay que crearse una cuenta en su web y obtener una clave de API. Es posible que te den algo de crédito gratuito, si no es así tendrás que cargar algunos dólares con la tarjeta de crédito. Una vez tenemos la API key hay ya podemos empezar a escribir el código:

from openai import OpenAI

PROMPT = '''
If the text provided is in {language1} translate it to {language2}.
If it is in {language2} translate it to {language1}.
Don't write anything else. Just the translation.
The text:
{text}
'''

class Translator:

    def __init__(self):
        self.client = OpenAI(
            api_key='...TOKEN DE OPENAI...',
        )

    def translate(self, text):
        prompt = PROMPT.format(
            language1='Spanish',
            language2='English',
            text=text)

        completion = self.client.chat.completions.create(
                model='gpt-3.5-turbo',
                messages=[
                    {
                        'role': 'user',
                        'content': prompt,
                    }
                ])
        result = completion.choices[0].message.content

        print(result)
        print(dict(completion).get('usage'))
        return result

if __name__ == '__main__':
    translator = Translator()
    print(translator.translate('hola. ¿qué tal?'))

No doy mucho detalle porque, como he dicho, no estoy muy a favor de OpenAI en estos momentos pero el código no es muy complicado si sabes algo de Python.

El prompt

Sí que quiero hablar del prompt:

PROMPT = '''
If the text provided is in {language1} translate it to {language2}.
If it is in {language2} translate it to {language1}.
Don't write anything else. Just the translation.
The text:
{text}
'''

La magia de los LLM tipo GPT es que son capaces de procesar instrucciones. Les explicas lo que quieres que hagan y lo intentarán hacer. En el pasado para usar un modelo de IA debías de recopilar datos, etiquetarlos, entrenar, evaluar, etc. Obviamente un modelo entrenado explícitamente para una tarea debería funcionar mejor que un modelo genérico como GPT pero te ahorra un montón de tiempo.

Primero le doy las instrucciones, traduce entre dos idiomas, y al final del mismo prompt le doy el texto a traducir. Este tipo de IA es muy impredecible, no sabes muy bien qué está haciendo internamente, por lo que crear un prompt es un poco prueba y error. Por ejemplo, tuve que añadir la instrucción "Don't write anything else. Just the translation." porque la IA añadía comentarios tipo:

De acuerdo, la traducción de la frase es "hola, qué tal"

Al final este prompt ha funcionado razonablemente bien aunque en algunas veces escribe cosas que no debería. En un par de ocasiones ha traducido al portugués en vez al español sin motivo aparente.

Mejorando el bot

En esta sección voy a explicar algunos problemas que tuve con el bot y cómo los solucioné. No están propiamente relacionados con el traductor si no que son detalles de implementación del protocolo de Telegram.

¿Dónde está el objeto Message

El código que escribimos para el bot recibe no solo los mensajes que envía la gente sino también ediciones de mensajes anteriores. Por ejemplo si alguien ha escrito un mensaje y luego lo edita. Estos últimos mensajes tienen un formato ligeramente distinto y pueden provocar fallos en el bot.

Para mensaje normales el bot recibe un objeto de tipo Update con un campo message que contiene el nuevo mensaje. En el caso que evento sea un mensaje editado, el Update tendrá el mensaje en el campo edited_message.

Este es el código que uso en mi función start para encontrar el mensaje independiente de si viene de un mensaje nuevo o uno editado.

    message = update.message
    if message is None:
        message = update.edited_message
    if message is None:
        return

¿Dónde está el texto?

De la misma forma que el objeto Message puede estar en dos sitios diferentes. El propio texto dentro del mensaje también es escurridizo: si recibimos un mensaje de texto normal el texto estará en el campo text pero si recibimos un mensaje con foto tendremos que buscar en el campo caption. Este es el código que uso yo:

        text = message.text
        if text is None:
            text = message.caption
        if text is None:
            return

Limitando el uso del bot

No parece haber forma de hacer un bot privado en Telegram así que si creamos uno estará expuesto a que cualquier persona interactúe con él. En mi caso estoy utilizando la API de OpenAI que acarrea un coste por mensaje por lo que quiero limitar el uso que hace la gente él.

Una forma sencilla que he encontrado de proteger el bot es mirar el chatId del mensaje e ignorar cualquier mensaje que no provenga de una lista de chat ids que yo he escrito. El código es el siguiente:

    if message.chat.id not in [-1, -2...]:
        new_msg = 'invalid chat id'
    else:
        # Traducir
        ...
		
    await context.bot.send_message(chat_id=update.effective_chat.id, text=new_msg,
            parse_mode=ParseMode.MARKDOWN_V2,)

Cuando quiera añadir el bot a un chat nuevo tendré que escribir un mensaje de ese canal, mirar en los logs del bot cuál es el chat_id y actualizar la lista. Parece ser que los chats privados utilizan ids positivos y los chats de grupo tienen números negativos.

Sintaxis de Markdown

Telegram por defecto interpreta los mensajes como Markdown. Por tanto, si escribimos un mensaje que contenga alguno de los símbolos que se usan en la sintaxis de Markdown (como _, *, etc) en el mejor de los casos mostrará algo raro y en el peor dará error y el mensaje no se enviará.

Para solucionar este problema que escapar todos los caracteres especiales. Es decir, ponerles delante un \. Para ello he creado una función llamada escapeEverything:

def escapeEverything(text):
    seqs = ['_', '*', '[', ']', '(', ')', '~', '`', '>', '#', '+', '-', '=',
            '|', '{', '}', '.', '!']
    for s in seqs:
        text = text.replace(s, '\\'+s)
    return text

Esta función se ha de aplicar al mensaje antes de enviarlo con send_message

        msg = tr.translate(text)
        msg = escapeEverything(msg)

La función start

Como resumen, este el código completo de la función start que procesa los mensajes que recibe el bot:

async def start(update: Update, context: ContextTypes.DEFAULT_TYPE):
    print(type(update))
    print(update)
    message = update.message
    if message is None:
        message = update.edited_message
    if message is None:
        return

    if message.chat.id not in [...]:
        new_msg = 'invalid chat id'
    else:
        text = message.text
        if text is None:
            text = message.caption
        if text is None:
            return
        user = escapeEverything(message.from_user.first_name)
        msg = tr.translate(text)
        msg = escapeEverything(msg)

        new_msg = f'*{user}*: _{msg}_'
        print('next message:', new_msg)

    await context.bot.send_message(chat_id=update.effective_chat.id, text=new_msg,
            parse_mode=ParseMode.MARKDOWN_V2,)

Artículo siguiente: Kabosu (el perro) ha muerto
Artículo anterior: Coloreado de sintaxis para el blog