Monitorizar web con Python y Telegram 3

En los artículos anteriorer de como Monitorizar una web con Python y que nos avise por Telegram (Monitorizar web con Python y Telegram 1), hemos hablado de como crear un script, y nos falta lo más importante, el bot de telegram, el monitor y el ciclo de vida del script.

Script en Python

Python y Telegram: Crear el script

En estos punto vamos a proceder a crear el script, es importante ya que la intención no es llegar a desplegar una aplicación, sino tener un ejemplo de como con un script se pueden realizar tareas sin necesidad de desplegar aplicaciones que nos pueden llevar incluso más tiempo el asegurar su comportamiento.

Conectar con Telegram

El momento correcto de conectar con Telegram es el momento en el que levantamos el contexto del demonio que hemos visto en Monitorizar web con Python y Telegram 2, para ello vamos a realizar los siguiente cambios:

with context:
    logger = getLogger()
    updater = Updater(token=cfg['TOKEN'], use_context=True)
    dispatcher = updater.dispatcher
    start_handler = CommandHandler('start', start)
    dispatcher.add_handler(start_handler)
    status_handler = CommandHandler('status', status)
    dispatcher.add_handler(status_handler)
    token_handler = CommandHandler('token', token)
    dispatcher.add_handler(token_handler)
    restart_handler = CommandHandler('restart', restart)
    dispatcher.add_handler(restart_handler)
    check_thread = threading.Thread(
        target=monitorProc, args=(updater, logger, ))
    check_thread.start()
    do_main_program(logger, updater)

Podemos ver una definición principal que es updater, este objeto se utilizará en todas las llamadas realizadas a Telegram, el segundo punto es el uso del dispacher para definir comandos del bot y que función python va a ejecutar la respuesta a dicho comando del bot, definimos 4 comandos para el bot:

start

Monitorizar web con Python y Telegram 3 1

Esta función debe mostrar un mensaje de presentación cuando se accede por primera vez al bot o cuando se ejecuta directamente. Lo definimos en el contexto con:

dispatcher = updater.dispatcher
    start_handler = CommandHandler('start', start)
    dispatcher.add_handler(start_handler)

Y podemos ver como añadimos por un lado un manejador de los comandos del bot “CommandHandler” cuyos argumentos son el comando del bot y la función que se llamará cuando el bot reciba dicho comando:

def start(update, context):
    update.message.reply_text(
        'Hello {}'.format(update.message.from_user.first_name))
    if monitor():
        context.bot.send_message(
            chat_id=update.effective_chat.id,
            text=cfg['MONITOR_NAME'] + ". Status: OK")
    else:
        context.bot.send_message(
            chat_id=update.effective_chat.id,
            text=cfg['MONITOR_NAME'] + ". Status: ERROR")

La función start devuelve dos mensajes, uno directamente a través de reply (update.message.reply_text) y un segundo con el estado del monitor a través del identificador del canal (no un reply, sino un mensaje directo al canal) con la función context.bot.send_message(chatid, mensaje).

status

Monitorizar web con Python y Telegram 3 2

Para el estado no vamos a diferenciar con el caso anterior y simplemente comprueba el monitor y envía un mensaje directo al canal.

def status(update, context):
    if monitor():
        context.bot.send_message(
            chat_id=update.effective_chat.id,
            text=cfg['MONITOR_NAME'] + " Status: OK")
    else:
        context.bot.send_message(
            chat_id=update.effective_chat.id,
            text=cfg['MONITOR_NAME'] + " Status: ERROR")

token

Monitorizar web con Python y Telegram 3 3

Para la función de ejecutar voy a plantear un pequeño mecanismo de interacción que compruebe la validez del usuario, para ello el usuario pedirá un token (aleatorio) por el canal y como solo el bot y el usuario comparten el grupo (previamente definido en la configuración), le llegará dicho token (de un solo uso) al grupo.

def generateToken(len=8):
    letters = string.ascii_letters
    return ''.join(random.choice(letters) for i in range(len))


def token(update, context):
    global token
    token = generateToken()
    logger.info(cfg['MONITOR_NAME'] + " - Generate token: %s" % token)
    context.bot.send_message(chat_id=cfg['CHAT_ID'], text="%s" % token)

restart

Como podemos ver en el caso anterior, una vez recibido el token podemos usarlo para ejecutar un comando (en mi caso de reinicio del servicio web para la recarga de la capa SSL).

def restart(update, context):
    global token
    msgText = update.effective_message.text
    if token is not None and msgText == "/restart %s" % token:
        logger.info(cfg['MONITOR_NAME'] + " - Restart: %s" % msgText)
        subprocess.call(cfg['CMD'], shell=True)
        update.message.reply_text("Restart Completed")
    else:
        logger.info(cfg['MONITOR_NAME'] + " - Incorrect Token")
        update.message.reply_text("Incorrect Token")
    token = None

Monitor

La función monitor se ejecuta en su propio hilo y aunque puede extenderse para conectarse a otras herramientas y comprobar cualquier elemento que necesitemos, solo vamos a realizar una comprobación de GET la URL, debe dar un estado OK (200), en caso contrario devuelve que ha habido un error (falso).

Como se puede ver la función es realmente simple:

def monitor():
    try:
        http = urllib3.PoolManager()
        response = http.request('GET', cfg['MONITOR_URL'])
        if response.status != 200:
            return False
        return True
    except urllib3.exceptions.HTTPError:
        return False

Iniciar, parar y estado

En el arranque de la aplicación, antes de entrar en el contexto del demonio, debemos comprobar el ciclo de vida de la misma (inicio, comprobar, recargar, parar), que realizaremos con el siguiente código:

if (
    (len(sys.argv) == 3) and
    (sys.argv[2] in ['start', 'stop', 'reload', 'status'])
):
    cfg = readConf(sys.argv[1])
    updater = Updater(token=cfg['TOKEN'], use_context=True)
    pid = TimeoutPIDLockFile(cfg['PIDFILE'], cfg['LOCK_WAIT_TIMEOUT'])
    if sys.argv[2] == 'stop':
        if pid.is_locked():
            pidNumber = pid.read_pid()
            os.kill(pidNumber, signal.SIGHUP)
            sleep(15)
            if psutil.pid_exists(pidNumber):
                os.kill(pidNumber, signal.SIGTERM)
                sleep(5)
                if psutil.pid_exists(pidNumber) or pid.is_locked():
                    sys.stderr.write(
                        cfg['MONITOR_NAME'] + " Bot can't be stopped")
                    sys.exit(1)
        sys.exit(0)
    elif sys.argv[2] == 'reload':
        if pid.is_locked():
            os.kill(pid.read_pid(), signal.SIGUSR1)
        sleep(5)
        sys.exit(0)
    elif sys.argv[2] == 'status':
        if pid.is_locked():
            sys.stdout.write(
                "%s Bot Active with PID: %s,%s" %
                (cfg['MONITOR_NAME'], pid.read_pid(), pid.is_locked()))
            sys.exit(0)
        else:
            sys.stdout.write(cfg['MONITOR_NAME'] + " Bot No Active")
            sys.exit(1)
    elif sys.argv[2] != 'start':
        sys.stderr.write(
            "Command must be %s CONFIGFILE.yml [start|stop|status|reload]\n"
            % sys.argv[0])
        sys.stderr.write(
            "If is used without arguments then start is selected\n" %
            sys.argv[0])
        sys.exit(1)
else:
    sys.stderr.write(
        "Command must be %s CONFIGFILE.yml [start|stop|status|reload]\n"
        % sys.argv[0])
    sys.exit(1)

Es muy importante en las comprobaciones que solo exista una instancia de la aplicación corriendo, ya que solo una puede conectarse a la vez a Telegram. Para ello utilizamos pid que es el identificador del proceso devuelto por TimeoutPIDLockFile y que podemos comprobar si está aún activa la aplicación y bloqueada.

Este mismo identificador (pid) nos servirá para enviar señales a la aplicación, que recibirá y lanzarán los procesos de apagado ordenado y recarga en su caso.

if pid.is_locked() and not pid.i_am_locking():
    logging.warning("%s is locked, but not by me" % cfg['PIDFILE'])
    sys.exit(1)

La funciones que hemos definido cuando hemos hablado del contexto serán las que reciban las señales, como el caso de end_program, que responde a SIGHUP, lanzado por

            pidNumber = pid.read_pid()
            os.kill(pidNumber, signal.SIGHUP)

Y recibido por

def end_program(signum, frame):
    logger.warning(cfg['MONITOR_NAME'] + " - SIGHUP - Terminate")
    check_thread.noStop = False
    check_thread.join()
    logger.warning(cfg['MONITOR_NAME'] + " - SIGHUP - Close Monitor")
    updater.stop()
    logger.warning(
        cfg['MONITOR_NAME'] + " - SIGHUP - Close Telegram Connection")
    sys.exit(0)

Un detalle muy importante es como paramos ordenadamente, primero paramos el hilo con

    check_thread.noStop = False
    check_thread.join()

Y la conexión a Telegram con updater.stop().

Systemd

Por último queremos que el script se ejecute al iniciar el equipo o podamos controlar su estado desde sistema ponemos el siguiente contenido en el archivo /etc/systemd/system/NOMBRE.service

[Unit]
Description=Minimal Systemd configuration
After=network.target

[Service]
ExecStart=command CONFIGFILE.yml start
ExecStop=command CONFIGFILE.yml stop
Restart=always
StartLimitInterval=0
RestartSec=10
      
[Install]
WantedBy=multi-user.target

Para instalar la configuración ejecutamos systemctl daemon-reload y systemctl enable NOMBRE && systemctl start NOMBRE

Volver arriba