Add standalone GusVoice AWG bot and Linux installer
This commit is contained in:
commit
38c6a1a682
23 files changed
+2924
No files matched your search
+484
@@ -0,0 +1,484 @@
|
||||
"""A native GusVoice UI for an already installed AWG Toolza server."""
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import contextlib
|
||||
from dataclasses import dataclass
|
||||
import logging
|
||||
from pathlib import Path
|
||||
import re
|
||||
import secrets
|
||||
import shutil
|
||||
import time
|
||||
|
||||
from gusvoice_bot import GusVoiceApiError, InputFile
|
||||
|
||||
from . import __version__
|
||||
from .core import AwgError, EXPIRE, LIMIT, NAME, SPEED, esc, redacted, size
|
||||
|
||||
log = logging.getLogger("awg-gus")
|
||||
PAGE = 10
|
||||
FILE_MAX = 25 * 1024 * 1024
|
||||
|
||||
|
||||
def button(text, data):
|
||||
if len(data.encode()) > 64:
|
||||
raise ValueError("callback_data too long")
|
||||
return {"text": text, "callback_data": data}
|
||||
|
||||
|
||||
HOME = [button("🏠 Меню", "home")]
|
||||
|
||||
|
||||
def client_status(c):
|
||||
if c.get("blocked"):
|
||||
return "🚫 заблокирован"
|
||||
if c.get("online"):
|
||||
return "🟢 онлайн"
|
||||
return "⚪ офлайн" if c.get("handshake") else "⚪ ещё не подключался"
|
||||
|
||||
|
||||
@dataclass
|
||||
class Event:
|
||||
uid: str
|
||||
chat: str
|
||||
update_id: int
|
||||
message: str | None = None
|
||||
text: str = ""
|
||||
data: str = ""
|
||||
|
||||
|
||||
class Bot:
|
||||
def __init__(self, config, api, delivery, state):
|
||||
self.config, self.api, self.io, self.state = config, api, delivery, state
|
||||
self.owners = set(config["owners"])
|
||||
self.steps = {}
|
||||
self.confirmations = {}
|
||||
self.tasks = {}
|
||||
self.interrupted = {row[0] for row in state.db.execute("SELECT uid FROM pending")}
|
||||
with state.db:
|
||||
state.db.execute("DELETE FROM pending")
|
||||
self.observed = {}
|
||||
|
||||
async def screen(self, e, text, rows=None):
|
||||
out = await self.io.show(e.chat, text, rows if rows is not None else [HOME], e.message)
|
||||
if isinstance(out, dict):
|
||||
e.message = out.get("message_id")
|
||||
return out
|
||||
|
||||
async def clients(self):
|
||||
data = (await self.api.call("clients", "list")).get("data")
|
||||
if not isinstance(data, list):
|
||||
raise AwgError("Неверный ответ clients list")
|
||||
return [c for c in data if isinstance(c, dict) and isinstance(c.get("name"), str) and NAME.fullmatch(c["name"])]
|
||||
|
||||
async def client(self, name):
|
||||
if not NAME.fullmatch(name):
|
||||
raise ValueError("Неверное имя клиента")
|
||||
c = next((c for c in await self.clients() if c["name"] == name), None)
|
||||
if c is None:
|
||||
raise ValueError("Клиент уже удалён. Обновите список.")
|
||||
return c
|
||||
|
||||
async def process(self, update):
|
||||
n = update.get("update_id")
|
||||
if not isinstance(n, int) or isinstance(n, bool) or n < self.state.get("offset", 0):
|
||||
return
|
||||
cb = update.get("callback_query")
|
||||
msg = cb.get("message") if isinstance(cb, dict) else update.get("message")
|
||||
author = cb.get("from") if isinstance(cb, dict) else (msg or {}).get("from")
|
||||
msg, author = msg or {}, author or {}
|
||||
chat = msg.get("chat") or {}
|
||||
uid, cid = author.get("id"), chat.get("id")
|
||||
allowed = (chat.get("type") == "dm" and uid in self.owners and isinstance(cid, str))
|
||||
if not allowed:
|
||||
self.state.claim(n)
|
||||
if isinstance(cb, dict):
|
||||
await self.io.ack(cb["id"], "Доступ только владельцу в личных сообщениях")
|
||||
elif chat.get("type") == "dm" and (msg.get("text") or "").strip() == "/whoami":
|
||||
await self.io.show(cid, f"Ваш ID: <code>{esc(uid)}</code>\nДоступ к управлению не выдан.")
|
||||
return
|
||||
key = (uid, cid)
|
||||
busy = key in self.tasks and not self.tasks[key].done()
|
||||
# Persist deduplication before ack or starting a privileged operation.
|
||||
self.state.claim(n, uid, cid)
|
||||
if isinstance(cb, dict):
|
||||
try:
|
||||
await self.io.ack(cb["id"], "Предыдущая операция ещё выполняется" if busy else None)
|
||||
except Exception:
|
||||
self.state.finish(n)
|
||||
raise
|
||||
if busy:
|
||||
self.state.finish(n)
|
||||
if not cb:
|
||||
await self.io.show(cid, "⏳ Предыдущая операция ещё выполняется. Дождитесь результата.")
|
||||
return
|
||||
e = Event(uid, cid, n, msg.get("message_id") if cb else None,
|
||||
msg.get("text") or "", cb.get("data") or "" if cb else "")
|
||||
self.tasks[key] = asyncio.create_task(self._handle(e), name="awg-gus-action")
|
||||
|
||||
async def _handle(self, e):
|
||||
completed = True
|
||||
try:
|
||||
if e.uid in self.interrupted:
|
||||
self.interrupted.remove(e.uid)
|
||||
await self.io.show(e.chat, "⚠️ Предыдущий запуск прервался во время обработки. "
|
||||
"Проверьте состояние: незавершённые действия автоматически не повторяются.")
|
||||
if e.data:
|
||||
await self.action(e)
|
||||
else:
|
||||
await self.message(e)
|
||||
except asyncio.CancelledError:
|
||||
completed = False
|
||||
raise
|
||||
except (AwgError, ValueError) as exc:
|
||||
await self._error(e, str(exc))
|
||||
except Exception as exc:
|
||||
# API/transport exceptions may contain a sensitive URL. Log class only.
|
||||
log.error("Handler failed: %s (update %s)", type(exc).__name__, e.update_id)
|
||||
await self._error(e, "Ошибка связи или обработки. Если действие изменяло сервер, проверьте результат перед повтором.")
|
||||
finally:
|
||||
if completed:
|
||||
self.state.finish(e.update_id)
|
||||
|
||||
def cancel_confirmations(self, e):
|
||||
self.confirmations = {k: v for k, v in self.confirmations.items() if v[:2] != (e.uid, e.chat)}
|
||||
|
||||
async def _error(self, e, text):
|
||||
with contextlib.suppress(Exception):
|
||||
await self.screen(e, "❌ " + esc(redacted(text)[:650]))
|
||||
|
||||
async def message(self, e):
|
||||
text = e.text.strip()
|
||||
command = text.split(maxsplit=1)[0].split("@", 1)[0] if text else ""
|
||||
if command.startswith("/"):
|
||||
self.cancel_confirmations(e)
|
||||
if command in {"/start", "/menu", "/status", "/cancel"}:
|
||||
self.steps.pop((e.uid, e.chat), None)
|
||||
await self.home(e)
|
||||
elif command == "/clients":
|
||||
await self.list_clients(e)
|
||||
elif command == "/whoami":
|
||||
await self.screen(e, f"Ваш ID: <code>{esc(e.uid)}</code>\nДоступ: владелец.")
|
||||
elif command == "/help":
|
||||
await self.screen(e, "<b>AWG для Гуся</b>\n/menu — главное меню\n/clients — клиенты\n"
|
||||
"/cancel — отменить ввод\n/whoami — ваш ID\n\n"
|
||||
"Изменения клиентов общие с Telegram-ботом: оба управляют одним awg2.")
|
||||
elif command == "/new":
|
||||
await self.prompt(e, "add", "", "Имя нового клиента: латиница, цифры, _ и -, до 32 символов.")
|
||||
elif (e.uid, e.chat) in self.steps:
|
||||
await self.input(e, text)
|
||||
else:
|
||||
await self.home(e)
|
||||
|
||||
async def home(self, e):
|
||||
d = (await self.api.call("status")).get("data") or {}
|
||||
s = d.get("server") or {}
|
||||
state = "🟢 работает" if s.get("up") else "🔴 не поднят"
|
||||
text = (f"<b>🪿 AWG для Гуся · {__version__}</b>\n"
|
||||
f"Toolza: {esc(d.get('version', '?'))}\n"
|
||||
f"Сервер: {esc(d.get('host', '?'))}\nAWG: {state}\n"
|
||||
f"Клиенты: {s.get('clients', 0)} · онлайн {s.get('online', 0)}\n"
|
||||
f"Протокол: {esc(s.get('proto', '?'))} · порт {esc(s.get('port', '?'))}")
|
||||
await self.screen(e, text, [
|
||||
[button("👥 Клиенты", "list:0"), button("➕ Создать", "new")],
|
||||
[button("🖥 Сервер", "server"), button("🩺 Диагностика", "diag")],
|
||||
[button("💾 Резервная копия", "backup"), button("📦 Экспорт клиентов", "export")],
|
||||
[button("🔔 Уведомления", "notices"), button("ℹ️ О боте", "about")],
|
||||
[button("🔄 Обновить", "home")]])
|
||||
|
||||
async def list_clients(self, e, page=0):
|
||||
rows = sorted(await self.clients(), key=lambda c: (not c.get("online"), c["name"].lower()))
|
||||
page = min(max(0, page), max(0, (len(rows) - 1) // PAGE))
|
||||
buttons = [[button(f"{'🚫' if c.get('blocked') else '🟢' if c.get('online') else '⚪'} {c['name']}",
|
||||
"client:" + c["name"])] for c in rows[page * PAGE:(page + 1) * PAGE]]
|
||||
nav = []
|
||||
if page:
|
||||
nav.append(button("←", f"list:{page-1}"))
|
||||
if (page + 1) * PAGE < len(rows):
|
||||
nav.append(button("→", f"list:{page+1}"))
|
||||
if nav:
|
||||
buttons.append(nav)
|
||||
buttons += [[button("➕ Создать", "new"), button("🔄 Обновить", f"list:{page}")], HOME]
|
||||
await self.screen(e, f"<b>👥 Клиенты: {len(rows)}</b>\nСтраница {page+1}. Выберите клиента.", buttons)
|
||||
|
||||
async def card(self, e, name):
|
||||
c = await self.client(name)
|
||||
exp = c.get("expires")
|
||||
expiry = time.strftime("%Y-%m-%d %H:%M", time.localtime(exp)) if exp else "без ограничения"
|
||||
text = (f"<b>👤 {esc(name)}</b>\n{client_status(c)}\nIP: <code>{esc(c.get('ip', ''))}</code>\n"
|
||||
f"Трафик: ↓ {size(c.get('rx'))} · ↑ {size(c.get('tx'))}\nСрок: {expiry} (время сервера)\n")
|
||||
text += (f"Лимит: {size(c.get('used'))} / {size(c['limit'])} ({esc(c.get('period', 'month'))})\n"
|
||||
if c.get("limit") else "Лимит: нет\n")
|
||||
text += f"Скорость: {esc(c.get('speed') or 'не ограничена')}" + (" Мбит/с" if c.get("speed") else "")
|
||||
watching = self.state.watched(e.uid, name)
|
||||
await self.screen(e, text, [
|
||||
[button("📄 Конфиг + QR", f"conf:{name}"), button("🔄 Обновить", f"client:{name}")],
|
||||
[button("📅 Срок", f"expiry:{name}"), button("📊 Лимит", f"limit:{name}")],
|
||||
[button("🚀 Скорость", f"speed:{name}"), button("✏️ Имя", f"rename:{name}")],
|
||||
[button("🔑 Новый ключ", f"reissue:{name}"), button("🗑 Удалить", f"delete:{name}")],
|
||||
[button("🔕 Не следить" if watching else "🔔 Следить", f"watch:{name}"),
|
||||
button("♻️ Сбросить трафик", f"reset:{name}")],
|
||||
[button("← Клиенты", "list:0")], HOME])
|
||||
|
||||
async def action(self, e):
|
||||
op, _, arg = e.data.partition(":")
|
||||
if op != "yes":
|
||||
self.steps.pop((e.uid, e.chat), None)
|
||||
self.cancel_confirmations(e)
|
||||
if op == "home":
|
||||
await self.home(e)
|
||||
elif op == "list":
|
||||
await self.list_clients(e, int(arg) if arg.isdigit() else 0)
|
||||
elif op == "new":
|
||||
await self.prompt(e, "add", "", "Имя нового клиента: латиница, цифры, _ и -, до 32 символов.")
|
||||
elif op == "client":
|
||||
await self.card(e, arg)
|
||||
elif op == "conf":
|
||||
await self.client(arg)
|
||||
await self.send_config(e, arg)
|
||||
elif op == "expiry":
|
||||
await self.client(arg)
|
||||
await self.screen(e, f"<b>Срок: {esc(arg)}</b>\nОтсчитывается от момента применения.", [
|
||||
[button("1 день", f"exp:{arg}:+1d"), button("7 дней", f"exp:{arg}:+7d")],
|
||||
[button("30 дней", f"exp:{arg}:+30d"), button("Без срока", f"exp:{arg}:off")],
|
||||
[button("← Клиент", f"client:{arg}")]])
|
||||
elif op == "exp":
|
||||
name, _, value = arg.partition(":")
|
||||
await self.client(name)
|
||||
args = ["client", "unexpire", name] if value == "off" else ["client", "expire", name, value]
|
||||
await self.confirm(e, f"Изменить срок {esc(name)}: {esc(value)}?", args, name)
|
||||
elif op in {"rename", "limit", "speed"}:
|
||||
await self.client(arg)
|
||||
prompt = {"rename": "Новое имя: латиница, цифры, _ и -, до 32 символов.",
|
||||
"limit": "Лимит: например 50G или 500M. Добавьте total для общего лимита: 50G total.\n"
|
||||
"По умолчанию — за месяц. off — снять ограничение.",
|
||||
"speed": "Скорость в Мбит/с: целое число, например 20. off — снять ограничение."}[op]
|
||||
await self.prompt(e, op, arg, prompt)
|
||||
elif op in {"delete", "reissue", "reset"}:
|
||||
await self.client(arg)
|
||||
verb, label = {"delete": ("del", "Удалить клиента и его доступ"),
|
||||
"reissue": ("reissue", "Перевыпустить ключ: старый конфиг перестанет работать"),
|
||||
"reset": ("limit-reset", "Сбросить счётчик лимита трафика")}[op]
|
||||
await self.confirm(e, f"{label}: <b>{esc(arg)}</b>?", ["client", verb, arg], arg)
|
||||
elif op == "watch":
|
||||
await self.client(arg)
|
||||
self.state.watch(e.uid, arg)
|
||||
await self.card(e, arg)
|
||||
elif op == "newexp":
|
||||
draft = self.state.get(f"draft:{e.uid}:{e.chat}")
|
||||
if not draft or time.time() - draft["time"] > 600:
|
||||
raise ValueError("Ввод устарел. Создайте клиента заново.")
|
||||
value = arg
|
||||
args = ["client", "add", draft["name"], "mimicry=server"]
|
||||
if value != "off":
|
||||
args.append("expire=" + value)
|
||||
await self.confirm(e, f"Создать <b>{esc(draft['name'])}</b>?\nСрок: {esc(value)}; мимикрия сервера.", args, draft["name"])
|
||||
elif op == "yes":
|
||||
await self.execute(e, arg)
|
||||
elif op == "server":
|
||||
d = (await self.api.call("server", "info")).get("data") or {}
|
||||
text = "<b>🖥 Сервер AWG</b>\n" + "\n".join(
|
||||
f"{label}: {esc(d.get(key, '—'))}" for key, label in
|
||||
[("up", "Работает"), ("proto", "Протокол"), ("profile", "Профиль"),
|
||||
("endpoint", "Endpoint"), ("net", "Подсеть"), ("mtu", "MTU"), ("clients", "Клиенты")])
|
||||
await self.screen(e, text, [[button("🔄 Перезапустить AWG", "restart")], HOME])
|
||||
elif op == "restart":
|
||||
await self.confirm(e, "Перезапустить интерфейс AWG? Соединения VPN кратковременно прервутся.", ["server", "restart"])
|
||||
elif op == "diag":
|
||||
r = await self.api.call("diag", "status")
|
||||
text = redacted(r.get("log") or "Диагностика завершена")
|
||||
text = re.sub(r"\x1b\[[0-9;]*[A-Za-z]", "", text)
|
||||
await self.screen(e, "<b>🩺 Диагностика</b>\n<pre>" + esc(text[-1800:]) + "</pre>")
|
||||
elif op in {"backup", "export"}:
|
||||
args = ["backup", "create"] if op == "backup" else ["clients", "export"]
|
||||
await self.confirm(e, "Создать и прислать архив в эту личную беседу? Архив содержит ключи VPN.", args)
|
||||
elif op == "notices":
|
||||
on = self.state.get(f"notify:{e.uid}", False)
|
||||
watches = [r[0] for r in self.state.db.execute("SELECT name FROM watches WHERE uid=?", (e.uid,))]
|
||||
await self.screen(e, "<b>🔔 Уведомления</b>\nСостояние сервера: " + ("включены" if on else "выключены") +
|
||||
"\nНаблюдение за клиентами: " + esc(", ".join(watches) or "нет") +
|
||||
"\nПроверка раз в минуту. Клиент офлайн после 5 минут без рукопожатия.\n"
|
||||
"При старте бот запоминает состояние без рассылки.",
|
||||
[[button("Выключить серверные" if on else "Включить серверные", "notify")], HOME])
|
||||
elif op == "notify":
|
||||
key = f"notify:{e.uid}"
|
||||
self.state.set(key, not self.state.get(key, False))
|
||||
e.data = "notices"
|
||||
await self.action(e)
|
||||
elif op == "about":
|
||||
await self.screen(e, f"<b>AWG для Гуся {__version__}</b>\n"
|
||||
"Отдельная служба: awg-gus-bot. Управление тем же awg2, что и в Telegram.\n"
|
||||
"Обновление на сервере: <code>sudo awg-gus-bot update</code>\n"
|
||||
"Проверка: <code>sudo awg-gus-bot check</code>\n"
|
||||
"В этой версии: клиенты, ключи/QR, сроки, трафик, скорость, диагностика, "
|
||||
"бэкап и экспорт, перезапуск AWG, уведомления.\n"
|
||||
"Установка AWG, туннели, обновление ядра и восстановление бэкапа — через меню awg2.")
|
||||
else:
|
||||
raise ValueError("Кнопка устарела. Откройте /menu.")
|
||||
|
||||
async def prompt(self, e, kind, name, text):
|
||||
self.steps[(e.uid, e.chat)] = (kind, name, time.monotonic() + 600)
|
||||
await self.screen(e, text + "\n\nОтмена: /cancel", [HOME])
|
||||
|
||||
async def input(self, e, text):
|
||||
key = (e.uid, e.chat)
|
||||
kind, name, expires = self.steps[key]
|
||||
if time.monotonic() > expires:
|
||||
self.steps.pop(key, None)
|
||||
raise ValueError("Ввод устарел. Откройте меню заново.")
|
||||
if kind in {"add", "rename"}:
|
||||
if not NAME.fullmatch(text):
|
||||
raise ValueError("Имя: латиница, цифры, _ и -, от 1 до 32 символов. Повторите ввод.")
|
||||
if any(c["name"] == text for c in await self.clients()):
|
||||
raise ValueError("Такое имя уже существует. Введите другое.")
|
||||
if kind == "add":
|
||||
self.state.set(f"draft:{e.uid}:{e.chat}", {"name": text, "time": time.time()})
|
||||
await self.screen(e, f"Срок нового клиента <b>{esc(text)}</b>:", [
|
||||
[button("1 день", "newexp:+1d"), button("7 дней", "newexp:+7d")],
|
||||
[button("30 дней", "newexp:+30d"), button("Без срока", "newexp:off")], HOME])
|
||||
else:
|
||||
await self.confirm(e, f"Переименовать {esc(name)} → {esc(text)}?", ["client", "rename", name, text], text)
|
||||
elif kind == "limit":
|
||||
parts = text.split()
|
||||
value = parts[0] if parts else ""
|
||||
value = value if value == "off" else value.upper()
|
||||
period = parts[1] if len(parts) == 2 else "month"
|
||||
if len(parts) > 2 or not LIMIT.fullmatch(value) or period not in {"month", "total"}:
|
||||
raise ValueError("Введите 50G, 500M, 50G total или off.")
|
||||
await self.confirm(e, f"Лимит {esc(name)}: {esc(value)} ({period})?", ["client", "limit", name, value, period], name)
|
||||
elif kind == "speed":
|
||||
if not SPEED.fullmatch(text):
|
||||
raise ValueError("Введите целое число Мбит/с или off.")
|
||||
await self.confirm(e, f"Скорость {esc(name)}: {esc(text)} Мбит/с?", ["client", "speed", name, text], name)
|
||||
self.steps.pop(key, None)
|
||||
|
||||
async def confirm(self, e, text, args, name=""):
|
||||
from .core import validate_command
|
||||
validate_command(args)
|
||||
now = time.monotonic()
|
||||
self.confirmations = {k: v for k, v in self.confirmations.items()
|
||||
if v[0:2] != (e.uid, e.chat) and v[2] > now}
|
||||
token = secrets.token_hex(8)
|
||||
self.confirmations[token] = (e.uid, e.chat, now + 120, args, name)
|
||||
await self.screen(e, text + "\n\nПодтверждение действует 2 минуты.",
|
||||
[[button("✅ Подтвердить", "yes:" + token), button("Отмена", "home")]])
|
||||
|
||||
async def execute(self, e, token):
|
||||
pending = self.confirmations.get(token)
|
||||
if not pending or pending[:2] != (e.uid, e.chat) or pending[2] < time.monotonic():
|
||||
raise ValueError("Подтверждение устарело или уже использовано. Повторите действие из меню.")
|
||||
self.confirmations.pop(token)
|
||||
args, name = pending[3:]
|
||||
await self.screen(e, "⏳ Выполняю…")
|
||||
r = await self.api.call(*args)
|
||||
await self.screen(e, "✅ Выполнено", [[button("👥 Клиенты", "list:0")], HOME])
|
||||
if args[:2] in (["client", "add"], ["client", "reissue"]):
|
||||
await self.send_config(e, name)
|
||||
elif args[:2] in (["backup", "create"], ["clients", "export"]):
|
||||
data = r.get("data") or {}
|
||||
path = Path(data.get("path") or data.get("file") or "")
|
||||
if not path.is_absolute() or not path.is_file():
|
||||
raise AwgError("Архив создан, но awg2 не вернул путь к файлу.")
|
||||
if path.stat().st_size > FILE_MAX:
|
||||
await self.io.show(e.chat, "Архив больше 25 МиБ и сохранён на сервере. Заберите его через SSH.")
|
||||
else:
|
||||
await self.io.call("send_message", chat_id=e.chat, text="Архив AWG: содержит ключи VPN.", files=[InputFile(path)])
|
||||
|
||||
async def send_config(self, e, name):
|
||||
data = (await self.api.call("client", "conf", name)).get("data") or {}
|
||||
text = data.get("text")
|
||||
if not isinstance(text, str) or not text or len(text.encode()) > FILE_MAX:
|
||||
raise AwgError("Конфиг отсутствует или превышает допустимый размер.")
|
||||
files = [InputFile(text.encode(), filename=name + ".conf", content_type="text/plain")]
|
||||
note = ""
|
||||
if len(text.encode()) <= 2800 and shutil.which("qrencode"):
|
||||
proc = await asyncio.create_subprocess_exec("qrencode", "-t", "PNG", "-o", "-", "-s", "8", "-m", "2",
|
||||
stdin=asyncio.subprocess.PIPE, stdout=asyncio.subprocess.PIPE, stderr=asyncio.subprocess.DEVNULL)
|
||||
try:
|
||||
png, _ = await asyncio.wait_for(proc.communicate(text.encode()), 10)
|
||||
if proc.returncode == 0 and png.startswith(b"\x89PNG\r\n\x1a\n"):
|
||||
files.append(InputFile(png, filename=name + ".png", content_type="image/png"))
|
||||
else:
|
||||
note = " QR не сформирован; импортируйте файл."
|
||||
except (asyncio.TimeoutError, asyncio.CancelledError) as exc:
|
||||
with contextlib.suppress(ProcessLookupError):
|
||||
proc.kill()
|
||||
await proc.wait()
|
||||
if isinstance(exc, asyncio.CancelledError):
|
||||
raise
|
||||
note = " QR не сформирован; импортируйте файл."
|
||||
else:
|
||||
note = " QR недоступен или конфиг слишком длинный; импортируйте файл."
|
||||
await self.io.call("send_message", chat_id=e.chat, text=f"{name} — импорт в AmneziaVPN / AmneziaWG.{note}", files=files)
|
||||
|
||||
async def monitor_tick(self):
|
||||
watches = list(self.state.db.execute("SELECT uid,name FROM watches"))
|
||||
notify = [uid for uid in self.owners if self.state.get(f"notify:{uid}", False)]
|
||||
if not watches and not notify:
|
||||
return
|
||||
clients = {c["name"]: c for c in await self.clients()} if watches else {}
|
||||
current = {}
|
||||
for uid, name in watches:
|
||||
if uid not in self.owners:
|
||||
continue
|
||||
c = clients.get(name)
|
||||
status = "удалён" if c is None else "заблокирован" if c.get("blocked") else (
|
||||
"онлайн" if c.get("handshake") and float(c.get("ago") or 0) < 300 else "офлайн")
|
||||
current[(uid, name)] = status
|
||||
if notify:
|
||||
d = (await self.api.call("server", "info")).get("data") or {}
|
||||
for uid in notify:
|
||||
current[(uid, "Сервер AWG")] = "работает" if d.get("up") else "не поднят"
|
||||
for key, status in current.items():
|
||||
previous = self.observed.get(key)
|
||||
if previous is not None and previous != status:
|
||||
row = self.state.db.execute("SELECT chat FROM chats WHERE uid=?", (key[0],)).fetchone()
|
||||
if row:
|
||||
await self.io.show(row[0], f"🔔 <b>{esc(key[1])}</b>: {esc(previous)} → {esc(status)}")
|
||||
self.observed = current
|
||||
|
||||
async def monitor(self):
|
||||
while True:
|
||||
try:
|
||||
await self.monitor_tick()
|
||||
except Exception as exc:
|
||||
log.warning("Monitor failed: %s", type(exc).__name__)
|
||||
await asyncio.sleep(60)
|
||||
|
||||
async def run(self):
|
||||
webhook = await self.io.call("get_webhook_info")
|
||||
if webhook.get("url"):
|
||||
raise ValueError("У этого бота уже настроен webhook. Используйте отдельного бота Гуся.")
|
||||
await self.io.call("set_my_commands", commands=[
|
||||
{"command": "menu", "description": "Управление AWG"},
|
||||
{"command": "clients", "description": "Клиенты VPN"},
|
||||
{"command": "new", "description": "Создать клиента"},
|
||||
{"command": "cancel", "description": "Отменить ввод"},
|
||||
{"command": "help", "description": "Помощь"}])
|
||||
monitor = asyncio.create_task(self.monitor())
|
||||
delay = 1
|
||||
try:
|
||||
while True:
|
||||
try:
|
||||
updates = await self.io.call("get_updates", offset=self.state.get("offset", 0), timeout=30,
|
||||
allowed_updates=["message", "callback_query"])
|
||||
for update in updates:
|
||||
await self.process(update)
|
||||
delay = 1
|
||||
except GusVoiceApiError as exc:
|
||||
if exc.code in {401, 403, 404, 409}:
|
||||
log.error("Polling stopped: HTTP %s. Check token, enabled bots, webhook and duplicate processes.", exc.code)
|
||||
raise
|
||||
log.warning("Polling retry: HTTP %s", exc.code)
|
||||
await asyncio.sleep(delay)
|
||||
delay = min(30, delay * 2)
|
||||
except (OSError, TimeoutError) as exc:
|
||||
log.warning("Network retry: %s", type(exc).__name__)
|
||||
await asyncio.sleep(delay)
|
||||
delay = min(30, delay * 2)
|
||||
finally:
|
||||
monitor.cancel()
|
||||
for task in self.tasks.values():
|
||||
task.cancel()
|
||||
await asyncio.gather(monitor, *self.tasks.values(), return_exceptions=True)
|
||||
Reference in new issue
Block a user