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

+215
View File
@@ -0,0 +1,215 @@
"""Local AWG API, private state, and GusVoice delivery. No shell commands from chat."""
from __future__ import annotations
import asyncio
import contextlib
import html
import json
import logging
import os
from pathlib import Path
import re
import signal
import sqlite3
import time
from gusvoice_bot import AsyncClient, GusVoiceApiError
log = logging.getLogger("awg-gus")
NAME = re.compile(r"[A-Za-z0-9_-]{1,32}\Z")
LIMIT = re.compile(r"(?:[1-9][0-9]{0,5}[MG]|off)\Z")
SPEED = re.compile(r"(?:[1-9][0-9]{0,5}|off)\Z")
EXPIRE = re.compile(r"\+[1-9][0-9]{0,3}[dh]\Z")
def esc(value):
return html.escape(str(value if value is not None else ""))
def redacted(value):
text = str(value)
text = re.sub(r"(?im)(PrivateKey|PresharedKey|BOT_TOKEN|password)\s*[:=]\s*\S+", r"\1=[скрыто]", text)
return re.sub(r"gvb_[A-Za-z0-9_-]+", "[токен скрыт]", text)
def size(value):
n = float(value or 0)
for unit in ("Б", "КиБ", "МиБ", "ГиБ", "ТиБ"):
if abs(n) < 1024 or unit == "ТиБ":
return f"{n:.1f} {unit}"
n /= 1024
class AwgError(RuntimeError):
pass
def validate_command(args):
"""Second allow-list immediately before a privileged subprocess starts."""
a = tuple(args)
if a in {("version",), ("status",), ("server", "info"), ("server", "restart"),
("clients", "list"), ("clients", "export"), ("diag", "status"),
("backup", "create"), ("backup", "list"), ("tunnels", "status")}:
return
if len(a) >= 3 and a[0] == "client" and NAME.fullmatch(a[2]):
op = a[1]
if len(a) == 3 and op in {"conf", "del", "reissue", "unexpire", "limit-reset"}:
return
if len(a) == 4:
if op == "rename" and NAME.fullmatch(a[3]):
return
if op == "expire" and EXPIRE.fullmatch(a[3]):
return
if op == "speed" and SPEED.fullmatch(a[3]):
return
if len(a) == 5 and op == "limit" and LIMIT.fullmatch(a[3]) and a[4] in {"month", "total"}:
return
if op == "add" and len(a) in {4, 5} and a[3] == "mimicry=server":
if len(a) == 4 or (a[4].startswith("expire=") and EXPIRE.fullmatch(a[4][7:])):
return
if len(a) == 4 and a[:2] == ("traffic", "daily") and (a[2] == "all" or NAME.fullmatch(a[2])) and a[3] == "7":
return
raise ValueError("Недопустимая операция awg2")
class Awg:
def __init__(self, binary="/usr/local/bin/awg2"):
self.binary = binary
self.lock = asyncio.Lock()
async def call(self, *args, timeout=120):
validate_command(args)
# Upstream awg2 also uses a flock shared with the Telegram bot.
async with self.lock:
try:
proc = await asyncio.create_subprocess_exec(
self.binary, "api", *args, stdin=asyncio.subprocess.DEVNULL,
stdout=asyncio.subprocess.PIPE, stderr=asyncio.subprocess.PIPE,
start_new_session=(os.name == "posix"))
except OSError as e:
raise AwgError("Не удалось запустить awg2. Проверьте установку и путь к программе.") from e
try:
out, _ = await asyncio.wait_for(proc.communicate(), timeout)
except (asyncio.TimeoutError, asyncio.CancelledError) as e:
with contextlib.suppress(ProcessLookupError):
if os.name == "posix":
os.killpg(proc.pid, signal.SIGTERM)
else:
proc.kill()
try:
await asyncio.wait_for(proc.communicate(), 5)
except asyncio.TimeoutError:
with contextlib.suppress(ProcessLookupError):
if os.name == "posix":
os.killpg(proc.pid, signal.SIGKILL)
else:
proc.kill()
await proc.communicate()
if isinstance(e, asyncio.CancelledError):
raise
raise AwgError("awg2 не ответил вовремя. Проверьте результат в меню: операция могла выполниться.") from e
try:
result = json.loads(out.decode("utf-8", errors="replace").strip().splitlines()[-1])
if not isinstance(result, dict) or not isinstance(result.get("ok"), bool):
raise ValueError
except (ValueError, IndexError) as e:
raise AwgError("awg2 вернул ответ без JSON API. Нужна версия AWG Toolza с командой awg2 api.") from e
if not result["ok"] or proc.returncode:
raise AwgError(redacted(result.get("error") or "awg2 завершился с ошибкой")[:500])
return result
class State:
def __init__(self, path, identity):
path = Path(path)
path.parent.mkdir(parents=True, exist_ok=True, mode=0o700)
self.db = sqlite3.connect(path)
if os.name == "posix":
path.chmod(0o600)
self.db.executescript("""
CREATE TABLE IF NOT EXISTS kv(key TEXT PRIMARY KEY, value TEXT NOT NULL);
CREATE TABLE IF NOT EXISTS chats(uid TEXT PRIMARY KEY, chat TEXT NOT NULL);
CREATE TABLE IF NOT EXISTS watches(uid TEXT, name TEXT, PRIMARY KEY(uid,name));
CREATE TABLE IF NOT EXISTS pending(id INTEGER PRIMARY KEY, uid TEXT, chat TEXT);
""")
old = self.get("identity")
if old is not None and old != identity:
self.db.close()
raise ValueError("Состояние принадлежит другому боту/инстансу. Укажите отдельный state_dir.")
self.set("identity", identity)
def get(self, key, default=None):
row = self.db.execute("SELECT value FROM kv WHERE key=?", (key,)).fetchone()
return json.loads(row[0]) if row else default
def set(self, key, value):
with self.db:
self.db.execute("INSERT OR REPLACE INTO kv VALUES (?,?)", (key, json.dumps(value)))
def claim(self, update_id, uid=None, chat=None):
if update_id < self.get("offset", 0):
return False
# Persist BEFORE any mutation. Interrupted operations are reported, never replayed.
with self.db:
self.db.execute("INSERT OR REPLACE INTO kv VALUES ('offset',?)", (json.dumps(update_id + 1),))
if uid and chat:
self.db.execute("INSERT INTO pending VALUES (?,?,?)", (update_id, uid, chat))
self.db.execute("INSERT OR REPLACE INTO chats VALUES (?,?)", (uid, chat))
return True
def finish(self, update_id):
with self.db:
self.db.execute("DELETE FROM pending WHERE id=?", (update_id,))
def watch(self, uid, name):
with self.db:
if self.watched(uid, name):
self.db.execute("DELETE FROM watches WHERE uid=? AND name=?", (uid, name))
return False
self.db.execute("INSERT INTO watches VALUES (?,?)", (uid, name))
return True
def watched(self, uid, name):
return bool(self.db.execute("SELECT 1 FROM watches WHERE uid=? AND name=?", (uid, name)).fetchone())
class Delivery:
"""Throttle sends/edits together; acknowledge callbacks independently."""
def __init__(self, client):
self.client = client
self.locks = {}
self.next_send = {}
async def call(self, method, **params):
chat = params.get("chat_id")
if chat and method in {"send_message", "edit_message_text", "edit_message_reply_markup"}:
lock = self.locks.setdefault(chat, asyncio.Lock())
async with lock:
await asyncio.sleep(max(0, self.next_send.get(chat, 0) - time.monotonic()))
try:
return await getattr(self.client, method)(**params)
finally:
self.next_send[chat] = time.monotonic() + 1.1
return await getattr(self.client, method)(**params)
async def ack(self, callback_id, text=None):
try:
await self.call("answer_callback_query", callback_query_id=callback_id, text=text)
except GusVoiceApiError as e:
if e.code != 400:
raise
async def show(self, chat, text, buttons=None, message=None):
params = dict(chat_id=chat, text=text, parse_mode="HTML",
reply_markup={"inline_keyboard": buttons} if buttons else None)
if message:
try:
return await self.call("edit_message_text", message_id=message, **params)
except GusVoiceApiError as e:
if e.code != 400:
raise
return await self.call("send_message", **params)
def client_for(config):
return AsyncClient(config["token"], config["url"], discover=False, max_retries=3)