Add v2 support for independent GusVoice connections to one AWG server
This commit is contained in:
1 parent
38c6a1a682
commit
0e62fe20ed
9 files changed
+759
-148
No files matched your search
+1
-1
@@ -1 +1 @@
|
||||
__version__ = "0.1.0"
|
||||
__version__ = "0.2.0"
|
||||
@@ -176,6 +176,7 @@ class Bot:
|
||||
s = d.get("server") or {}
|
||||
state = "🟢 работает" if s.get("up") else "🔴 не поднят"
|
||||
text = (f"<b>🪿 AWG для Гуся · {__version__}</b>\n"
|
||||
+ (f"Подключение: {esc(self.config['label'])}\n" if self.config.get("label") else "") +
|
||||
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"
|
||||
|
||||
@@ -21,6 +21,7 @@ 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"
|
||||
@@ -47,6 +48,9 @@ def validate_config(d):
|
||||
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
|
||||
|
||||
|
||||
@@ -122,6 +126,8 @@ def configure(args):
|
||||
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")
|
||||
@@ -130,6 +136,8 @@ def configure(args):
|
||||
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)
|
||||
@@ -186,6 +194,9 @@ async def run(args):
|
||||
|
||||
|
||||
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)
|
||||
|
||||
@@ -0,0 +1,259 @@
|
||||
"""Local CLI for independently managed systemd connections to GusVoice."""
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import asyncio
|
||||
import contextlib
|
||||
import os
|
||||
from pathlib import Path
|
||||
import shutil
|
||||
import sqlite3
|
||||
import subprocess
|
||||
import sys
|
||||
import tempfile
|
||||
import time
|
||||
from types import SimpleNamespace
|
||||
|
||||
from .core import Awg
|
||||
from .main import check, configure, load_config
|
||||
from .profiles import Layout, profile_name
|
||||
|
||||
|
||||
def systemctl(*args, check=True):
|
||||
result = subprocess.run(["systemctl", *args], capture_output=True, text=True)
|
||||
if check and result.returncode:
|
||||
raise RuntimeError(f"systemctl {args[0]} завершился с кодом {result.returncode}")
|
||||
return result
|
||||
|
||||
|
||||
@contextlib.contextmanager
|
||||
def install_lock(layout):
|
||||
import fcntl
|
||||
path = layout.root / "run/lock/awg-gus-install.lock"
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
with path.open("a") as lock:
|
||||
try:
|
||||
fcntl.flock(lock, fcntl.LOCK_EX | fcntl.LOCK_NB)
|
||||
except BlockingIOError as exc:
|
||||
raise ValueError("Другая установка или настройка уже выполняется") from exc
|
||||
yield
|
||||
|
||||
|
||||
def install_units(layout):
|
||||
folder = layout.root / "etc/systemd/system"
|
||||
folder.mkdir(parents=True, exist_ok=True)
|
||||
for unit, config in [
|
||||
("awg-gus-bot.service", "/etc/awg-gus-bot.json"),
|
||||
("awg-gus-bot@.service", "/etc/awg-gus-bot/instances/%i.json"),
|
||||
]:
|
||||
text = f"""[Unit]
|
||||
Description=AWG Toolza bot for GusVoice ({'default' if '%' not in config else '%i'})
|
||||
After=network-online.target
|
||||
Wants=network-online.target
|
||||
StartLimitIntervalSec=300
|
||||
StartLimitBurst=5
|
||||
|
||||
[Service]
|
||||
Type=simple
|
||||
User=root
|
||||
WorkingDirectory=/opt/awg-gus-bot/current
|
||||
ExecStart={sys.executable} /opt/awg-gus-bot/current/run.py run --config {config}
|
||||
Environment=PYTHONUNBUFFERED=1
|
||||
UMask=0077
|
||||
Restart=on-failure
|
||||
RestartSec=10
|
||||
TimeoutStopSec=20
|
||||
KillMode=control-group
|
||||
|
||||
[Install]
|
||||
WantedBy=multi-user.target
|
||||
"""
|
||||
path = folder / unit
|
||||
if not path.exists() or path.read_text(encoding="utf-8") != text:
|
||||
temporary = path.with_suffix(path.suffix + ".new")
|
||||
temporary.write_text(text, encoding="utf-8")
|
||||
temporary.chmod(0o644)
|
||||
temporary.replace(path)
|
||||
|
||||
|
||||
def database_copy(source, destination):
|
||||
with contextlib.closing(sqlite3.connect(source)) as src:
|
||||
with contextlib.closing(sqlite3.connect(destination)) as dst:
|
||||
src.backup(dst)
|
||||
|
||||
|
||||
def setup_profile(name, *, reconfigure=False, layout=None, setup_fn=configure, check_fn=check):
|
||||
layout = layout or Layout()
|
||||
name = profile_name(name)
|
||||
path, state, unit = layout.config(name), layout.state(name), layout.unit(name)
|
||||
exists = path.is_file()
|
||||
if exists and not reconfigure:
|
||||
raise ValueError(f"Подключение {name} уже существует. Для него используйте setup {name}.")
|
||||
if reconfigure and not exists:
|
||||
raise ValueError(f"Подключение {name} не найдено")
|
||||
if exists:
|
||||
previous = load_config(path)
|
||||
awg2, state = previous["awg2"], Path(previous["state_dir"])
|
||||
else:
|
||||
awg2 = shutil.which("awg2")
|
||||
if not awg2:
|
||||
raise ValueError("Команда awg2 не найдена. Сначала установите AWG Toolza.")
|
||||
# This is read-only and happens before stopping the selected connection.
|
||||
asyncio.run(Awg(awg2).call("version", timeout=30))
|
||||
path.parent.mkdir(parents=True, exist_ok=True, mode=0o700)
|
||||
state.mkdir(parents=True, exist_ok=True, mode=0o700)
|
||||
state.chmod(0o700)
|
||||
database = state / "state.sqlite3"
|
||||
was_active = systemctl("is-active", "--quiet", unit, check=False).returncode == 0
|
||||
was_enabled = systemctl("is-enabled", "--quiet", unit, check=False).returncode == 0
|
||||
with tempfile.TemporaryDirectory(prefix=".setup-", dir=state) as temporary:
|
||||
backup = Path(temporary)
|
||||
if exists:
|
||||
shutil.copy2(path, backup / "config")
|
||||
had_database = database.exists()
|
||||
database_saved = False
|
||||
if was_active:
|
||||
systemctl("stop", unit)
|
||||
try:
|
||||
if had_database:
|
||||
database_copy(database, backup / "state")
|
||||
database_saved = True
|
||||
args = SimpleNamespace(config=str(path), awg2=awg2, state_dir=str(state),
|
||||
label=name, layout=layout)
|
||||
setup_fn(args)
|
||||
asyncio.run(check_fn(args))
|
||||
install_units(layout)
|
||||
systemctl("daemon-reload")
|
||||
systemctl("enable", unit)
|
||||
systemctl("reset-failed", unit, check=False)
|
||||
systemctl("restart", unit)
|
||||
time.sleep(2)
|
||||
if systemctl("is-active", "--quiet", unit, check=False).returncode:
|
||||
raise RuntimeError(f"Служба {unit} не запустилась; откатываю это подключение")
|
||||
except BaseException:
|
||||
systemctl("stop", unit, check=False)
|
||||
if exists:
|
||||
shutil.copy2(backup / "config", path)
|
||||
else:
|
||||
path.unlink(missing_ok=True)
|
||||
if database_saved:
|
||||
database_copy(backup / "state", database)
|
||||
elif not had_database:
|
||||
for suffix in ("", "-journal", "-wal", "-shm"):
|
||||
database.with_name(database.name + suffix).unlink(missing_ok=True)
|
||||
if not was_enabled:
|
||||
systemctl("disable", unit, check=False)
|
||||
if was_active:
|
||||
systemctl("reset-failed", unit, check=False)
|
||||
systemctl("start", unit, check=False)
|
||||
raise
|
||||
print(f"Подключение {name} готово. Отправьте /menu боту на этом сервере Гуся.")
|
||||
|
||||
|
||||
def choose_existing(layout, name):
|
||||
if name:
|
||||
profile_name(name)
|
||||
if name not in layout.names():
|
||||
raise ValueError(f"Подключение {name} не найдено")
|
||||
return name
|
||||
if "default" in layout.names():
|
||||
return "default"
|
||||
if len(layout.names()) == 1:
|
||||
return layout.names()[0]
|
||||
raise ValueError("Укажите имя подключения; список: awg-gus-bot list")
|
||||
|
||||
|
||||
def list_profiles(layout):
|
||||
if not layout.names():
|
||||
print("Подключений пока нет.")
|
||||
for name in layout.names():
|
||||
try:
|
||||
data = load_config(layout.config(name))
|
||||
status = systemctl("is-active", layout.unit(name), check=False).stdout.strip()
|
||||
print(f"{name}: {data['url']} — {status or 'inactive'}")
|
||||
except (ValueError, KeyError):
|
||||
print(f"{name}: ошибка настроек; проверьте это подключение отдельно")
|
||||
|
||||
|
||||
def dispatch(command, name, layout):
|
||||
if command == "menu":
|
||||
list_profiles(layout)
|
||||
print("\n1. Добавить ещё одного Гуся\n2. Обновить программу для всех подключений\n"
|
||||
"3. Список подключений\n4. Перенастроить подключение\n0. Выход")
|
||||
choice = input("Выберите действие: ").strip()
|
||||
if choice == "0":
|
||||
return
|
||||
if choice == "2":
|
||||
subprocess.run(["/usr/local/bin/awg-gus-bot", "update"], check=True)
|
||||
return
|
||||
command = {"1": "add", "3": "list", "4": "setup"}.get(choice)
|
||||
if not command:
|
||||
raise ValueError("Неизвестный пункт меню")
|
||||
if command == "setup":
|
||||
name = input("Имя подключения [default]: ").strip() or "default"
|
||||
if command in {"list", "status"} and not name:
|
||||
list_profiles(layout)
|
||||
elif command in {"add", "setup"}:
|
||||
if not sys.stdin.isatty():
|
||||
raise ValueError("Для привязки нужен терминал")
|
||||
if command == "add":
|
||||
name = name or input("Имя нового подключения (например gus2): ").strip()
|
||||
else:
|
||||
name = choose_existing(layout, name)
|
||||
with install_lock(layout):
|
||||
setup_profile(name, reconfigure=command == "setup", layout=layout)
|
||||
elif command == "services":
|
||||
for name in layout.names():
|
||||
print(layout.unit(name))
|
||||
elif command == "validate":
|
||||
for name in layout.names():
|
||||
load_config(layout.config(name))
|
||||
elif command == "install-units":
|
||||
install_units(layout)
|
||||
elif command == "check":
|
||||
failures = []
|
||||
names = [choose_existing(layout, name)] if name else layout.names()
|
||||
for item in names:
|
||||
try:
|
||||
print(f"Проверяю {item}…", flush=True)
|
||||
asyncio.run(check(SimpleNamespace(config=str(layout.config(item)))))
|
||||
except Exception as exc:
|
||||
print(f"{item}: {type(exc).__name__}; проверьте адрес, токен и доступность узла.")
|
||||
failures.append(item)
|
||||
if failures:
|
||||
raise ValueError("Не прошли проверку: " + ", ".join(failures))
|
||||
elif command in {"start", "stop", "restart", "enable", "disable", "status", "logs"}:
|
||||
name = choose_existing(layout, name)
|
||||
unit = layout.unit(name)
|
||||
if command == "logs":
|
||||
subprocess.run(["journalctl", "-u", unit, "-n", "80", "--no-pager"], check=True)
|
||||
elif command == "status":
|
||||
subprocess.run(["systemctl", "status", unit, "--no-pager"], check=True)
|
||||
else:
|
||||
with install_lock(layout):
|
||||
systemctl(command, *(["--now"] if command in {"enable", "disable"} else []), unit)
|
||||
else:
|
||||
raise ValueError("Неизвестная команда")
|
||||
|
||||
|
||||
def main(argv=None):
|
||||
parser = argparse.ArgumentParser(description="Несколько Гусей → один AWG-сервер")
|
||||
parser.add_argument("command", nargs="?", default="menu", choices=[
|
||||
"menu", "add", "setup", "list", "check", "status", "logs", "start", "stop", "restart",
|
||||
"enable", "disable", "services", "validate", "install-units"])
|
||||
parser.add_argument("name", nargs="?")
|
||||
args = parser.parse_args(argv)
|
||||
os.umask(0o077)
|
||||
try:
|
||||
if os.name != "posix" or os.geteuid() != 0:
|
||||
raise ValueError("Запустите на AWG-сервере через sudo awg-gus-bot")
|
||||
dispatch(args.command, args.name, Layout())
|
||||
except (ValueError, RuntimeError) as exc:
|
||||
print(f"Ошибка: {exc}", file=sys.stderr)
|
||||
sys.exit(1)
|
||||
except KeyboardInterrupt:
|
||||
print("Настройка отменена.", file=sys.stderr)
|
||||
sys.exit(130)
|
||||
except Exception as exc:
|
||||
print(f"Ошибка: {type(exc).__name__}. Проверьте сеть и журнал выбранной службы.", file=sys.stderr)
|
||||
sys.exit(1)
|
||||
@@ -0,0 +1,60 @@
|
||||
"""One local connection per GusVoice bot; legacy v1 paths remain valid."""
|
||||
from dataclasses import dataclass
|
||||
import json
|
||||
from pathlib import Path
|
||||
import re
|
||||
import uuid
|
||||
|
||||
|
||||
def profile_name(value):
|
||||
if not isinstance(value, str) or not re.fullmatch(r"[a-z][a-z0-9_-]{0,31}", value):
|
||||
raise ValueError("Имя подключения: строчная латиница, цифры, _ и -, до 32 символов; первая — буква.")
|
||||
return value
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class Layout:
|
||||
root: Path = Path("/")
|
||||
|
||||
def config(self, name):
|
||||
profile_name(name)
|
||||
return self.root / ("etc/awg-gus-bot.json" if name == "default"
|
||||
else f"etc/awg-gus-bot/instances/{name}.json")
|
||||
|
||||
def state(self, name):
|
||||
profile_name(name)
|
||||
base = self.root / "var/lib/awg-gus-bot"
|
||||
return base if name == "default" else base / "instances" / name
|
||||
|
||||
def names(self):
|
||||
names = ["default"] if self.config("default").is_file() else []
|
||||
for path in sorted((self.root / "etc/awg-gus-bot/instances").glob("*.json")):
|
||||
name = profile_name(path.stem)
|
||||
if name == "default":
|
||||
raise ValueError("Имя default зарезервировано для первого подключения.")
|
||||
names.append(name)
|
||||
return names
|
||||
|
||||
@staticmethod
|
||||
def unit(name):
|
||||
profile_name(name)
|
||||
return "awg-gus-bot.service" if name == "default" else f"awg-gus-bot@{name}.service"
|
||||
|
||||
|
||||
def assert_unique_bot(target, candidate, layout=None):
|
||||
"""Before polling: aliases/rotated tokens must not steal another bot's updates."""
|
||||
layout = layout or Layout()
|
||||
target = Path(target).resolve()
|
||||
for name in layout.names():
|
||||
path = layout.config(name)
|
||||
try:
|
||||
existing = json.loads(path.read_text(encoding="utf-8"))
|
||||
same_bot = uuid.UUID(existing["bot_id"]) == uuid.UUID(candidate["bot_id"])
|
||||
same_token = existing["token"] == candidate["token"]
|
||||
except (ValueError, KeyError, TypeError, AttributeError) as exc:
|
||||
raise ValueError(f"Проверьте настройки подключения {name}: неверный формат.") from exc
|
||||
if path.resolve() == target:
|
||||
if not same_bot or existing["url"] != candidate["url"]:
|
||||
raise ValueError("Это другой бот/сервер. Добавьте новое подключение вместо перенастройки старого.")
|
||||
elif same_bot or same_token:
|
||||
raise ValueError(f"Этот бот уже подключён как {name}. Повторный polling отключит его работу.")
|
||||
Reference in new issue
Block a user