Not a member of Pastebin yet?
Sign Up,
it unlocks many cool features!
- """
- CVE Bot ESCANER - Telegram (version LOCAL Windows)
- --------------------------------------------------
- Mandas una URL al bot y te responde con los componentes que detecta
- Wappalyzer y sus CVEs, usando el MISMO motor del scanner:
- - Indice completo local de MITRE (cvelistV5) + actualizacion por delta.
- - Matching por producto normalizado + rango de version (con soporte para
- rangos embebidos como string: "< 1.13.2", ">= 1.12.0, < 3.5.0", etc.).
- Mantiene el sistema interactivo de aprobacion de usuarios del bot original.
- Uso:
- /start -> registrarse (el admin aprueba)
- <pega una URL> -> el bot la escanea y responde
- /usuarios -> (admin) estadisticas
- /pendientes -> (admin) solicitudes por aprobar
- Rutas RELATIVAS: los archivos de estado y el cache MITRE se crean junto al .py.
- """
- import asyncio
- import io
- import json
- import os
- import re
- import sys
- import zipfile
- import logging
- from datetime import datetime
- from concurrent.futures import ThreadPoolExecutor
- import requests
- from wappalyzer import analyze
- from telegram import Update, InlineKeyboardButton, InlineKeyboardMarkup
- from telegram.ext import (Application, CommandHandler, MessageHandler,
- CallbackQueryHandler, ContextTypes, filters)
- # =============================================================================
- # CONFIGURACION
- # =============================================================================
- BASE_DIR = os.path.dirname(os.path.abspath(__file__))
- BOT_TOKEN = os.environ.get("CVE_BOT_TOKEN", "AQUITUTOKEN")
- ADMIN_CHAT_ID = int(os.environ.get("CVE_ADMIN_ID", "TUID"))
- # -- MITRE / scanner -----------------------------------------------------------
- CACHE_DIR = os.path.join(BASE_DIR, 'mitre_cache')
- INDEX_FILE = os.path.join(CACHE_DIR, 'product_index.json')
- META_FILE = os.path.join(CACHE_DIR, 'meta.json')
- CVE_ZIP_URL = "https://github.com/CVEProject/cvelistV5/archive/refs/heads/main.zip"
- DELTA_URL = "https://raw.githubusercontent.com/CVEProject/cvelistV5/main/cves/delta.json"
- RAW_BASE = "https://raw.githubusercontent.com/CVEProject/cvelistV5/main/cves/"
- TIMEOUT_URL = 30
- MAX_MSG = 3800 # limite seguro por mensaje de Telegram (max real 4096)
- # -- archivos de usuarios ------------------------------------------------------
- APPROVED_FILE = os.path.join(BASE_DIR, "cve_approved_users.txt")
- PENDING_FILE = os.path.join(BASE_DIR, "cve_pending_users.txt")
- BANNED_FILE = os.path.join(BASE_DIR, "cve_banned_users.txt")
- SEVERITY_EMOJI = {
- "CRITICAL": "\U0001F534", "HIGH": "\U0001F7E0", "MEDIUM": "\U0001F7E1",
- "LOW": "\U0001F7E2", "NONE": "⚪", "LIMPIO": "✅", "N/A": "⚪",
- }
- # indice MITRE en memoria (se carga en main)
- INDEX = {}
- _scan_pool = ThreadPoolExecutor(max_workers=4)
- # =============================================================================
- # LOGGING
- # =============================================================================
- def setup_logging():
- log_file = os.path.join(BASE_DIR, "cve_bot_scan.log")
- logging.basicConfig(
- level=logging.INFO,
- format='%(asctime)s [%(levelname)s] %(message)s',
- handlers=[logging.FileHandler(log_file, encoding='utf-8'),
- logging.StreamHandler(sys.stdout)]
- )
- logging.getLogger('httpx').setLevel(logging.WARNING)
- logging.getLogger('telegram').setLevel(logging.WARNING)
- logger = logging.getLogger(__name__)
- # =============================================================================
- # GESTION DE USUARIOS
- # =============================================================================
- def _load_ids(filepath) -> dict:
- result = {}
- if not os.path.exists(filepath):
- return result
- try:
- with open(filepath, 'r', encoding='utf-8') as f:
- for line in f:
- line = line.strip()
- if line:
- parts = line.split('|')
- if parts[0].lstrip('-').isdigit():
- result[int(parts[0])] = line
- except Exception as e:
- logger.error(f"Error leyendo {filepath}: {e}")
- return result
- def _save_ids(filepath, data: dict):
- try:
- with open(filepath, 'w', encoding='utf-8') as f:
- for line in data.values():
- f.write(line + '\n')
- except Exception as e:
- logger.error(f"Error escribiendo {filepath}: {e}")
- def load_approved() -> set:
- return set(_load_ids(APPROVED_FILE).keys())
- def load_pending() -> dict:
- return _load_ids(PENDING_FILE)
- def load_banned() -> set:
- return set(_load_ids(BANNED_FILE).keys())
- def add_pending(user_id: int, username: str, first_name: str) -> bool:
- pending = _load_ids(PENDING_FILE)
- approved = load_approved()
- banned = load_banned()
- if user_id in approved or user_id in banned or user_id in pending:
- return False
- ts = datetime.now().strftime('%Y-%m-%d %H:%M:%S')
- pending[user_id] = f"{user_id}|{username}|{first_name}|{ts}|PENDIENTE"
- _save_ids(PENDING_FILE, pending)
- logger.info(f"Pendiente: {first_name} (@{username}) id={user_id}")
- return True
- def approve_user(user_id: int):
- pending = _load_ids(PENDING_FILE)
- if user_id in pending:
- line = pending.pop(user_id)
- _save_ids(PENDING_FILE, pending)
- approved = _load_ids(APPROVED_FILE)
- parts = line.split('|'); parts[-1] = 'APROBADO'
- approved[user_id] = '|'.join(parts)
- _save_ids(APPROVED_FILE, approved)
- else:
- approved = _load_ids(APPROVED_FILE)
- if user_id not in approved:
- ts = datetime.now().strftime('%Y-%m-%d %H:%M:%S')
- approved[user_id] = f"{user_id}|desconocido|desconocido|{ts}|APROBADO"
- _save_ids(APPROVED_FILE, approved)
- logger.info(f"Aprobado: id={user_id}")
- def ban_user(user_id: int, reason: str = "blocked"):
- for filepath in [APPROVED_FILE, PENDING_FILE]:
- data = _load_ids(filepath)
- if user_id in data:
- line = data.pop(user_id)
- _save_ids(filepath, data)
- banned = _load_ids(BANNED_FILE)
- ts = datetime.now().strftime('%Y-%m-%d %H:%M:%S')
- parts = line.split('|')
- uname = parts[1] if len(parts) > 1 else '?'
- name = parts[2] if len(parts) > 2 else '?'
- banned[user_id] = f"{user_id}|{uname}|{name}|{ts}|{reason}"
- _save_ids(BANNED_FILE, banned)
- logger.info(f"Baneado: id={user_id} -- {reason}")
- return
- def clean(text) -> str:
- if not text:
- return ""
- return str(text).replace("&", "&").replace("<", "<").replace(">", ">")
- # =============================================================================
- # MOTOR MITRE (mismo del scanner)
- # =============================================================================
- def norm(s):
- return re.sub(r'[^a-z0-9]', '', (s or '').lower())
- def parse_version(v):
- if not v:
- return None
- nums = re.findall(r'\d+', str(v))
- return tuple(int(x) for x in nums[:4]) if nums else None
- def vcmp(a, b):
- if a is None or b is None:
- return 0
- la = list(a) + [0] * (4 - len(a))
- lb = list(b) + [0] * (4 - len(b))
- return (la > lb) - (la < lb)
- def parse_cve(data):
- if not isinstance(data, dict):
- return None, []
- cna = data.get('containers', {}).get('cna', {})
- if not isinstance(cna, dict):
- return None, []
- cve_id = data.get('cveMetadata', {}).get('cveId')
- if not cve_id:
- return None, []
- score, severity = None, 'NONE'
- metrics = cna.get('metrics', [])
- if isinstance(metrics, list):
- for m in metrics:
- if not isinstance(m, dict):
- continue
- for k in ('cvssV3_1', 'cvssV3_0', 'cvssV4_0', 'cvssV2_0'):
- if k in m and isinstance(m[k], dict):
- score = m[k].get('baseScore')
- severity = (m[k].get('baseSeverity') or 'NONE').upper()
- break
- if score is not None:
- break
- entradas = []
- for aff in cna.get('affected', []) or []:
- if not isinstance(aff, dict):
- continue
- pkey = norm(aff.get('product', ''))
- if not pkey or pkey in ('na',):
- continue
- versions = []
- for v in aff.get('versions', []) or []:
- if isinstance(v, dict):
- versions.append({'version': v.get('version'),
- 'lessThan': v.get('lessThan'),
- 'lessThanOrEqual': v.get('lessThanOrEqual')})
- entradas.append({'pkey': pkey, 'cve': cve_id, 'versions': versions,
- 'score': score,
- 'severity': severity if score is not None else 'NONE'})
- return cve_id, entradas
- def build_full_index():
- logger.info("[MITRE] Descargando cvelistV5 completo (solo la primera vez)...")
- r = requests.get(CVE_ZIP_URL, stream=True, timeout=600)
- r.raise_for_status()
- buf = io.BytesIO(r.content)
- index = {}
- with zipfile.ZipFile(buf) as z:
- names = [n for n in z.namelist() if n.endswith('.json') and '/cves/' in n]
- logger.info(f"[MITRE] Procesando {len(names)} CVEs...")
- for i, name in enumerate(names):
- if i % 20000 == 0:
- logger.info(f"[MITRE] {i}/{len(names)}")
- try:
- data = json.loads(z.read(name))
- except Exception:
- continue
- _, entradas = parse_cve(data)
- for e in entradas:
- pkey = e.pop('pkey')
- index.setdefault(pkey, []).append(e)
- return index
- def merge_cve(index, cve_id, entradas):
- for pkey in list(index.keys()):
- index[pkey] = [e for e in index[pkey] if e['cve'] != cve_id]
- if not index[pkey]:
- del index[pkey]
- for e in entradas:
- pkey = e.pop('pkey')
- index.setdefault(pkey, []).append(e)
- def actualizar_delta(index):
- logger.info("[MITRE] Buscando CVEs nuevos/modificados (delta)...")
- try:
- r = requests.get(DELTA_URL, timeout=60,
- headers={'User-Agent': 'CiberAudit-Bot/1.0'})
- r.raise_for_status()
- delta = r.json()
- except Exception as e:
- logger.warning(f"[MITRE] No se pudo leer delta ({str(e)[:60]}). Uso indice actual.")
- return index, 0
- pendientes = []
- for clave in ('new', 'updated'):
- for item in delta.get(clave, []):
- if isinstance(item, dict) and item.get('cveId'):
- pendientes.append((item.get('cveId'), item.get('githubLink')))
- if not pendientes:
- logger.info("[MITRE] Indice ya al dia.")
- return index, 0
- logger.info(f"[MITRE] {len(pendientes)} CVEs a actualizar...")
- sesion = requests.Session()
- sesion.headers.update({'User-Agent': 'CiberAudit-Bot/1.0'})
- def descargar_uno(cid, link):
- url = None
- if link and link.startswith('http'):
- url = link.replace('https://github.com/CVEProject/cvelistV5/blob/',
- 'https://raw.githubusercontent.com/CVEProject/cvelistV5/')
- else:
- m = re.match(r'CVE-(\d{4})-(\d+)', cid)
- if m:
- anio, num = m.group(1), m.group(2)
- rango = num[:-3] + 'xxx' if len(num) > 3 else '0xxx'
- url = f"{RAW_BASE}{anio}/{rango}/{cid}.json"
- if not url:
- return None
- try:
- rr = sesion.get(url, timeout=30)
- if rr.status_code == 200:
- return rr.json()
- except Exception:
- return None
- return None
- resultados = {}
- with ThreadPoolExecutor(max_workers=8) as ex:
- futs = {ex.submit(descargar_uno, cid, link): cid for cid, link in pendientes}
- for fut in futs:
- cid = futs[fut]
- try:
- resultados[cid] = fut.result()
- except Exception:
- resultados[cid] = None
- n = 0
- for cid, data in resultados.items():
- if data is None:
- continue
- cve_id, entradas = parse_cve(data)
- if cve_id:
- merge_cve(index, cve_id, entradas)
- n += 1
- logger.info(f"[MITRE] {n} CVEs actualizados desde el delta.")
- return index, n
- def cargar_indice():
- os.makedirs(CACHE_DIR, exist_ok=True)
- if os.path.exists(INDEX_FILE):
- logger.info(f"[MITRE] Cargando indice local: {INDEX_FILE}")
- with open(INDEX_FILE, encoding='utf-8') as f:
- index = json.load(f)
- index, n = actualizar_delta(index)
- if n > 0:
- with open(INDEX_FILE, 'w', encoding='utf-8') as f:
- json.dump(index, f)
- else:
- index = build_full_index()
- with open(INDEX_FILE, 'w', encoding='utf-8') as f:
- json.dump(index, f)
- logger.info(f"[MITRE] Indice construido: {len(index)} productos")
- with open(META_FILE, 'w', encoding='utf-8') as f:
- json.dump({'ultima_actualizacion': datetime.now().isoformat(),
- 'productos': len(index)}, f)
- return index
- # -- matching por version (con soporte de rangos en string) --------------------
- def _test_constraint(t, raw):
- raw = (raw or '').strip()
- if not raw or raw == '0':
- return False
- if ' - ' in raw and not re.search(r'[<>=]', raw):
- lo, hi = raw.split(' - ', 1)
- lo, hi = parse_version(lo), parse_version(hi)
- return lo is not None and hi is not None and vcmp(t, lo) >= 0 and vcmp(t, hi) <= 0
- if re.search(r'[<>=]', raw):
- oks = []
- for tok in raw.split(','):
- tok = tok.strip()
- if not tok:
- continue
- m = re.match(r'(>=|<=|>|<|==|=)?\s*(.+)', tok)
- if not m:
- continue
- op = m.group(1) or '='
- ver = parse_version(m.group(2))
- if ver is None:
- continue
- c = vcmp(t, ver)
- oks.append(
- (op in ('=', '==') and c == 0) or
- (op == '>' and c > 0) or (op == '>=' and c >= 0) or
- (op == '<' and c < 0) or (op == '<=' and c <= 0)
- )
- return bool(oks) and all(oks)
- base = parse_version(raw)
- return base is not None and vcmp(t, base) == 0
- def version_afectada(target, ventry):
- t = parse_version(target)
- if t is None:
- return False
- lt = parse_version(ventry.get('lessThan'))
- lte = parse_version(ventry.get('lessThanOrEqual'))
- raw = (ventry.get('version') or '').strip()
- if lt is not None or lte is not None:
- base = parse_version(raw) if raw and raw != '0' else None
- low_ok = base is None or vcmp(t, base) >= 0
- if lt is not None:
- return low_ok and vcmp(t, lt) < 0
- return low_ok and vcmp(t, lte) <= 0
- return _test_constraint(t, raw)
- def match_cves(index, component, version):
- ckey = norm(component)
- if not ckey or not version:
- return []
- seen, out = set(), []
- for e in index.get(ckey, []):
- if any(version_afectada(version, v) for v in e['versions']) and e['cve'] not in seen:
- seen.add(e['cve'])
- out.append({'id': e['cve'], 'score': e['score'], 'severity': e['severity']})
- return out
- def severity_label(cves):
- order = {'CRITICAL': 4, 'HIGH': 3, 'MEDIUM': 2, 'LOW': 1, 'NONE': 0}
- if not cves:
- return 'LIMPIO'
- return max(cves, key=lambda c: order.get(c['severity'], 0))['severity']
- # =============================================================================
- # ESCANEO (sincrono, corre en un hilo aparte)
- # =============================================================================
- def scan_sync(url):
- if not url.startswith('http'):
- url = 'https://' + url
- results = analyze(url=url, scan_type='full', timeout=TIMEOUT_URL)
- techs = results.get(url, {})
- if not techs:
- return {'url': url, 'ok': False, 'msg': 'Sin tecnologias detectadas'}
- componentes, grupos, todos = [], [], []
- for name, data in sorted(techs.items()):
- ver = data.get('version')
- componentes.append(f"{name}{(' ' + ver) if ver else ''}")
- if not ver:
- continue
- cves = match_cves(INDEX, name, ver)
- if cves:
- grupos.append((f"{name} {ver}", cves))
- todos.extend(cves)
- return {'url': url, 'ok': True, 'componentes': componentes,
- 'grupos': grupos, 'sev_max': severity_label(todos),
- 'total': len({c['id'] for c in todos})}
- def formatear_resultado(res):
- """Devuelve una lista de mensajes HTML (troceada si es larga)."""
- if not res['ok']:
- return [f"⚠️ <b>{clean(res['url'])}</b>\n{clean(res['msg'])}"]
- emoji = SEVERITY_EMOJI.get(res['sev_max'], "⚪")
- head = (f"\U0001F50D <b>Resultado</b>\n{clean(res['url'])}\n\n"
- f"{emoji} Severidad max: <b>{res['sev_max']}</b> | "
- f"CVEs unicos: <b>{res['total']}</b>\n"
- f"\U0001F9E9 Componentes: <code>{clean(', '.join(res['componentes']))}</code>\n")
- if not res['grupos']:
- return [head + "\n✅ Sin CVEs conocidos para las versiones detectadas."]
- bloques = []
- for comp, cves in res['grupos']:
- e = SEVERITY_EMOJI.get(severity_label(cves), "⚪")
- lineas = [f"\n{e} <b>{clean(comp)}</b>"]
- for c in cves:
- s = c['score'] if c['score'] is not None else 'N/A'
- lineas.append(f" • <code>{c['id']}</code> [{c['severity']}] score {s}")
- bloques.append("\n".join(lineas))
- # trocear para no pasar el limite de Telegram
- mensajes, actual = [], head
- for b in bloques:
- if len(actual) + len(b) > MAX_MSG:
- mensajes.append(actual)
- actual = ""
- actual += b
- if actual.strip():
- mensajes.append(actual)
- return mensajes
- # =============================================================================
- # HANDLERS TELEGRAM
- # =============================================================================
- async def cmd_start(update: Update, context: ContextTypes.DEFAULT_TYPE):
- user = update.effective_user
- uid, username, name = user.id, (user.username or 'sin_username'), (user.first_name or 'sin_nombre')
- if uid in load_banned():
- await update.message.reply_text("Tu acceso ha sido revocado.")
- return
- if uid == ADMIN_CHAT_ID:
- approve_user(uid)
- await update.message.reply_text(
- f"<b>Bienvenido Admin {clean(name)}</b>\n\n"
- "Manda una URL y la escaneo (Wappalyzer + MITRE).\n\n"
- "/pendientes -- solicitudes\n/usuarios -- estadisticas",
- parse_mode='HTML')
- return
- if uid in load_approved():
- await update.message.reply_text(
- "Ya estas aprobado. Mandame una URL (ej: https://ejemplo.com) y la escaneo.")
- return
- if add_pending(uid, username, name):
- await update.message.reply_text(
- f"Hola <b>{clean(name)}</b>! Tu solicitud fue recibida. "
- "El administrador debe aprobarte para poder escanear.", parse_mode='HTML')
- keyboard = InlineKeyboardMarkup([[
- InlineKeyboardButton("Aprobar", callback_data=f"approve:{uid}"),
- InlineKeyboardButton("Rechazar", callback_data=f"ban:{uid}")]])
- await context.bot.send_message(
- chat_id=ADMIN_CHAT_ID,
- text=(f"<b>Nueva solicitud</b>\nNombre: {clean(name)}\n"
- f"Usuario: @{clean(username)}\nID: <code>{uid}</code>"),
- parse_mode='HTML', reply_markup=keyboard)
- else:
- await update.message.reply_text("Tu solicitud ya esta en revision.")
- async def handle_url(update: Update, context: ContextTypes.DEFAULT_TYPE):
- user = update.effective_user
- uid = user.id
- if uid in load_banned():
- return
- if uid != ADMIN_CHAT_ID and uid not in load_approved():
- await update.message.reply_text("Necesitas aprobacion. Usa /start para solicitarla.")
- return
- texto = (update.message.text or "").strip()
- m = re.search(r'(https?://\S+|[a-z0-9.-]+\.[a-z]{2,}\S*)', texto, re.I)
- if not m:
- await update.message.reply_text("Mandame una URL valida, ej: https://ejemplo.com")
- return
- url = m.group(1)
- aviso = await update.message.reply_text(f"\U0001F50E Escaneando {clean(url)} ... (puede tardar unos segundos)")
- loop = asyncio.get_running_loop()
- try:
- res = await loop.run_in_executor(_scan_pool, scan_sync, url)
- except Exception as e:
- logger.error(f"Error escaneando {url}: {e}")
- await aviso.edit_text(f"❌ Error al escanear: {clean(str(e)[:150])}")
- return
- mensajes = formatear_resultado(res)
- try:
- await aviso.edit_text(mensajes[0], parse_mode='HTML', disable_web_page_preview=True)
- except Exception:
- await aviso.edit_text(mensajes[0])
- for extra in mensajes[1:]:
- await update.message.reply_text(extra, parse_mode='HTML', disable_web_page_preview=True)
- logger.info(f"Escaneo {url} por id={uid}: {res.get('total', 0)} CVEs [{res.get('sev_max')}]")
- async def cmd_usuarios(update: Update, context: ContextTypes.DEFAULT_TYPE):
- if update.effective_user.id != ADMIN_CHAT_ID:
- return
- await update.message.reply_text(
- f"<b>Usuarios</b>\nAprobados: <b>{len(load_approved())}</b>\n"
- f"Pendientes: <b>{len(load_pending())}</b>\nBaneados: <b>{len(load_banned())}</b>",
- parse_mode='HTML')
- async def cmd_pendientes(update: Update, context: ContextTypes.DEFAULT_TYPE):
- if update.effective_user.id != ADMIN_CHAT_ID:
- return
- pending = load_pending()
- if not pending:
- await update.message.reply_text("No hay solicitudes pendientes.")
- return
- for uid, line in pending.items():
- parts = line.split('|')
- name = parts[2] if len(parts) > 2 else '?'
- uname = parts[1] if len(parts) > 1 else '?'
- ts = parts[3] if len(parts) > 3 else '?'
- keyboard = InlineKeyboardMarkup([[
- InlineKeyboardButton("Aprobar", callback_data=f"approve:{uid}"),
- InlineKeyboardButton("Rechazar", callback_data=f"ban:{uid}")]])
- await update.message.reply_text(
- f"{clean(name)} (@{clean(uname)})\nID <code>{uid}</code>\n{ts}",
- parse_mode='HTML', reply_markup=keyboard)
- async def callback_handler(update: Update, context: ContextTypes.DEFAULT_TYPE):
- query = update.callback_query
- await query.answer()
- if update.effective_user.id != ADMIN_CHAT_ID:
- await query.edit_message_text("Sin permisos.")
- return
- action, uid_str = query.data.split(':')
- uid = int(uid_str)
- if action == 'approve':
- approve_user(uid)
- try:
- await context.bot.send_message(
- chat_id=uid,
- text="<b>Solicitud aprobada!</b>\nYa puedes mandarme URLs para escanear.",
- parse_mode='HTML')
- except Exception:
- pass
- await query.edit_message_text(f"Usuario <code>{uid}</code> aprobado.", parse_mode='HTML')
- elif action == 'ban':
- ban_user(uid, reason='rechazado_por_admin')
- await query.edit_message_text(f"Usuario <code>{uid}</code> rechazado.", parse_mode='HTML')
- # =============================================================================
- # MAIN
- # =============================================================================
- async def main():
- setup_logging()
- logger.info("CVE Bot Escaner iniciando...")
- if BOT_TOKEN == "PON_AQUI_TU_TOKEN":
- logger.error("Falta el token. Define CVE_BOT_TOKEN o edita BOT_TOKEN.")
- return
- global INDEX
- logger.info("Cargando indice MITRE (puede tardar la primera vez)...")
- INDEX = cargar_indice()
- logger.info(f"Indice listo: {len(INDEX)} productos.")
- app = Application.builder().token(BOT_TOKEN).build()
- app.add_handler(CommandHandler("start", cmd_start))
- app.add_handler(CommandHandler("usuarios", cmd_usuarios))
- app.add_handler(CommandHandler("pendientes", cmd_pendientes))
- app.add_handler(CallbackQueryHandler(callback_handler))
- app.add_handler(MessageHandler(filters.TEXT & ~filters.COMMAND, handle_url))
- await app.initialize()
- await app.start()
- await app.updater.start_polling(drop_pending_updates=True)
- logger.info("CVE Bot Escaner listo. Mandale una URL.")
- try:
- while True:
- await asyncio.sleep(3600)
- except (KeyboardInterrupt, asyncio.CancelledError):
- pass
- finally:
- await app.updater.stop()
- await app.stop()
- await app.shutdown()
- logger.info("CVE Bot Escaner detenido")
- if __name__ == "__main__":
- try:
- asyncio.run(main())
- except KeyboardInterrupt:
- print("\nDetenido por el usuario")
Advertisement
Add Comment
Please, Sign In to add comment