30

Bash y Notificaciones, alerta desde Scripts

Vistas: 0
Tutorial: Scripts en Bash
Bash y Notificaciones, alerta desde Scripts

Como te comenté en el capítulo anterior, después de generar reportes automáticos toca asegurarse de que lleguen a quien los necesita. Tus scripts trabajan en segundo plano. Hacen backups de madrugada, verifican healthchecks cada minuto, despliegan código a las 3 AM. Cuando algo falla — o incluso cuando todo sale bien — necesitas saberlo sin tener que mirar manualmente.

Las notificaciones desde Bash transforman scripts mudos en sistemas alertantes:

  • Backup nocturno fallido → notificación urgente por Telegram + email
  • Healthcheck de servicio caído → alerta crítica con sonido
  • Cron diario exitoso → notificación informativa por email

Bash no tiene una librería de notificaciones, pero puede hablar con cualquier servicio que tenga una API HTTP, un cliente CLI o un protocolo de red.

¿Por qué notificar desde Bash?

Tus scripts trabajan en segundo plano. Hacen backups de madrugada, verifican healthchecks cada minuto, despliegan código a las 3 AM, rotan logs al mediodía. Cuando algo falla — o incluso cuando todo sale bien — necesitas saberlo sin tener que mirar manualmente.

Las notificaciones desde Bash transforman scripts mudos en sistemas alertantes:

  • Backup nocturno fallido → notificación urgente por Telegram + email + desktop
  • Healthcheck de servicio caído → alerta crítica con SMS + sonido + wall
  • Cron diario exitoso → notificación informativa por email
  • Despliegue completado → mensaje a Slack/Discord del equipo

Bash no tiene una librería de notificaciones, pero puede hablar con cualquier servicio que tenga una API HTTP, un cliente CLI o un protocolo de red. En este capítulo aprenderás a integrar todos los canales de notificación desde un solo script configurable:

CanalHerramientaIdeal para
Desktopnotify-sendUsuario local en escritorio
Sonidopaplay, aplayAlertas audibles
Sistemawall, write, loggerTodos los usuarios del sistema
Emailmail, mutt, ssmtpNotificaciones formales con adjuntos
Telegramcurl + Bot APINotificaciones móviles en tiempo real
Webhookcurl + JSONEquipos en Slack/Discord/Mattermost
Pushcurl + ntfy/GotifyPush móvil auto-gestionado
SMScurl + TwilioAlertas críticas donde sea

Niveles de notificación

Cuatro niveles estándar

Antes de escribir una sola función de envío, define los niveles que usarán todas las notificaciones:

NOTIFY_LEVELS=("INFO" "WARN" "ERROR" "CRITICAL")
NivelPrioridad¿A quién?Canales típicos
INFOBajaSolo log / emailEmail, logger, desktop si verbose
WARNMediaOperadorEmail, Telegram, desktop
ERRORAltaAdministradorTelegram, webhook, push, SMS
CRITICALMáximaTodos + guardiaSMS, sonido, wall, todos los canales

Decisión de canal por nivel

# Ejemplo de lógica de enrutamiento
notify_dispatch() {
    local level="$1"
    local message="$2"

    case "$level" in
        INFO)     notify_email "$message" ;;
        WARN)     notify_email "$message"
                  notify_telegram "$message" ;;
        ERROR)    notify_telegram "$message"
                  notify_webhook "$message"
                  notify_desktop "$message" ;;
        CRITICAL) notify_telegram "$message"
                  notify_webhook "$message"
                  notify_sms "$message"
                  notify_sound "$message"
                  notify_wall "$message" ;;
    esac
}

Thresholds configurables

# Solo notificar si el nivel es >= al threshold configurado
NOTIFY_THRESHOLD="WARN"  # INFO, WARN, ERROR, CRITICAL

nivel_numerico() {
    case "$1" in
        INFO)     return 0 ;;
        WARN)     return 1 ;;
        ERROR)    return 2 ;;
        CRITICAL) return 3 ;;
        *)        return 0 ;;
    esac
}

if nivel_numerico "$level" -ge nivel_numerico "$NOTIFY_THRESHOLD"; then
    notify_dispatch "$level" "$message"
fi

Notificaciones desktop

notify-send — Urgencia, expiración, iconos

notify-send es el comando estándar de Linux para mostrar notificaciones en el escritorio. Forma parte de libnotify-bin.

# Instalación
sudo apt install libnotify-bin   # Debian/Ubuntu
sudo dnf install libnotify        # Fedora
sudo pacman -S libnotify          # Arch

# Uso básico
notify-send "Título" "Mensaje de prueba"

# Con urgencia: low, normal, critical
notify-send -u critical "¡Alerta!" "El disco está al 95%"

# Con tiempo de expiración en milisegundos
notify-send -t 5000 "Notificación" "Desaparece en 5 segundos"

# Con icono personalizado
notify-send -i dialog-warning "Cuidado" "Backup no verificado"
notify-send -i /ruta/al/icono.png "Backup" "Completado"

# Reemplazar notificación anterior (same ID)
notify-send -p -r 1234 "Progreso" "50%"   # -p imprime el ID
notify-send -r 1234 "Progreso" "100%"

Iconos comunes del sistema:

IconoContexto
dialog-informationInfo general
dialog-warningAdvertencia
dialog-errorError
media-playback-stopDetenido
media-recordGrabando / en progreso
emblem-importantImportante

Sonidos del sistema

Las notificaciones visuales no sirven si no estás mirando la pantalla. Los sonidos te alertan aunque estés en otra aplicación.

# paplay — PulseAudio (Linux moderno)
paplay /usr/share/sounds/freedesktop/stereo/complete.oga
paplay /usr/share/sounds/freedesktop/stereo/dialog-warning.oga
paplay /usr/share/sounds/freedesktop/stereo/dialog-error.oga

# aplay — ALSA (alternativa sin PulseAudio)
aplay /usr/share/sounds/alsa/Front_Center.wav

# speaker-test — Pitido genérico (no necesita archivo)
speaker-test -t sine -f 1000 -l 1 2>/dev/null   # kHz, 1 segundo
speaker-test -t pink -l 1 2>/dev/null             # Ruido rosa

# beep — Altavoz interno de la placa (necesita módulo pcspkr)
sudo modprobe pcspkr
beep -f 800 -l 200 -r 3   # Hz, 200ms, 3 veces

Sonidos del theme freedesktop:

SONIDOS=(
    "/usr/share/sounds/freedesktop/stereo/complete.oga"
    "/usr/share/sounds/freedesktop/stereo/dialog-warning.oga"
    "/usr/share/sounds/freedesktop/stereo/dialog-error.oga"
    "/usr/share/sounds/freedesktop/stereo/bell.oga"
    "/usr/share/sounds/freedesktop/stereo/message-new-instant.oga"
)

notify_sound() {
    local level="$1"
    case "$level" in
        INFO)     paplay "${SONIDOS[0]}" 2>/dev/null ;;
        WARN)     paplay "${SONIDOS[1]}" 2>/dev/null ;;
        ERROR)    paplay "${SONIDOS[2]}" 2>/dev/null ;;
        CRITICAL) paplay "${SONIDOS[2]}" 2>/dev/null
                  paplay "${SONIDOS[4]}" 2>/dev/null ;;
    esac
}

wall y write — Mensajes a todos los usuarios

wall (write all) envía un mensaje a todas las terminales de todos los usuarios del sistema. write envía a un usuario concreto.

# Mensaje a todos los usuarios
wall "⚠ El servidor se reiniciará en 5 minutos. Guarda tu trabajo."

# Con origen identificado
echo "Backup completado a las $(date)" | wall -n  # -n suprime el banner

# Mensaje a un usuario específico
write lorenzo <<< "El backup de tu home ha finalizado"

# Solo si el usuario tiene mesg habilitado
if mesg y 2>/dev/null; then
    write lorenzo <<< "Notificación urgente"
fi

⚠ wall requiere permisos de root o mesg y en sistemas modernos. En systemd, los mensajes viajan por logind y pueden no aparecer en todos los terminales.

Email desde Bash

mail/mailx — El clásico

El comando mail (o mailx) es el estándar POSIX para enviar correos. Necesita un MTA (Mail Transfer Agent) instalado y configurado.

# Instalación
sudo apt install mailutils          # Debian/Ubuntu (proporciona mail)
sudo apt install bsd-mailx          # Alternativa BSD
sudo dnf install mailx              # Fedora

# Envío básico
echo "Cuerpo del mensaje" | mail -s "Asunto del correo" usuario@ejemplo.com

# Con archivo como cuerpo
mail -s "Reporte de backup" admin@ejemplo.com < /tmp/reporte.txt

# Con destinatarios múltiples
echo "Alerta" | mail -s "⚠ Error crítico" admin1@ejemplo.com admin2@ejemplo.com

# Con remitente personalizado
echo "Cuerpo" | mail -s "Asunto" -a "From: backup-script@servidor.com" user@ejemplo.com

# CC y BCC (depende de la implementación)
echo "Cuerpo" | mail -s "Asunto" -c cc@ejemplo.com -b bcc@ejemplo.com user@ejemplo.com

mutt — Multipropósito con adjuntos

mutt es un cliente de correo completo que desde Bash brilla para enviar adjuntos y formatear HTML.

# Instalación
sudo apt install mutt

# Envío con adjunto
echo "Cuerpo del mensaje" | mutt -s "Backup diario" \
    -a /backups/backup_20260611.tar.gz -- admin@ejemplo.com

# Múltiples adjuntos
mutt -s "Logs del sistema" \
    -a /var/log/syslog.log \
    -a /var/log/auth.log -- admin@ejemplo.com < /tmp/cuerpo.txt

# Envío silencioso (sin interactividad)
mutt -s "Alerta automática" -e "set copy=no" \
    admin@ejemplo.com < /tmp/mensaje.txt

# Configuración SMTP inline
mutt -s "Reporte" \
    -e "set smtp_url=smtp://usuario:pass@smtp.ejemplo.com:587/" \
    admin@ejemplo.com < /tmp/reporte.txt

ssmtp — MTA mínimo para scripts

ssmtp es un MTA mínimo que solo envía, perfecto para servidores sin postfix completo.

# Instalación
sudo apt install ssmtp

# Configuración: /etc/ssmtp/ssmtp.conf
cat > /etc/ssmtp/ssmtp.conf << 'EOF'
root=admin@ejemplo.com
mailhub=smtp.gmail.com:587
AuthUser=tu-cuenta@gmail.com
AuthPass=contraseña-de-aplicacion
UseTLS=YES
FromLineOverride=YES
EOF

# Envío
echo -e "Subject: Alerta del servidor\n\nEl backup ha fallado" | \
    ssmtp admin@ejemplo.com

# Con remitente personalizado
printf "From: backup-script@midominio.com\nSubject: Resumen\n\nTodo OK\n" | \
    ssmtp admin@ejemplo.com

Adjuntar archivos con mutt

notify_email_attach() {
    local to="$1"
    local subject="$2"
    local body="$3"
    shift 3
    local attachments=("$@")

    if [[ ${#attachments[@]} -eq 0 ]]; then
        echo "$body" | mail -s "$subject" "$to"
        return
    fi

    # Construir argumentos -a para mutt
    local attach_args=()
    for file in "${attachments[@]}"; do
        if [[ -f "$file" ]]; then
            attach_args+=("-a" "$file")
        else
            echo "⚠ Archivo no encontrado: $file" >&2
        fi
    done

    echo "$body" | mutt -s "$subject" "${attach_args[@]}" -- "$to"
}

Telegram Bot API

Crear un bot y obtener credenciales

  1. Habla con @BotFather en Telegram
  2. Envía /newbot y sigue las instrucciones
  3. Recibirás un Token como 123456:ABC-DEF1234ghIkl-zyx57W2v1u123ew11
  4. Obtén tu Chat ID: envía un mensaje al bot y visita https://api.telegram.org/bot<TOKEN>/getUpdates
TOKEN="123456:ABC-DEF1234ghIkl-zyx57W2v1u123ew11"
CHAT_ID="123456789"

⚠ Nunca hardcodees el token en el script. Usa variables de entorno o un archivo de configuración con permisos 600.

sendMessage — Texto con parse_mode

# Mensaje de texto simple
curl -s -X POST "https://api.telegram.org/bot${TOKEN}/sendMessage" \
    -d "chat_id=${CHAT_ID}" \
    -d "text=Hola desde Bash!"

# Con parse_mode: MarkdownV2 o HTML
curl -s -X POST "https://api.telegram.org/bot${TOKEN}/sendMessage" \
    -d "chat_id=${CHAT_ID}" \
    -d "parse_mode=MarkdownV2" \
    -d "text=*Backup completado* ✅\n· Archivos: 1,234\n· Tamaño: 2.3 GB\n· Duración: 45s"

# Con HTML
curl -s -X POST "https://api.telegram.org/bot${TOKEN}/sendMessage" \
    -d "chat_id=${CHAT_ID}" \
    -d "parse_mode=HTML" \
    -d "text=<b>⚠ ERROR CRÍTICO</b>\n<code>Disco / lleno al 98%</code>"

# Deshabilitar preview de enlaces
curl -s -X POST "https://api.telegram.org/bot${TOKEN}/sendMessage" \
    -d "chat_id=${CHAT_ID}" \
    -d "text=Revisa el reporte: https://ejemplo.com/reporte" \
    -d "disable_web_page_preview=true"

Caracteres especiales en MarkdownV2: _ * [ ] ( ) ~ ` > # + - = | { } . ! deben escaparse con \.

sendDocument — Enviar archivos

# Enviar archivo como documento
curl -s -X POST "https://api.telegram.org/bot${TOKEN}/sendDocument" \
    -F "chat_id=${CHAT_ID}" \
    -F "document=@/var/log/backup.log" \
    -F "caption=📄 Log del backup nocturno"

# Enviar imagen como foto
curl -s -X POST "https://api.telegram.org/bot${TOKEN}/sendPhoto" \
    -F "chat_id=${CHAT_ID}" \
    -F "photo=@/tmp/grafica_uso_disco.png" \
    -F "caption=📊 Uso de disco semanal"

Función completa

notify_telegram() {
    local level="$1"
    local message="$2"
    local file="${3:-}"

    local token="${TELEGRAM_TOKEN:-}"
    local chat_id="${TELEGRAM_CHAT_ID:-}"

    [[ -z "$token" || -z "$chat_id" ]] && return 1

    local icon=""
    case "$level" in
        INFO)     icon="ℹ️ " ;;
        WARN)     icon="⚠️ " ;;
        ERROR)    icon="❌ " ;;
        CRITICAL) icon="🚨 " ;;
    esac

    local text="${icon}*[${level}]* ${message}"

    if [[ -n "$file" && -f "$file" ]]; then
        curl -s -X POST "https://api.telegram.org/bot${token}/sendDocument" \
            -F "chat_id=${chat_id}" \
            -F "document=@${file}" \
            -F "caption=${text}" \
            -F "parse_mode=MarkdownV2" > /dev/null
    else
        curl -s -X POST "https://api.telegram.org/bot${token}/sendMessage" \
            -d "chat_id=${chat_id}" \
            -d "text=${text}" \
            -d "parse_mode=MarkdownV2" > /dev/null
    fi
}

Webhooks (Slack / Discord / Mattermost)

Slack — Incoming Webhook

# Obtén la URL del webhook en: Slack API → Incoming Webhooks
SLACK_WEBHOOK="https://hooks.slack.com/services/T00/B00/xxxxx"

# Mensaje simple
curl -s -X POST "$SLACK_WEBHOOK" \
    -H "Content-Type: application/json" \
    -d '{"text": "Backup completado exitosamente"}'

# Mensaje con formato (mrkdwn)
curl -s -X POST "$SLACK_WEBHOOK" \
    -H "Content-Type: application/json" \
    -d '{
        "text": "Resumen de Backup",
        "attachments": [
            {
                "color": "#36a64f",
                "title": "Backup Nocturno",
                "fields": [
                    {"title": "Estado", "value": "✅ Exitoso", "short": true},
                    {"title": "Archivos", "value": "1,234", "short": true},
                    {"title": "Tamaño", "value": "2.3 GB", "short": true},
                    {"title": "Duración", "value": "45s", "short": true}
                ],
                "footer": "Servidor producción",
                "ts": '"$(date +%s)"'
            }
        ]
    }'

Discord — Webhook

DISCORD_WEBHOOK="https://discord.com/api/webhooks/123456/xxxxx"

# Mensaje simple
curl -s -X POST "$DISCORD_WEBHOOK" \
    -H "Content-Type: application/json" \
    -d '{"content": "@here ⚠ El servidor web ha caído"}'

# Con embed (rich message)
curl -s -X POST "$DISCORD_WEBHOOK" \
    -H "Content-Type: application/json" \
    -d '{
        "username": "MonitorBot",
        "avatar_url": "https://ejemplo.com/icon.png",
        "embeds": [{
            "title": "🔴 Alerta: Servidor MySQL",
            "description": "El servicio MySQL no responde en producción",
            "color": 15158332,
            "fields": [
                {"name": "Host", "value": "db-prod-01", "inline": true},
                {"name": "Puerto", "value": "3306", "inline": true},
                {"name": "Último OK", "value": "2026-06-11 02:30:00 UTC"}
            ],
            "footer": {"text": "Monitor automático"},
            "timestamp": "'"$(date -u +%Y-%m-%dT%H:%M:%SZ)"'"
        }]
    }'

Colores comunes en Discord:

ColorDecimalUso
Verde3066993✅ INFO
Amarillo16776960⚠ WARN
Rojo15158332❌ ERROR
Rojo oscuro10038562🚨 CRITICAL

Mattermost — Webhook

MATTERMOST_WEBHOOK="https://mattermost.ejemplo.com/hooks/xxxxx"

curl -s -X POST "$MATTERMOST_WEBHOOK" \
    -H "Content-Type: application/json" \
    -d '{
        "text": "### 🚨 Error en backup\n**Host:** servidor01\n**Error:** Disco lleno\n**Fecha:** 2026-06-11",
        "username": "BackupBot",
        "icon_url": "https://ejemplo.com/backup-icon.png"
    }'

Función webhook genérica

notify_webhook() {
    local level="$1"
    local message="$2"
    local webhook_url="${WEBHOOK_URL:-}"

    [[ -z "$webhook_url" ]] && return 1

    local color
    case "$level" in
        INFO)     color=3066993 ;;     # verde
        WARN)     color=16776960 ;;    # amarillo
        ERROR)    color=15158332 ;;    # rojo
        CRITICAL) color=10038562 ;;    # rojo oscuro
    esac

    local payload
    payload=$(cat << EOF
{
    "content": "[${level}] ${message}",
    "embeds": [{
        "title": "Notificación de ${HOSTNAME}",
        "description": "$(echo "$message" | head -c 2000)",
        "color": ${color},
        "timestamp": "$(date -u +%Y-%m-%dT%H:%M:%SZ)",
        "footer": {"text": "Script: $(basename "$0") PID: $$"}
    }]
}
EOF
)

    curl -s -X POST "$webhook_url" \
        -H "Content-Type: application/json" \
        -d "$payload" > /dev/null
}

Push notifications

ntfy.sh — Push simple y público

ntfy.sh es un servicio de push notifications gratuito y open-source. Puedes usarlo público o self-hosted.

# Enviar notificación a un topic público
curl -d "Backup completado" https://ntfy.sh/mi-topic-personal

# Con título, prioridad y tags
curl -H "Title: Backup Nocturno" \
     -H "Priority: high" \
     -H "Tags: white_check_mark" \
     -d "Backup completado exitosamente" \
     https://ntfy.sh/mi-topic

# Prioridades: 1 (min), 2 (low), 3 (default), 4 (high), 5 (max)
curl -H "Priority: 5" \
     -H "Tags: warning" \
     -H "Title: ⚠ Disco casi lleno" \
     -d "El disco / está al 95% de capacidad" \
     https://ntfy.sh/mi-topic

# Con click action (abrir URL)
curl -H "Click: https://ejemplo.com/dashboard" \
     -d "Revisa el dashboard" \
     https://ntfy.sh/mi-topic

# Autenticación con token (self-hosted o ntfy.sh pro)
curl -H "Authorization: Bearer tk_mi_token" \
     -d "Mensaje secreto" \
     https://ntfy.sh/mi-topic-privado

Tags útiles de ntfy:

TagIcono
white_check_mark✅
warning⚠️
x❌
rotating_light🚨
computer💻
floppy_disk💾
email📧
alarm_clock⏰

Gotify — Self-hosted

Gotify es un servidor de push notifications auto-gestionado.

GOTIFY_URL="http://gotify.ejemplo.com"
GOTIFY_TOKEN="AppToken123"

curl -X POST "${GOTIFY_URL}/message?token=${GOTIFY_TOKEN}" \
    -F "title=Backup completado" \
    -F "message=Todo correcto (2.3 GB, 1,234 archivos)" \
    -F "priority=5"

# Prioridades: 0 (min) a 10 (max)

Apprise — Unifica todos los canales

Apprise es una librería que unifica todos los servicios de notificación bajo una sola API.

# Instalación
sudo apt install apprise
pip install apprise   # o desde pip

# Enviar a múltiples servicios a la vez
apprise -t "Backup completado" -b "Todo correcto" \
    "slack://tokenA/tokenB/tokenC/" \
    "tgram://BOTTOKEN/CHATID/" \
    "mailto://user:pass@smtp.ejemplo.com:587?to=admin@ejemplo.com"

# Soporta 100+ servicios: Telegram, Slack, Discord, Twilio, Pushover,
# Gotify, ntfy, Email, Teams, Matrix, Signal, y muchos más

SMS con Twilio API

Twilio permite enviar SMS desde cualquier lenguaje que haga HTTP. Necesitas una cuenta de Twilio (el crédito inicial suele ser ~$15).

# Credenciales
TWILIO_SID="ACxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx"
TWILIO_TOKEN="xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx"
TWILIO_FROM="+1234567890"  # Número Twilio
TWILIO_TO="+0987654321"    # Tu número

# Enviar SMS con curl (Basic Auth)
curl -s -X POST "https://api.twilio.com/2010-04-01/Accounts/${TWILIO_SID}/Messages.json" \
    -u "${TWILIO_SID}:${TWILIO_TOKEN}" \
    --data-urlencode "From=${TWILIO_FROM}" \
    --data-urlencode "To=${TWILIO_TO}" \
    --data-urlencode "Body=🚨 CRITICAL: Servidor web caído en producción"

# Verificar respuesta
respuesta=$(curl -s -X POST "https://api.twilio.com/2010-04-01/Accounts/${TWILIO_SID}/Messages.json" \
    -u "${TWILIO_SID}:${TWILIO_TOKEN}" \
    --data-urlencode "From=${TWILIO_FROM}" \
    --data-urlencode "To=${TWILIO_TO}" \
    --data-urlencode "Body=Test" 2>&1)

if echo "$respuesta" | grep -q '"status": "queued"'; then
    echo "✅ SMS enviado correctamente"
else
    echo "❌ Error al enviar SMS: $respuesta"
fi

⚠ Control de costos: Los SMS no son gratis. Usa SMS solo para nivel CRITICAL y aplica rate limiting estricto.

Batching y rate limiting

Buffer de notificaciones + Digest

Cuando un script genera muchas notificaciones seguidas (ej: healthcheck que verifica 50 servicios), conviene acumularlas en un buffer y enviar un solo mensaje resumen.

# Buffer global de notificaciones
declare -a NOTIFY_BUFFER=()

# Acumular en buffer
notify_buffer() {
    local level="$1"
    local message="$2"
    NOTIFY_BUFFER+=("[$level] $(date +%H:%M:%S) - $message")
}

# Enviar digest
notify_flush() {
    local level="${1:-INFO}"
    local force="${2:-false}"

    [[ ${#NOTIFY_BUFFER[@]} -eq 0 ]] && return

    local digest
    digest=$(printf "• %s\n" "${NOTIFY_BUFFER[@]}")

    local summary="📊 *Resumen de notificaciones*\n"
    summary+="· Total: ${#NOTIFY_BUFFER[@]} eventos\n"
    summary+="· Período: $(date '+%Y-%m-%d %H:%M')\n\n"
    summary+="$digest"

    notify_dispatch "$level" "$summary"
    NOTIFY_BUFFER=()
}

# Uso en un bucle
for svc in nginx mysql redis; do
    if systemctl is-active --quiet "$svc"; then
        notify_buffer "INFO" "$svc está activo"
    else
        notify_buffer "ERROR" "$svc está CAÍDO"
    fi
done

# Al final, enviar el digest
notify_flush "WARN"

Rate limiting por canal

Evita tormentas de notificaciones: si algo falla 20 veces en un minuto, solo notifica la primera y luego cada N minutos.

# Archivo de timestamps por tipo de notificación
RATE_FILE="/tmp/notify_rate_$$.txt"

check_rate_limit() {
    local channel="$1"    # telegram, email, slack, etc.
    local min_interval="${2:-300}"  # segundos entre notificaciones

    local last_time=0
    if [[ -f "$RATE_FILE" ]]; then
        last_time=$(grep "^${channel}:" "$RATE_FILE" 2>/dev/null | cut -d: -f2)
        last_time=${last_time:-0}
    fi

    local now
    now=$(date +%s)

    if (( now - last_time < min_interval )); then
        return 1  # Rate limited, no enviar
    fi

    # Actualizar timestamp
    if [[ -f "$RATE_FILE" ]]; then
        sed -i "/^${channel}:/d" "$RATE_FILE"
    fi
    echo "${channel}:${now}" >> "$RATE_FILE"
    return 0  # OK, enviar
}

# Uso
if check_rate_limit "telegram" 300; then
    notify_telegram "ERROR" "Servicio caído"
else
    echo "⚠ Telegram rate limited, esperando 5 min" >&2
fi

Circuit breaker

Si un canal falla repetidamente (ej: el webhook de Slack devuelve 429 Too Many Requests), desactívalo temporalmente:

declare -A CHANNEL_FAILURES=()
MAX_FAILURES=3
COOLDOWN=600  # minutos

notify_with_circuit_breaker() {
    local channel="$1"
    shift
    local failures="${CHANNEL_FAILURES[$channel]:-0}"

    if (( failures >= MAX_FAILURES )); then
        echo "⚠ Circuit breaker abierto para $channel ($failures fallos)" >&2
        return 1
    fi

    if "$@"; then
        CHANNEL_FAILURES[$channel]=0
    else
        CHANNEL_FAILURES[$channel]=$((failures + 1))
        if (( CHANNEL_FAILURES[$channel] >= MAX_FAILURES )); then
            echo "🚨 Circuit breaker activado para $channel. Esperando ${COOLDOWN}s" >&2
            (sleep "$COOLDOWN" && unset CHANNEL_FAILURES[$channel]) &
        fi
    fi
}

Configuración centralizada

Centraliza toda la configuración de notificaciones en un solo archivo:

# ~/.notifirc — Configuración de notificaciones
# Cargar con: source ~/.notifirc

# ── Canal: Desktop ──
NOTIFY_DESKTOP_ENABLE=true
NOTIFY_DESKTOP_LEVELS="WARN ERROR CRITICAL"
NOTIFY_DESKTOP_EXPIRE=5000
NOTIFY_DESKTOP_ICON_WARN=dialog-warning
NOTIFY_DESKTOP_ICON_ERROR=dialog-error

# ── Canal: Email ──
NOTIFY_EMAIL_ENABLE=true
NOTIFY_EMAIL_LEVELS="INFO WARN ERROR CRITICAL"
NOTIFY_EMAIL_TO="admin@ejemplo.com"
NOTIFY_EMAIL_FROM="backup-script@servidor.local"
NOTIFY_EMAIL_MTA="mail"   # mail | mutt | ssmtp

# ── Canal: Telegram ──
NOTIFY_TELEGRAM_ENABLE=true
NOTIFY_TELEGRAM_LEVELS="WARN ERROR CRITICAL"
NOTIFY_TELEGRAM_BOT_TOKEN="123456:ABC-DEF1234ghIkl"
NOTIFY_TELEGRAM_CHAT_ID="123456789"

# ── Canal: Webhook (Slack/Discord) ──
NOTIFY_WEBHOOK_ENABLE=true
NOTIFY_WEBHOOK_LEVELS="ERROR CRITICAL"
NOTIFY_WEBHOOK_URL="https://hooks.slack.com/services/..."

# ── Canal: SMS ──
NOTIFY_SMS_ENABLE=false
NOTIFY_SMS_LEVELS="CRITICAL"
NOTIFY_SMS_TWILIO_SID="AC..."
NOTIFY_SMS_TWILIO_TOKEN="..."
NOTIFY_SMS_FROM="+1234567890"
NOTIFY_SMS_TO="+0987654321"

# ── Canal: Sonido ──
NOTIFY_SOUND_ENABLE=false
NOTIFY_SOUND_LEVELS="CRITICAL"

# ── Canal: Sistema ──
NOTIFY_WALL_ENABLE=false
NOTIFY_WALL_LEVELS="CRITICAL"

# ── General ──
NOTIFY_RATE_INTERVAL=300        # Segundos entre notificaciones del mismo canal
NOTIFY_CIRCUIT_BREAKER=3        # Fallos antes de desactivar canal
NOTIFY_BUFFER_ENABLE=true       # Acumular antes de enviar
NOTIFY_BUFFER_FLUSH_INTERVAL=60 # Forzar flush cada N segundos

Función que evalúa qué canales activar:

canal_habilitado_para_nivel() {
    local canal="$1"
    local level="$2"

    local enable_var="NOTIFY_${canal}_ENABLE"
    local levels_var="NOTIFY_${canal}_LEVELS"

    [[ "${!enable_var:-false}" != "true" ]] && return 1

    local levels="${!levels_var:-}"
    [[ -z "$levels" ]] && return 1

    # Verificar si el nivel está en la lista
    for l in $levels; do
        [[ "$l" == "$level" ]] && return 0
    done

    return 1
}

Casos prácticos

Backup notifications

#!/bin/bash
# backup_notify.sh — Backup con notificaciones multicanal
set -euo pipefail

source ~/.notifirc

ORIGEN="/home/lorenzo/documentos"
DESTINO="/backups/documentos_$(date +%Y%m%d).tar.gz"
LOG="/var/log/backup_notify.log"

notify() {
    local level="$1"
    local msg="$2"

    echo "$(date) [$level] $msg" >> "$LOG"

    if canal_habilitado_para_nivel "DESKTOP" "$level"; then
        notify-send -u "${level,,}" "[$level] Backup" "$msg"
    fi
    if canal_habilitado_para_nivel "TELEGRAM" "$level"; then
        notify_telegram "$level" "$msg"
    fi
    if canal_habilitado_para_nivel "EMAIL" "$level"; then
        notify_email "$level" "$msg"
    fi
}

echo "=== Iniciando backup: $(date) ===" >> "$LOG"

if tar czf "$DESTINO" "$ORIGEN" 2>> "$LOG"; then
    notify "INFO" "Backup completado: $DESTINO ($(du -h "$DESTINO" | cut -f1))"
else
    notify "ERROR" "Backup FALLIDO: $ORIGEN → $DESTINO"
fi

Healthcheck + alerta

#!/bin/bash
# healthcheck.sh — Verificar servicios y notificar si caen
source ~/.notifirc

SERVICIOS=("nginx" "postgresql" "redis" "docker")
FALLOS=()

for svc in "${SERVICIOS[@]}"; do
    if ! systemctl is-active --quiet "$svc" 2>/dev/null; then
        FALLOS+=("$svc")
    fi
done

if [[ ${#FALLOS[@]} -gt 0 ]]; then
    local msg="Servicios CAÍDOS: ${FALLOS[*]}"
    notify_dispatch "ERROR" "$msg"
    notify_sound "ERROR"
    exit 1
else
    notify_dispatch "INFO" "Healthcheck OK: todos los servicios activos"
fi

Cron wrapper notificador

#!/bin/bash
# cron_notify.sh — Envuelve cualquier comando de cron y notifica el resultado
# Uso: ./cron_notify.sh [--silent-ok] <comando> [args...]

source ~/.notifirc

SILENT_OK=false
if [[ "$1" == "--silent-ok" ]]; then
    SILENT_OK=true
    shift
fi

if [[ $# -eq 0 ]]; then
    echo "Uso: $0 [--silent-ok] <comando> [args...]" >&2
    exit 1
fi

INICIO=$(date +%s)
COMANDO="$*"

# Capturar stdout y stderr
TMPOUT=$(mktemp)
TMPERR=$(mktemp)

if "$@" >"$TMPOUT" 2>"$TMPERR"; then
    DURACION=$(( $(date +%s) - INICIO ))
    if $SILENT_OK; then
        echo "[OK] $COMANDO — ${DURACION}s" | notify_email "INFO" "Cron OK: $COMANDO"
    fi
else
    DURACION=$(( $(date +%s) - INICIO ))
    ERROR=$(cat "$TMPERR" | head -20)
    SALIDA=$(cat "$TMPOUT" | head -20)

    MSG="❌ *Comando fallido*\n· Host: $(hostname)\n· Comando: \`$COMANDO\`\n· Duración: ${DURACION}s\n· Exit: $?\n\n*stderr:*\n\`\`\`\n${ERROR}\n\`\`\`"

    notify_dispatch "ERROR" "$MSG"
    if [[ -s "$TMPERR" ]]; then
        notify_telegram "ERROR" "📎 Log de error adjunto" "$TMPERR"
    fi
fi

rm -f "$TMPOUT" "$TMPERR"

Funciones profesionales

# ──────────────────────────────────────────────────
# Funciones profesionales para notificaciones
# ──────────────────────────────────────────────────

# notify_send_nivel — Notificación desktop con urgencia e icono
# Uso: notify_send_nivel INFO|WARN|ERROR|CRITICAL "mensaje" [icono_opcional]
notify_send_nivel() {
    local level="$1"
    local message="$2"
    local icon="${3:-}"

    command -v notify-send &>/dev/null || return 1

    local urgency="normal"
    local expire=5000
    local default_icon="dialog-information"

    case "$level" in
        INFO)
            urgency="low"
            expire=3000
            default_icon="dialog-information"
            ;;
        WARN)
            urgency="normal"
            expire=5000
            default_icon="dialog-warning"
            ;;
        ERROR)
            urgency="critical"
            expire=8000
            default_icon="dialog-error"
            ;;
        CRITICAL)
            urgency="critical"
            expire=0  # No expira hasta que el usuario la cierre
            default_icon="emblem-important"
            ;;
    esac

    notify-send -u "$urgency" -t "$expire" -i "${icon:-$default_icon}" \
        "[$level] $(hostname)" "$message"
}

# notify_email — Enviar email con nivel y adjunto opcional
# Uso: notify_email INFO|WARN|ERROR|CRITICAL "mensaje" [archivo_adjunto]
notify_email() {
    local level="$1"
    local message="$2"
    local attach="${3:-}"

    local to="${NOTIFY_EMAIL_TO:-}"
    local mta="${NOTIFY_EMAIL_MTA:-mail}"

    [[ -z "$to" ]] && return 1
    command -v "$mta" &>/dev/null || return 1

    local subject="[$level] $(hostname) — $(date '+%Y-%m-%d %H:%M')"
    local body="Nivel: $level
Host: $(hostname)
Script: $(basename "$0") [PID: $$]
Fecha: $(date '+%Y-%m-%d %H:%M:%S')

$message
---
Notificación automática generada por $(basename "$0")"

    case "$mta" in
        mail|mailx)
            if [[ -n "$attach" && -f "$attach" ]]; then
                # mailx no soporta adjuntos nativos; usar mutt
                command -v mutt &>/dev/null || {
                    echo "$body" | "$mta" -s "$subject" "$to"
                    return
                }
                echo "$body" | mutt -s "$subject" -a "$attach" -- "$to"
            else
                echo "$body" | "$mta" -s "$subject" "$to"
            fi
            ;;
        mutt)
            if [[ -n "$attach" && -f "$attach" ]]; then
                echo "$body" | mutt -s "$subject" -a "$attach" -- "$to"
            else
                echo "$body" | mutt -s "$subject" "$to"
            fi
            ;;
        ssmtp)
            {
                echo "From: ${NOTIFY_EMAIL_FROM:-root@$(hostname -f)}"
                echo "To: $to"
                echo "Subject: $subject"
                echo ""
                echo "$body"
            } | ssmtp "$to"
            ;;
    esac
}

# notify_telegram — Enviar mensaje o archivo a Telegram
# Uso: notify_telegram INFO|WARN|ERROR|CRITICAL "mensaje" [archivo]
notify_telegram() {
    local level="$1"
    local message="$2"
    local file="${3:-}"

    local token="${NOTIFY_TELEGRAM_BOT_TOKEN:-${TELEGRAM_TOKEN:-}}"
    local chat_id="${NOTIFY_TELEGRAM_CHAT_ID:-${TELEGRAM_CHAT_ID:-}}"

    [[ -z "$token" || -z "$chat_id" ]] && return 1

    # Emoji por nivel
    local emoji
    case "$level" in
        INFO)     emoji="ℹ️" ;;
        WARN)     emoji="⚠️" ;;
        ERROR)    emoji="❌" ;;
        CRITICAL) emoji="🚨" ;;
    esac

    local text="${emoji} *[${level}]* $(hostname)"
    text+=$'\n'"$(echo "$message" | sed 's/[_*\[\]()~`>#+\-=|{}.!]/\\&/g')"

    if [[ -n "$file" && -f "$file" ]]; then
        curl -s -X POST "https://api.telegram.org/bot${token}/sendDocument" \
            -F "chat_id=${chat_id}" \
            -F "document=@${file}" \
            -F "caption=${text}" \
            -F "parse_mode=MarkdownV2" > /dev/null 2>&1
    else
        curl -s -X POST "https://api.telegram.org/bot${token}/sendMessage" \
            -d "chat_id=${chat_id}" \
            -d "text=${text}" \
            -d "parse_mode=MarkdownV2" \
            -d "disable_web_page_preview=true" > /dev/null 2>&1
    fi
}

# notify_webhook — Enviar notificación formateada a Slack/Discord/Mattermost
# Uso: notify_webhook INFO|WARN|ERROR|CRITICAL "mensaje"
notify_webhook() {
    local level="$1"
    local message="$2"
    local webhook_url="${NOTIFY_WEBHOOK_URL:-${WEBHOOK_URL:-}}"

    [[ -z "$webhook_url" ]] && return 1

    local color name
    case "$level" in
        INFO)     color="3066993";  name="✅ INFO" ;;
        WARN)     color="16776960"; name="⚠️ WARN" ;;
        ERROR)    color="15158332"; name="❌ ERROR" ;;
        CRITICAL) color="10038562"; name="🚨 CRITICAL" ;;
    esac

    local payload
    payload=$(cat << JSONEOF
{
    "content": "${name} desde $(hostname)",
    "embeds": [{
        "title": "Notificación automática",
        "description": "$(echo "$message" | head -c 1800)",
        "color": ${color},
        "fields": [
            {"name": "Nivel", "value": "${level}", "inline": true},
            {"name": "Host", "value": "$(hostname)", "inline": true},
            {"name": "Script", "value": "$(basename "$0")", "inline": true}
        ],
        "timestamp": "$(date -u +%Y-%m-%dT%H:%M:%SZ)",
        "footer": {"text": "PID: $$"}
    }]
}
JSONEOF
)

    curl -s -X POST "$webhook_url" \
        -H "Content-Type: application/json" \
        -d "$payload" > /dev/null 2>&1
}

Errores comunes

Error 1: Hardcodear tokens y credenciales en el script

# ❌ Token visible en el código (Git, pantallazos, etc.)
TOKEN="123456:ABC-DEF1234ghIkl"

# ✅ Variables de entorno o archivo con permisos 600
export TELEGRAM_TOKEN="123456:ABC-DEF1234ghIkl"  # en ~/.bashrc o .env
source ~/.notifirc  # archivo con chmod 600

Error 2: No verificar que el comando de notificación existe

# ❌ En un servidor sin X, notify-send falla silenciosamente
notify-send "Alerta"  # command not found → script sigue, pero no notifica

# ✅ Verificar disponibilidad antes de usar
if command -v notify-send &>/dev/null; then
    notify-send -u critical "Alerta" "Disco lleno"
fi

Error 3: Enviar demasiadas notificaciones (tormenta)

# ❌ Bucle que verifica 50 servicios y envía 50 notificaciones individuales
for svc in $(systemctl list-units --type=service --all --no-pager | awk '{print $1}'); do
    if ! systemctl is-active --quiet "$svc"; then
        notify_telegram "ERROR" "$svc caído"  # ¡50 mensajes!
    fi
done

# ✅ Acumular en buffer y enviar digest
for svc in ...; do
    if ! systemctl is-active --quiet "$svc"; then
        notify_buffer_add "ERROR" "$svc caído"
    fi
done
notify_buffer_flush  # mensaje con los 50 servicios

Error 4: No escapar caracteres especiales en Telegram MarkdownV2

# ❌ El mensaje contiene . o - que rompen MarkdownV2
text="Error en backup_2026.06.11-version_final.tar.gz"

# ✅ Escapar caracteres especiales
text=$(echo "$text" | sed 's/[_*\[\]()~`>#+\-=|{}.!]/\\&/g')

Error 5: Ignorar el rate limiting de APIs externas

# ❌ Enviar 20 mensajes a Slack en 1 segundo → HTTP 429
for i in {1..20}; do
    curl -X POST "$SLACK_WEBHOOK" -d "{\"text\":\"Alerta $i\"}"
done

# ✅ Aplicar espera entre mensajes
for i in {1..20}; do
    curl -X POST "$SLACK_WEBHOOK" -d "{\"text\":\"Alerta $i\"}"
    sleep 2  # respetar rate limit de Slack (1 msg/seg)
done

Error 6: No usar parse_mode seguro o fallar por HTML inválido

# ❌ HTML mal formado → Telegram ignora el parse_mode y muestra el raw
curl -d "parse_mode=HTML" -d "text=<b>Error</i> en backup"  # <b> no cerrado

# ✅ Usar MarkdownV2 con escape, o validar HTML
curl -d "parse_mode=MarkdownV2" -d "text=*Error* en backup"

Error 7: Olvidar que wall requiere permisos en systemd

# ❌ wall en systemd no llega a terminales gráficos
wall "Mensaje importante"  # Puede que nadie lo vea

# ✅ Combinar wall con notify-send y logger
notify-send -u critical "Aviso" "Mensaje importante"
logger -p user.alert -t "$0" "Mensaje importante"
wall "Mensaje importante" 2>/dev/null || true

Error 8: No manejar fallo de red en los envíos

# ❌ Si no hay internet, la notificación falla silenciosamente
curl -s -X POST "https://api.telegram.org/bot${TOKEN}/sendMessage" \
    -d "chat_id=${CID}" -d "text=Alerta"  # Falla, nadie lo sabe

# ✅ Verificar conectividad y reintentar
if ping -c 1 -W 2 api.telegram.org &>/dev/null; then
    for intento in {1..3}; do
        curl -s -X POST "https://api.telegram.org/bot${TOKEN}/sendMessage" \
            -d "chat_id=${CID}" -d "text=Alerta" && break
        sleep $((intento * 2))
    done
else
    # Canal alternativo: log local
    logger -p user.err "No se pudo enviar notificación Telegram: sin conexión"
fi

Error 9: No limpiar archivos temporales de rate limiting

# ❌ Los archivos de rate limit se acumulan en /tmp
RATE_FILE="/tmp/notify_rate_$$.txt"
# Si el script se mata con kill -9, el archivo temporal queda para siempre

# ✅ Trap para limpiar siempre
trap 'rm -f "$RATE_FILE"' EXIT INT TERM

Error 10: Confundir canales y enviar información sensible al canal equivocado

# ❌ Enviar logs con contraseñas por Telegram (canal inseguro)
notify_telegram "ERROR" "$(cat /etc/backup/passphrase.txt)"

# ✅ Filtrar información sensible por canal
if [[ "$NIVEL" == "CRITICAL" ]]; then
    notify_telegram "ERROR" "Error en backup (detalles en email)"
    notify_email "ERROR" "$(cat /var/log/backup.log)"  # Adjunto completo por email
fi

Más información,

Deja una respuesta