Add standalone GusVoice AWG bot and Linux installer

This commit is contained in:
wenil committed 2026-10-30 14:44:04 +03:00
commit 38c6a1a682
23 files changed
+2924

No files matched your search

+213
View File
@@ -0,0 +1,213 @@
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
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}: нужен абсолютный путь")
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())
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}
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():
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)