225 lines
9.8 KiB
Python
225 lines
9.8 KiB
Python
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)
|