"""Telegram bot — только SSH-управление роутерами Keenetic.""" import asyncio, logging, re import httpx from . import config from .database import load_json, save_json from .ssh_client import ssh_exec, ssh_exec_verbose logger = logging.getLogger("keenetic_ssh.bot") _offset = 0 def _escape(text): if not text: return "(пусто)" text = re.sub(r"\x1b\[[0-9;]*[mGKHF]", "", text) return text.replace("&", "&").replace("<", "<").replace(">", ">")[:3500] def _find_router(R, name): if name in R: return name for k in R: if k.lower() == name.lower(): return k return None def _router_list(): R = load_json(config.ROUTERS_FILE, {}) if not R: return "Нет роутеров. Добавь: /add имя IP [user] [pass]" lines = [] for n, c in R.items(): ip = c.get("ip") or c.get("wan_ip") or "—" dn = c.get("display_name") or n lines.append(f"• {n} — {dn} — {ip}") return "\n".join(lines) def _get_router(name): R = load_json(config.ROUTERS_FILE, {}) rn = _find_router(R, name) if not rn: return None, None, None, None, None c = R[rn] ip = (c.get("ip") or c.get("wan_ip") or "").strip() u = c.get("user") or config.SSH_USER p = c.get("password") or config.SSH_PASS dn = c.get("display_name") or rn return ip, dn, rn, u, p async def telegram_loop(): global _offset if not config.TELEGRAM_TOKEN or not config.TELEGRAM_CHAT_ID: logger.error("Задай TELEGRAM_TOKEN и TELEGRAM_CHAT_ID в .env") return logger.info("Telegram bot started") while True: try: async with httpx.AsyncClient(timeout=35) as c: r = await c.get( f"https://api.telegram.org/bot{config.TELEGRAM_TOKEN}/getUpdates", params={"offset": _offset, "timeout": 30}, ) if r.status_code != 200: await asyncio.sleep(5) continue for upd in r.json().get("result", []): _offset = upd["update_id"] + 1 msg = upd.get("message", {}) text = (msg.get("text") or "").strip() chat_id = msg.get("chat", {}).get("id") if not text or not chat_id: continue if str(chat_id) != str(config.TELEGRAM_CHAT_ID): continue reply = await handle_command(text) if reply: for chunk in [reply[i : i + 4000] for i in range(0, len(reply), 4000)]: await c.post( f"https://api.telegram.org/bot{config.TELEGRAM_TOKEN}/sendMessage", json={"chat_id": chat_id, "text": chunk, "parse_mode": "HTML"}, ) except asyncio.CancelledError: break except Exception as e: logger.exception(e) await asyncio.sleep(10) async def handle_command(text: str) -> str: p = text.split(maxsplit=3) cmd = p[0].lower() a1 = p[1].strip() if len(p) > 1 else "" a2 = p[2].strip() if len(p) > 2 else "" a3 = p[3].strip() if len(p) > 3 else "" if cmd in ("/start", "/help"): return ( "🔧 Keenetic SSH — управление роутерами по SSH\n\n" "Список: /list\n\n" "SSH:\n" "/ssh <имя> <команда>\n" "/ssh all <команда> — на все роутеры\n\n" "Быстрые:\n" "/neo <имя> status|restart\n" "/uptime <имя>\n" "/interfaces <имя>\n" "/reboot <имя>\n" "/ping <имя> — с сервера до IP роутера\n\n" "Роутеры:\n" "/add <имя> <IP> [user] [pass]\n" "/setip <имя> <IP>\n" "/setname <имя> <название>\n" "/setweb <имя> <URL>\n" "/delete <имя>\n\n" "/router <имя> — карточка роутера\n" + _router_list() ) if cmd == "/list": return "📋 Роутеры\n\n" + _router_list() if cmd == "/add": parts = text.split() if len(parts) < 3: return "❓ /add имя IP [user] [pass]\nПример: /add andrey 192.168.88.1 root keenetic" R = load_json(config.ROUTERS_FILE, {}) key = parts[1].strip().lower() ip = parts[2] user = parts[3] if len(parts) > 3 else config.SSH_USER pwd = parts[4] if len(parts) > 4 else config.SSH_PASS R[key] = {"ip": ip, "user": user, "password": pwd, "display_name": key} save_json(config.ROUTERS_FILE, R) return f"✅ Добавлен {key} → {ip}" if cmd == "/router": if not a1: return "❓ /router имя\n\n" + _router_list() R = load_json(config.ROUTERS_FILE, {}) rn = _find_router(R, a1) if not rn: return f"❌ Не найден\n\n" + _router_list() c = R[rn] ip = c.get("ip") or c.get("wan_ip") or "—" return ( f"📡 {c.get('display_name') or rn} ({rn})\n" f"IP: {ip}\n" f"SSH: {c.get('user', config.SSH_USER)}\n" f"Web: {c.get('web_url') or '—'}" ) if cmd == "/ssh": if not a1: return "❓ /ssh имя команда\n/ssh all команда" if a1.lower() == "all": parts = text.split(None, 2) ssh_cmd = parts[2] if len(parts) > 2 else "uptime" R = load_json(config.ROUTERS_FILE, {}) lines = [f"🔧 SSH all: {_escape(ssh_cmd)}\n"] ok = fail = 0 for rname, rcfg in R.items(): rip = (rcfg.get("ip") or rcfg.get("wan_ip") or "").strip() if not rip: lines.append(f"⏭ {rname}: нет IP") continue ru = rcfg.get("user") or config.SSH_USER rp = rcfg.get("password") or config.SSH_PASS r = await ssh_exec_verbose(rip, ssh_cmd, user=ru, password=rp, timeout=120) icon = "✅" if r["ok"] else "❌" if r["ok"]: ok += 1 else: fail += 1 body = _escape((r["output"] or r["stderr"] or "")[:500]) lines.append(f"{icon} {rname} exit={r['exit_code']}\n
{body}
") lines.append(f"\nИтого: {ok} ✅ {fail} ❌") return "\n".join(lines) ip, dn, _, u, pw = _get_router(a1) if ip is None: return f"❌ Роутер не найден\n\n" + _router_list() if not ip: return f"❌ Нет IP у {a1}. /setip имя IP" parts = text.split(None, 2) ssh_cmd = parts[2] if len(parts) > 2 else "uptime" out = await ssh_exec(ip, ssh_cmd, user=u, password=pw, timeout=120) return f"🔧 {dn} ({ip})\n$ {ssh_cmd}\n\n
{_escape(out)}
" if cmd == "/neo": if not a1: return "❓ /neo имя status|restart" ip, dn, _, u, pw = _get_router(a1) if ip is None: return "❌ Не найден" if not ip: return "❌ Нет IP" sub = a2 or "status" out = await ssh_exec(ip, f"neo {sub}", user=u, password=pw) return f"🔄 {dn} neo {sub}\n
{_escape(out)}
" if cmd == "/reboot": if not a1: return "❓ /reboot имя" ip, dn, _, u, pw = _get_router(a1) if ip is None: return "❌ Не найден" if not ip: return "❌ Нет IP" out = await ssh_exec(ip, "reboot", user=u, password=pw) return f"♻️ {dn}\n
{_escape(out)}
" if cmd == "/ping": if not a1: return "❓ /ping имя" ip, dn, _, _, _ = _get_router(a1) if ip is None: return "❌ Не найден" if not ip: return "❌ Нет IP" try: proc = await asyncio.create_subprocess_exec( "ping", "-c", "4", "-W", "3", ip, stdout=asyncio.subprocess.PIPE, stderr=asyncio.subprocess.PIPE, ) out, _ = await asyncio.wait_for(proc.communicate(), timeout=20) return f"📶 {dn} ({ip})\n
{_escape(out.decode())}
" except Exception: return f"❌ Ping timeout" if cmd == "/uptime": if not a1: return "❓ /uptime имя" ip, dn, _, u, pw = _get_router(a1) if not ip: return "❌" if ip is None else "❌ Нет IP" out = await ssh_exec(ip, "uptime", user=u, password=pw) return f"⏱ {dn}\n
{_escape(out)}
" if cmd == "/interfaces": if not a1: return "❓ /interfaces имя" ip, dn, _, u, pw = _get_router(a1) if not ip: return "❌" if ip is None else "❌ Нет IP" out = await ssh_exec(ip, "ip -br addr show", user=u, password=pw) return f"🌐 {dn}\n
{_escape(out)}
" if cmd == "/setip": if not a1 or not a2: return "❓ /setip имя IP" R = load_json(config.ROUTERS_FILE, {}) rn = _find_router(R, a1) if not rn: return "❌ Не найден" R[rn]["ip"] = a2 save_json(config.ROUTERS_FILE, R) return f"✅ {rn} IP = {a2}" if cmd == "/setname": parts = text.split(None, 2) if len(parts) < 3: return "❓ /setname имя Красивое название" R = load_json(config.ROUTERS_FILE, {}) rn = _find_router(R, parts[1]) if not rn: return "❌ Не найден" R[rn]["display_name"] = parts[2].strip() save_json(config.ROUTERS_FILE, R) return f"✅ {rn} = {parts[2].strip()}" if cmd == "/setweb": parts = text.split(None, 2) if len(parts) < 3: return "❓ /setweb имя URL" R = load_json(config.ROUTERS_FILE, {}) rn = _find_router(R, parts[1]) if not rn: return "❌ Не найден" R[rn]["web_url"] = parts[2].strip() save_json(config.ROUTERS_FILE, R) return f"✅ web = {parts[2].strip()}" if cmd == "/delete": if not a1: return "❓ /delete имя" R = load_json(config.ROUTERS_FILE, {}) rn = _find_router(R, a1) if not rn: return "❌ Не найден" del R[rn] save_json(config.ROUTERS_FILE, R) return f"🗑 Удалён {rn}" return ""