from __future__ import annotations import argparse import asyncio import contextlib import getpass import hmac import json import logging import os from pathlib import Path import secrets import signal import sys import time from urllib.parse import urlsplit import uuid from gusvoice_bot import Client, GusVoiceApiError from . import __version__ from .bot import Bot from .core import Awg, Delivery, State, client_for from .profiles import assert_unique_bot DEFAULT_CONFIG = "/etc/awg-gus-bot.json" DEFAULT_STATE = "/var/lib/awg-gus-bot" def validate_url(url): u = urlsplit(url.strip()) if u.scheme != "https" or not u.hostname or u.username or u.password or u.query or u.fragment: raise ValueError("Укажите HTTPS-адрес Гуся без логина, пароля и параметров.") if u.path.rstrip("/") not in {"", "/api"}: raise ValueError("Нужен адрес сайта/сервера API, без /api/bot/v1.") return url.strip().rstrip("/").removesuffix("/api") def validate_config(d): validate_url(d["url"]) if not isinstance(d.get("token"), str) or not d["token"].startswith("gvb_"): raise ValueError("Требуется токен бота Гуся gvb_…") if not isinstance(d.get("owners"), list) or not d["owners"]: raise ValueError("Не задан владелец") for owner in d["owners"]: uuid.UUID(owner) uuid.UUID(d["bot_id"]) for key in ("awg2", "state_dir"): if not Path(d[key]).is_absolute(): raise ValueError(f"{key}: нужен абсолютный путь") if "label" in d and (not isinstance(d["label"], str) or len(d["label"]) > 80 or any(ord(c) < 32 for c in d["label"])): raise ValueError("Название подключения должно быть одной строкой до 80 символов") return d def load_config(path): with open(path, encoding="utf-8") as f: return validate_config(json.load(f)) def save_config(path, data): path = Path(path) path.parent.mkdir(parents=True, exist_ok=True) tmp = path.with_name(path.name + ".new") fd = os.open(tmp, os.O_WRONLY | os.O_CREAT | os.O_TRUNC, 0o600) try: with os.fdopen(fd, "w", encoding="utf-8") as f: json.dump(data, f, ensure_ascii=False, indent=2) f.write("\n") f.flush() os.fsync(f.fileno()) if os.name == "posix": tmp.chmod(0o600) os.replace(tmp, path) finally: with contextlib.suppress(FileNotFoundError): tmp.unlink() def check_features(me): uuid.UUID(me["id"]) # HTML rendering was introduced with API 3; do not silently send HTML to an older server. if int(me.get("api_version") or 0) < 3: raise ValueError("Нужен GusVoice Bot API 3 или новее (кнопки, команды и HTML). Обновите Гуся.") features = set(me.get("features") or []) if not {"keyboards", "commands", "parse_mode"}.issubset(features): raise ValueError("Инстанс не объявляет необходимые возможности: keyboards, commands, parse_mode.") def pair(client, code, *, duration=600, now=time.monotonic): deadline = now() + duration offset = 0 while now() < deadline: updates = client.get_updates(offset=offset, timeout=10, allowed_updates=["message"]) for u in updates: offset = max(offset, int(u["update_id"]) + 1) msg = u.get("message") or {} chat, author = msg.get("chat") or {}, msg.get("from") or {} text = (msg.get("text") or "").strip() if chat.get("type") != "dm" or author.get("bot"): continue if not hmac.compare_digest(text.encode("utf-8"), ("/start " + code).encode("utf-8")): continue try: uuid.UUID(author["id"]) uuid.UUID(chat["id"]) except (KeyError, ValueError, TypeError, AttributeError): continue client.get_updates(offset=offset, timeout=0, allowed_updates=[]) return author["id"], chat["id"], offset raise ValueError("Время привязки истекло. Запустите настройку ещё раз.") def configure(args): if not sys.stdin.isatty(): raise ValueError("Настройка требует терминала. Скачайте install.sh в файл и запустите sudo bash install.sh.") print("AWG для Гуся: настройка отдельного бота. Mini App не используется.") url = validate_url(input("HTTPS-адрес Гуся: ")) token = getpass.getpass("Токен нового бота gvb_… (ввод скрыт): ").strip() if not token.startswith("gvb_"): raise ValueError("Токен должен начинаться с gvb_") client = Client(token, url) me = client.get_me() check_features(me) if client.get_webhook_info().get("url"): raise ValueError("У этого бота настроен webhook. Создайте отдельного бота для AWG.") resolved = validate_url(client.api_url()) candidate = {"url": resolved, "token": token, "bot_id": me["id"]} assert_unique_bot(args.config, candidate, getattr(args, "layout", None)) code = secrets.token_urlsafe(18) print(f"\nОткройте личную беседу с @{me.get('username', '')} в Гусе.") print("Отправьте следующее сообщение (код действует 10 минут):\n") print("/start " + code) print("\nЖду сообщение…", flush=True) owner, chat, offset = pair(client, code) d = {"url": resolved, "token": token, "owners": [owner], "bot_id": me["id"], "awg2": args.awg2, "state_dir": args.state_dir} if getattr(args, "label", None): d["label"] = args.label validate_config(d) state = State(Path(args.state_dir) / "state.sqlite3", resolved + "|" + me["id"]) state.set("offset", offset) with state.db: state.db.execute("INSERT OR REPLACE INTO chats VALUES (?,?)", (owner, chat)) state.db.close() save_config(args.config, d) # Pairing is complete at this point; a delivery failure must not discard the valid config. with contextlib.suppress(Exception): client.send_message(chat, "✅ Аккаунт привязан. После завершения установки отправьте /menu.") print("Владелец привязан. Конфигурация сохранена.") async def check(args): d = load_config(args.config) me = await client_for(d).get_me() check_features(me) if me["id"] != d["bot_id"]: raise ValueError("Токен принадлежит другому боту. Повторите настройку с отдельным state_dir.") if (await client_for(d).get_webhook_info()).get("url"): raise ValueError("Задан webhook: нужен отдельный бот для polling.") await Awg(d["awg2"]).call("version", timeout=30) print(f"OK: AWG для Гуся {__version__}; GusVoice API {me['api_version']}; awg2 api отвечает.") async def run(args): d = load_config(args.config) client = client_for(d) me = await client.get_me() check_features(me) if me["id"] != d["bot_id"]: raise ValueError("Неверная привязка бота. Выполните setup.") state = State(Path(d["state_dir"]) / "state.sqlite3", d["url"] + "|" + d["bot_id"]) # flock prevents accidental second local process; the API prevents remote duplicate consumers. import fcntl lock = open(Path(d["state_dir"]) / "process.lock", "a") try: fcntl.flock(lock, fcntl.LOCK_EX | fcntl.LOCK_NB) except BlockingIOError as e: raise ValueError("Этот бот уже запущен") from e bot = Bot(d, Awg(d["awg2"]), Delivery(client), state) task = asyncio.create_task(bot.run()) loop = asyncio.get_running_loop() for sig in (signal.SIGTERM, signal.SIGINT): loop.add_signal_handler(sig, task.cancel) logging.info("AWG Gus bot %s started", __version__) try: await task except asyncio.CancelledError: pass finally: state.db.close() lock.close() def main(): if len(sys.argv) > 1 and sys.argv[1] == "manage": from .manage import main as manage_main return manage_main(sys.argv[2:]) parser = argparse.ArgumentParser(description="AWG Toolza bot for GusVoice") parser.add_argument("command", choices=["run", "setup", "check"]) parser.add_argument("--config", default=DEFAULT_CONFIG) parser.add_argument("--awg2", default="/usr/local/bin/awg2") parser.add_argument("--state-dir", default=DEFAULT_STATE) args = parser.parse_args() os.umask(0o077) logging.basicConfig(level=logging.INFO, format="%(asctime)s %(levelname)s %(message)s") try: if args.command == "setup": configure(args) else: asyncio.run(check(args) if args.command == "check" else run(args)) except GusVoiceApiError as e: print(f"Ошибка GusVoice API: HTTP {e.code}. Проверьте адрес, токен и включение ботов.", file=sys.stderr) sys.exit(1) except (ValueError, FileNotFoundError, KeyError) as e: # No raw request URLs or token-bearing exception repr in logs. print(str(e) if isinstance(e, ValueError) else "Не найден или неверно заполнен конфигурационный файл.", file=sys.stderr) sys.exit(1) except KeyboardInterrupt: sys.exit(130) except Exception as e: print(f"Ошибка: {type(e).__name__}. Проверьте сеть, сертификаты и доступность awg2.", file=sys.stderr) sys.exit(1)