commit 38c6a1a682da0da22257984547255b0d0ff3d2a3 Author: wenil Date: Fri Oct 30 14:44:04 2026 +0300 Add standalone GusVoice AWG bot and Linux installer diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 0000000..d0d6371 --- /dev/null +++ b/.gitattributes @@ -0,0 +1,2 @@ +* text=auto eol=lf +*.png binary diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..d0b2f90 --- /dev/null +++ b/.gitignore @@ -0,0 +1,7 @@ +__pycache__/ +*.py[cod] +.venv/ +*.sqlite3* +*.log +config.json +.env diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..aac693d --- /dev/null +++ b/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2026 AWG Gus Bot contributors + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/README.md b/README.md new file mode 100644 index 0000000..e657357 --- /dev/null +++ b/README.md @@ -0,0 +1,150 @@ +# AWG для Гуся + +Отдельный бот GusVoice для управления **уже установленным AWG Toolza (`awg2`)**. +Работает рядом с Telegram-ботом: своя служба, настройки и состояние; оба интерфейса управляют одними VPN-клиентами. +Telegram Mini App не используется. + +Версия **0.1.0 — первая версия для проверки на реальном сервере**. + +## Установка + +На том Linux-сервере, где уже работает `awg2`: + +```bash +sudo bash -c 'set -e; f=$(mktemp); trap '\''rm -f -- "$f"'\'' EXIT; curl -fsSL https://gitea.bvrdo.online/wenil/awg-gus-bot/raw/branch/main/install.sh -o "$f"; bash "$f"' +``` + +Можно скачать и прочитать скрипт отдельно: + +```bash +curl -fsSL https://gitea.bvrdo.online/wenil/awg-gus-bot/raw/branch/main/install.sh -o awg-gus-install.sh +less awg-gus-install.sh +sudo bash awg-gus-install.sh +``` + +Понадобятся: + +1. Linux с systemd, Python **3.10+**, работающая команда `awg2 api version`. + Типичные системы: Debian 12/13, Ubuntu 22.04/24.04. Сам AWG установщик этого проекта не устанавливает и не обновляет. +2. GusVoice с Bot API **3+**, включёнными ботами, кнопками, командами и HTML-разметкой. +3. **Отдельный новый бот** в «Настройки → Мои боты». Приватного режима достаточно для владельца. +4. HTTPS-адрес сайта Гуся и токен бота. Сертификат должен быть доверенным на AWG-сервере. + +Установщик запросит адрес и токен (токен вводится скрыто), проверит API и покажет одноразовую команду `/start <код>`. +Отправьте её **в личную беседу с созданным ботом**. Код действует 10 минут; случайное первое сообщение доступ не выдаёт. +После подтверждения владелец будет записан по UUID. Затем отправьте `/menu`. + +Адрес AWG-сервера вводить не нужно: бот выполняет локальный `awg2 api`. +Если адрес сайта и API Гуся отличаются, официальный SDK определит API через `config.json` сайта. +Нужен исходящий HTTPS-доступ от AWG-сервера к Гусю; открывать входящие порты не требуется — используется polling. + +При отсутствии зависимостей установщик использует apt для `python3`, `curl`, `tar`, `qrencode`, `ca-certificates`. +На других дистрибутивах установите эти программы самостоятельно. Python SDK включён в репозиторий; pip/PyPI для установки не нужны. + +## Что умеет 0.1.0 + +- Главное меню со статусом сервера и клиента: работает/офлайн, IP, трафик. +- Список клиентов с перелистыванием, создание, переименование и удаление. +- Выдача `.conf` и PNG QR-кода одним сообщением; конфиг передаётся в `qrencode` через stdin. +- Перевыпуск ключа, сроки доступа, месячный/общий лимит трафика, скорость, сброс счётчика лимита. +- Диагностика AWG, перезапуск интерфейса после подтверждения. +- Создание и отправка резервной копии, экспорт клиентских конфигов. +- Уведомления об изменении состояния AWG и выбранных клиентов. По умолчанию выключены. + +Все операции изменения AWG требуют кнопки подтверждения, действующей 2 минуты. +Список разрешённых `awg2 api` операций зафиксирован в коде. Произвольных команд оболочки из чата нет. +Проверка владельца и личной беседы выполняется для сообщений и кнопок. + +Команды: `/menu`, `/status`, `/clients`, `/new`, `/cancel`, `/whoami`, `/help`. + +Мониторинг проверяет состояние раз в минуту; клиент считается офлайн спустя 5 минут без рукопожатия. +Включите «Следить» в карточке нужного клиента. Отдельный переключатель состояния сервера — в «Уведомлениях». +После запуска бот запоминает текущее состояние без рассылки и пишет только при изменении. + +В первой версии через меню `awg2` остаются: установка/обновление AWG и модулей ядра, создание сервера, +настройка туннелей/DNS/обфускаторов, массовое создание, восстановление бэкапа и удаление всей установки. +Это отдельный нативный клиент основных функций, а не полная копия всех экранов Telegram-бота. + +## Сосуществование с Telegram + +| Компонент | Этот бот | +|---|---| +| Служба | `awg-gus-bot.service` | +| Программа | `/opt/awg-gus-bot/current` → отдельная версия в `releases/` | +| Настройки | `/etc/awg-gus-bot.json`, права `600` | +| Состояние | `/var/lib/awg-gus-bot`, отдельная SQLite-база | +| Управление | `/usr/local/bin/awg-gus-bot` | + +Установщик не меняет файлы и службу `awg-bot`, не обновляет `awg2` и не перезапускает VPN. +**Данные VPN общие:** если удалить клиента через Гуся, он исчезнет и в Telegram. +В проверенном upstream `awg2 api` изменяющие команды сериализованы общим `flock`. +Если другой интерфейс занят длительной операцией, AWG может попросить повторить действие позже. + +Бот работает от root, поскольку это требуется локальному `awg2 api`. +Настройки, токены и база состояния не входят в git. В журнал пишутся типы ошибок, без токенов и конфигов VPN. +Архивы и конфиги отправляются только владельцу в личку; в них содержатся ключи VPN. + +## Обновление и управление + +```bash +sudo awg-gus-bot update # загрузить новую версию из Gitea +sudo awg-gus-bot check # проверить токен, возможности Гуся и awg2 api +sudo awg-gus-bot status +sudo awg-gus-bot logs +sudo awg-gus-bot restart +sudo awg-gus-bot stop +sudo awg-gus-bot start +sudo awg-gus-bot setup # повторная настройка/привязка того же бота +``` + +Повторная установка тоже обновляет программу. Настройки сохраняются без повторного запроса токена. +Новая версия сначала скачивается и проверяется, затем переключается `current`. +Если служба не запустилась, установщик возвращает прежние программу/службу/настройки и запускает прежнюю службу, +если она работала до обновления. Старые версии автоматически не удаляются. + +При смене на другой аккаунт бота база специально не переиспользуется: старые chat UUID и курсор событий принадлежат прежнему боту. +Для такого переноса сначала сохраните текущую установку и подготовьте новое состояние; `setup` рассчитан на перепривязку того же аккаунта +или обновление его токена. + +## Ограничения и проверка на сервере + +- Файл до 25 МиБ. Большой архив остаётся на сервере — заберите его по SSH. +- Слишком длинный конфиг выдаётся файлом без QR. Если в журнале ошибка QR, проверьте `qrencode --version`. +- Отправка/редактирование ограничены примерно одним сообщением в секунду; ответы на кнопки выполняются отдельно. +- Пока длится действие в личке, новые нажатия получают ответ «операция выполняется». +- Курсор событий сохраняется **до запуска действия**, поэтому при сбое действие не повторится автоматически. + После перезапуска бот сообщит о прерванной обработке. Проверьте фактическое состояние перед повтором. + Это не обещание транзакции между чатом и AWG: действие могло выполниться до сбоя доставки ответа. +- Кнопки подтверждения и незавершённый ввод сбрасываются при перезапуске. +- Вторая копия с тем же токеном или ранее настроенный webhook мешают polling; используйте выделенного бота. + +Первый ручной прогон: + +1. `/menu` → проверить версию AWG, число клиентов, список и карточку существующего клиента. +2. Создать временного клиента, например `gus-test-01`, на 1 день. +3. Проверить скачивание `.conf` и отображение QR; импортировать конфиг и проверить соединение. +4. Убедиться, что этот же клиент появился в Telegram-боте. +5. Изменить срок/лимит, перезапустить только `awg-gus-bot`, повторно открыть `/menu`. +6. Удалить временного клиента с подтверждением; проверить, что Telegram-бот продолжает работать. + +## Разработка и происхождение + +```bash +python3 -m unittest discover -s tests -v +bash -n install.sh +bash -n awg-gus-bot +``` + +Тесты проверяют права владельца, личку, подтверждения/отмену, повторные события после перезапуска, +создание клиента, файлы и QR, лимиты, мониторинг. Транспортные тесты используют настоящий включённый SDK +и локальный HTTP-стенд. Вызовы AWG имитируются: эти тесты не меняют VPN и не подтверждают установку на реальном systemd-сервере. + +- GusVoice Python SDK **0.1.0** включён без изменений в `vendor/gusvoice_bot/` (MIT). + Источник: `Gleb1290/gusvoice`, commit `3113f3106b8d0d0924449ae17489c81d076a4cb5`. +- Контракт `awg2 api` сверялся с `pumbaX/awg-multi-script`, commit `aedff0bc4b0099a3584bc3c519fa42758b5e672e` + (AWG Toolza bot 3.4.40). Идея экранов и ограничения имён/QR основаны на этом проекте (MIT). +- Лицензии и уведомления находятся в `licenses/`; лицензия этого проекта — MIT. + +Официальный API: https://github.com/Gleb1290/gusvoice/blob/main/docs/bots.ru.md + +AWG Toolza: https://github.com/pumbaX/awg-multi-script diff --git a/awg-gus-bot b/awg-gus-bot new file mode 100755 index 0000000..5c4f503 --- /dev/null +++ b/awg-gus-bot @@ -0,0 +1,18 @@ +#!/usr/bin/env bash +set -Eeuo pipefail +BASE=/opt/awg-gus-bot +case "${1:-status}" in + status) systemctl status awg-gus-bot --no-pager ;; + logs) journalctl -u awg-gus-bot -n 80 --no-pager ;; + restart|stop|start) [[ $EUID -eq 0 ]] || { echo 'Запустите через sudo'; exit 1; }; systemctl "$1" awg-gus-bot ;; + check) exec python3 "$BASE/current/run.py" check ;; + setup) exec bash "$BASE/current/install.sh" --src "$BASE/current" --configure ;; + update) + [[ $EUID -eq 0 ]] || { echo 'Запустите через sudo'; exit 1; } + export AWG_GUS_REPO_URL AWG_GUS_REF + AWG_GUS_REPO_URL=$(cat "$BASE/current/source-url") + AWG_GUS_REF=$(cat "$BASE/current/source-ref") + exec bash "$BASE/current/install.sh" + ;; + *) echo 'Использование: awg-gus-bot status|logs|check|restart|stop|start|setup|update'; exit 2 ;; +esac diff --git a/awg_gus/__init__.py b/awg_gus/__init__.py new file mode 100644 index 0000000..3dc1f76 --- /dev/null +++ b/awg_gus/__init__.py @@ -0,0 +1 @@ +__version__ = "0.1.0" diff --git a/awg_gus/bot.py b/awg_gus/bot.py new file mode 100644 index 0000000..382ffe8 --- /dev/null +++ b/awg_gus/bot.py @@ -0,0 +1,484 @@ +"""A native GusVoice UI for an already installed AWG Toolza server.""" +from __future__ import annotations + +import asyncio +import contextlib +from dataclasses import dataclass +import logging +from pathlib import Path +import re +import secrets +import shutil +import time + +from gusvoice_bot import GusVoiceApiError, InputFile + +from . import __version__ +from .core import AwgError, EXPIRE, LIMIT, NAME, SPEED, esc, redacted, size + +log = logging.getLogger("awg-gus") +PAGE = 10 +FILE_MAX = 25 * 1024 * 1024 + + +def button(text, data): + if len(data.encode()) > 64: + raise ValueError("callback_data too long") + return {"text": text, "callback_data": data} + + +HOME = [button("🏠 Меню", "home")] + + +def client_status(c): + if c.get("blocked"): + return "🚫 заблокирован" + if c.get("online"): + return "🟢 онлайн" + return "⚪ офлайн" if c.get("handshake") else "⚪ ещё не подключался" + + +@dataclass +class Event: + uid: str + chat: str + update_id: int + message: str | None = None + text: str = "" + data: str = "" + + +class Bot: + def __init__(self, config, api, delivery, state): + self.config, self.api, self.io, self.state = config, api, delivery, state + self.owners = set(config["owners"]) + self.steps = {} + self.confirmations = {} + self.tasks = {} + self.interrupted = {row[0] for row in state.db.execute("SELECT uid FROM pending")} + with state.db: + state.db.execute("DELETE FROM pending") + self.observed = {} + + async def screen(self, e, text, rows=None): + out = await self.io.show(e.chat, text, rows if rows is not None else [HOME], e.message) + if isinstance(out, dict): + e.message = out.get("message_id") + return out + + async def clients(self): + data = (await self.api.call("clients", "list")).get("data") + if not isinstance(data, list): + raise AwgError("Неверный ответ clients list") + return [c for c in data if isinstance(c, dict) and isinstance(c.get("name"), str) and NAME.fullmatch(c["name"])] + + async def client(self, name): + if not NAME.fullmatch(name): + raise ValueError("Неверное имя клиента") + c = next((c for c in await self.clients() if c["name"] == name), None) + if c is None: + raise ValueError("Клиент уже удалён. Обновите список.") + return c + + async def process(self, update): + n = update.get("update_id") + if not isinstance(n, int) or isinstance(n, bool) or n < self.state.get("offset", 0): + return + cb = update.get("callback_query") + msg = cb.get("message") if isinstance(cb, dict) else update.get("message") + author = cb.get("from") if isinstance(cb, dict) else (msg or {}).get("from") + msg, author = msg or {}, author or {} + chat = msg.get("chat") or {} + uid, cid = author.get("id"), chat.get("id") + allowed = (chat.get("type") == "dm" and uid in self.owners and isinstance(cid, str)) + if not allowed: + self.state.claim(n) + if isinstance(cb, dict): + await self.io.ack(cb["id"], "Доступ только владельцу в личных сообщениях") + elif chat.get("type") == "dm" and (msg.get("text") or "").strip() == "/whoami": + await self.io.show(cid, f"Ваш ID: {esc(uid)}\nДоступ к управлению не выдан.") + return + key = (uid, cid) + busy = key in self.tasks and not self.tasks[key].done() + # Persist deduplication before ack or starting a privileged operation. + self.state.claim(n, uid, cid) + if isinstance(cb, dict): + try: + await self.io.ack(cb["id"], "Предыдущая операция ещё выполняется" if busy else None) + except Exception: + self.state.finish(n) + raise + if busy: + self.state.finish(n) + if not cb: + await self.io.show(cid, "⏳ Предыдущая операция ещё выполняется. Дождитесь результата.") + return + e = Event(uid, cid, n, msg.get("message_id") if cb else None, + msg.get("text") or "", cb.get("data") or "" if cb else "") + self.tasks[key] = asyncio.create_task(self._handle(e), name="awg-gus-action") + + async def _handle(self, e): + completed = True + try: + if e.uid in self.interrupted: + self.interrupted.remove(e.uid) + await self.io.show(e.chat, "⚠️ Предыдущий запуск прервался во время обработки. " + "Проверьте состояние: незавершённые действия автоматически не повторяются.") + if e.data: + await self.action(e) + else: + await self.message(e) + except asyncio.CancelledError: + completed = False + raise + except (AwgError, ValueError) as exc: + await self._error(e, str(exc)) + except Exception as exc: + # API/transport exceptions may contain a sensitive URL. Log class only. + log.error("Handler failed: %s (update %s)", type(exc).__name__, e.update_id) + await self._error(e, "Ошибка связи или обработки. Если действие изменяло сервер, проверьте результат перед повтором.") + finally: + if completed: + self.state.finish(e.update_id) + + def cancel_confirmations(self, e): + self.confirmations = {k: v for k, v in self.confirmations.items() if v[:2] != (e.uid, e.chat)} + + async def _error(self, e, text): + with contextlib.suppress(Exception): + await self.screen(e, "❌ " + esc(redacted(text)[:650])) + + async def message(self, e): + text = e.text.strip() + command = text.split(maxsplit=1)[0].split("@", 1)[0] if text else "" + if command.startswith("/"): + self.cancel_confirmations(e) + if command in {"/start", "/menu", "/status", "/cancel"}: + self.steps.pop((e.uid, e.chat), None) + await self.home(e) + elif command == "/clients": + await self.list_clients(e) + elif command == "/whoami": + await self.screen(e, f"Ваш ID: {esc(e.uid)}\nДоступ: владелец.") + elif command == "/help": + await self.screen(e, "AWG для Гуся\n/menu — главное меню\n/clients — клиенты\n" + "/cancel — отменить ввод\n/whoami — ваш ID\n\n" + "Изменения клиентов общие с Telegram-ботом: оба управляют одним awg2.") + elif command == "/new": + await self.prompt(e, "add", "", "Имя нового клиента: латиница, цифры, _ и -, до 32 символов.") + elif (e.uid, e.chat) in self.steps: + await self.input(e, text) + else: + await self.home(e) + + async def home(self, e): + d = (await self.api.call("status")).get("data") or {} + s = d.get("server") or {} + state = "🟢 работает" if s.get("up") else "🔴 не поднят" + text = (f"🪿 AWG для Гуся · {__version__}\n" + 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" + f"Протокол: {esc(s.get('proto', '?'))} · порт {esc(s.get('port', '?'))}") + await self.screen(e, text, [ + [button("👥 Клиенты", "list:0"), button("➕ Создать", "new")], + [button("🖥 Сервер", "server"), button("🩺 Диагностика", "diag")], + [button("💾 Резервная копия", "backup"), button("📦 Экспорт клиентов", "export")], + [button("🔔 Уведомления", "notices"), button("ℹ️ О боте", "about")], + [button("🔄 Обновить", "home")]]) + + async def list_clients(self, e, page=0): + rows = sorted(await self.clients(), key=lambda c: (not c.get("online"), c["name"].lower())) + page = min(max(0, page), max(0, (len(rows) - 1) // PAGE)) + buttons = [[button(f"{'🚫' if c.get('blocked') else '🟢' if c.get('online') else '⚪'} {c['name']}", + "client:" + c["name"])] for c in rows[page * PAGE:(page + 1) * PAGE]] + nav = [] + if page: + nav.append(button("←", f"list:{page-1}")) + if (page + 1) * PAGE < len(rows): + nav.append(button("→", f"list:{page+1}")) + if nav: + buttons.append(nav) + buttons += [[button("➕ Создать", "new"), button("🔄 Обновить", f"list:{page}")], HOME] + await self.screen(e, f"👥 Клиенты: {len(rows)}\nСтраница {page+1}. Выберите клиента.", buttons) + + async def card(self, e, name): + c = await self.client(name) + exp = c.get("expires") + expiry = time.strftime("%Y-%m-%d %H:%M", time.localtime(exp)) if exp else "без ограничения" + text = (f"👤 {esc(name)}\n{client_status(c)}\nIP: {esc(c.get('ip', ''))}\n" + f"Трафик: ↓ {size(c.get('rx'))} · ↑ {size(c.get('tx'))}\nСрок: {expiry} (время сервера)\n") + text += (f"Лимит: {size(c.get('used'))} / {size(c['limit'])} ({esc(c.get('period', 'month'))})\n" + if c.get("limit") else "Лимит: нет\n") + text += f"Скорость: {esc(c.get('speed') or 'не ограничена')}" + (" Мбит/с" if c.get("speed") else "") + watching = self.state.watched(e.uid, name) + await self.screen(e, text, [ + [button("📄 Конфиг + QR", f"conf:{name}"), button("🔄 Обновить", f"client:{name}")], + [button("📅 Срок", f"expiry:{name}"), button("📊 Лимит", f"limit:{name}")], + [button("🚀 Скорость", f"speed:{name}"), button("✏️ Имя", f"rename:{name}")], + [button("🔑 Новый ключ", f"reissue:{name}"), button("🗑 Удалить", f"delete:{name}")], + [button("🔕 Не следить" if watching else "🔔 Следить", f"watch:{name}"), + button("♻️ Сбросить трафик", f"reset:{name}")], + [button("← Клиенты", "list:0")], HOME]) + + async def action(self, e): + op, _, arg = e.data.partition(":") + if op != "yes": + self.steps.pop((e.uid, e.chat), None) + self.cancel_confirmations(e) + if op == "home": + await self.home(e) + elif op == "list": + await self.list_clients(e, int(arg) if arg.isdigit() else 0) + elif op == "new": + await self.prompt(e, "add", "", "Имя нового клиента: латиница, цифры, _ и -, до 32 символов.") + elif op == "client": + await self.card(e, arg) + elif op == "conf": + await self.client(arg) + await self.send_config(e, arg) + elif op == "expiry": + await self.client(arg) + await self.screen(e, f"Срок: {esc(arg)}\nОтсчитывается от момента применения.", [ + [button("1 день", f"exp:{arg}:+1d"), button("7 дней", f"exp:{arg}:+7d")], + [button("30 дней", f"exp:{arg}:+30d"), button("Без срока", f"exp:{arg}:off")], + [button("← Клиент", f"client:{arg}")]]) + elif op == "exp": + name, _, value = arg.partition(":") + await self.client(name) + args = ["client", "unexpire", name] if value == "off" else ["client", "expire", name, value] + await self.confirm(e, f"Изменить срок {esc(name)}: {esc(value)}?", args, name) + elif op in {"rename", "limit", "speed"}: + await self.client(arg) + prompt = {"rename": "Новое имя: латиница, цифры, _ и -, до 32 символов.", + "limit": "Лимит: например 50G или 500M. Добавьте total для общего лимита: 50G total.\n" + "По умолчанию — за месяц. off — снять ограничение.", + "speed": "Скорость в Мбит/с: целое число, например 20. off — снять ограничение."}[op] + await self.prompt(e, op, arg, prompt) + elif op in {"delete", "reissue", "reset"}: + await self.client(arg) + verb, label = {"delete": ("del", "Удалить клиента и его доступ"), + "reissue": ("reissue", "Перевыпустить ключ: старый конфиг перестанет работать"), + "reset": ("limit-reset", "Сбросить счётчик лимита трафика")}[op] + await self.confirm(e, f"{label}: {esc(arg)}?", ["client", verb, arg], arg) + elif op == "watch": + await self.client(arg) + self.state.watch(e.uid, arg) + await self.card(e, arg) + elif op == "newexp": + draft = self.state.get(f"draft:{e.uid}:{e.chat}") + if not draft or time.time() - draft["time"] > 600: + raise ValueError("Ввод устарел. Создайте клиента заново.") + value = arg + args = ["client", "add", draft["name"], "mimicry=server"] + if value != "off": + args.append("expire=" + value) + await self.confirm(e, f"Создать {esc(draft['name'])}?\nСрок: {esc(value)}; мимикрия сервера.", args, draft["name"]) + elif op == "yes": + await self.execute(e, arg) + elif op == "server": + d = (await self.api.call("server", "info")).get("data") or {} + text = "🖥 Сервер AWG\n" + "\n".join( + f"{label}: {esc(d.get(key, '—'))}" for key, label in + [("up", "Работает"), ("proto", "Протокол"), ("profile", "Профиль"), + ("endpoint", "Endpoint"), ("net", "Подсеть"), ("mtu", "MTU"), ("clients", "Клиенты")]) + await self.screen(e, text, [[button("🔄 Перезапустить AWG", "restart")], HOME]) + elif op == "restart": + await self.confirm(e, "Перезапустить интерфейс AWG? Соединения VPN кратковременно прервутся.", ["server", "restart"]) + elif op == "diag": + r = await self.api.call("diag", "status") + text = redacted(r.get("log") or "Диагностика завершена") + text = re.sub(r"\x1b\[[0-9;]*[A-Za-z]", "", text) + await self.screen(e, "🩺 Диагностика\n
" + esc(text[-1800:]) + "
") + elif op in {"backup", "export"}: + args = ["backup", "create"] if op == "backup" else ["clients", "export"] + await self.confirm(e, "Создать и прислать архив в эту личную беседу? Архив содержит ключи VPN.", args) + elif op == "notices": + on = self.state.get(f"notify:{e.uid}", False) + watches = [r[0] for r in self.state.db.execute("SELECT name FROM watches WHERE uid=?", (e.uid,))] + await self.screen(e, "🔔 Уведомления\nСостояние сервера: " + ("включены" if on else "выключены") + + "\nНаблюдение за клиентами: " + esc(", ".join(watches) or "нет") + + "\nПроверка раз в минуту. Клиент офлайн после 5 минут без рукопожатия.\n" + "При старте бот запоминает состояние без рассылки.", + [[button("Выключить серверные" if on else "Включить серверные", "notify")], HOME]) + elif op == "notify": + key = f"notify:{e.uid}" + self.state.set(key, not self.state.get(key, False)) + e.data = "notices" + await self.action(e) + elif op == "about": + await self.screen(e, f"AWG для Гуся {__version__}\n" + "Отдельная служба: awg-gus-bot. Управление тем же awg2, что и в Telegram.\n" + "Обновление на сервере: sudo awg-gus-bot update\n" + "Проверка: sudo awg-gus-bot check\n" + "В этой версии: клиенты, ключи/QR, сроки, трафик, скорость, диагностика, " + "бэкап и экспорт, перезапуск AWG, уведомления.\n" + "Установка AWG, туннели, обновление ядра и восстановление бэкапа — через меню awg2.") + else: + raise ValueError("Кнопка устарела. Откройте /menu.") + + async def prompt(self, e, kind, name, text): + self.steps[(e.uid, e.chat)] = (kind, name, time.monotonic() + 600) + await self.screen(e, text + "\n\nОтмена: /cancel", [HOME]) + + async def input(self, e, text): + key = (e.uid, e.chat) + kind, name, expires = self.steps[key] + if time.monotonic() > expires: + self.steps.pop(key, None) + raise ValueError("Ввод устарел. Откройте меню заново.") + if kind in {"add", "rename"}: + if not NAME.fullmatch(text): + raise ValueError("Имя: латиница, цифры, _ и -, от 1 до 32 символов. Повторите ввод.") + if any(c["name"] == text for c in await self.clients()): + raise ValueError("Такое имя уже существует. Введите другое.") + if kind == "add": + self.state.set(f"draft:{e.uid}:{e.chat}", {"name": text, "time": time.time()}) + await self.screen(e, f"Срок нового клиента {esc(text)}:", [ + [button("1 день", "newexp:+1d"), button("7 дней", "newexp:+7d")], + [button("30 дней", "newexp:+30d"), button("Без срока", "newexp:off")], HOME]) + else: + await self.confirm(e, f"Переименовать {esc(name)} → {esc(text)}?", ["client", "rename", name, text], text) + elif kind == "limit": + parts = text.split() + value = parts[0] if parts else "" + value = value if value == "off" else value.upper() + period = parts[1] if len(parts) == 2 else "month" + if len(parts) > 2 or not LIMIT.fullmatch(value) or period not in {"month", "total"}: + raise ValueError("Введите 50G, 500M, 50G total или off.") + await self.confirm(e, f"Лимит {esc(name)}: {esc(value)} ({period})?", ["client", "limit", name, value, period], name) + elif kind == "speed": + if not SPEED.fullmatch(text): + raise ValueError("Введите целое число Мбит/с или off.") + await self.confirm(e, f"Скорость {esc(name)}: {esc(text)} Мбит/с?", ["client", "speed", name, text], name) + self.steps.pop(key, None) + + async def confirm(self, e, text, args, name=""): + from .core import validate_command + validate_command(args) + now = time.monotonic() + self.confirmations = {k: v for k, v in self.confirmations.items() + if v[0:2] != (e.uid, e.chat) and v[2] > now} + token = secrets.token_hex(8) + self.confirmations[token] = (e.uid, e.chat, now + 120, args, name) + await self.screen(e, text + "\n\nПодтверждение действует 2 минуты.", + [[button("✅ Подтвердить", "yes:" + token), button("Отмена", "home")]]) + + async def execute(self, e, token): + pending = self.confirmations.get(token) + if not pending or pending[:2] != (e.uid, e.chat) or pending[2] < time.monotonic(): + raise ValueError("Подтверждение устарело или уже использовано. Повторите действие из меню.") + self.confirmations.pop(token) + args, name = pending[3:] + await self.screen(e, "⏳ Выполняю…") + r = await self.api.call(*args) + await self.screen(e, "✅ Выполнено", [[button("👥 Клиенты", "list:0")], HOME]) + if args[:2] in (["client", "add"], ["client", "reissue"]): + await self.send_config(e, name) + elif args[:2] in (["backup", "create"], ["clients", "export"]): + data = r.get("data") or {} + path = Path(data.get("path") or data.get("file") or "") + if not path.is_absolute() or not path.is_file(): + raise AwgError("Архив создан, но awg2 не вернул путь к файлу.") + if path.stat().st_size > FILE_MAX: + await self.io.show(e.chat, "Архив больше 25 МиБ и сохранён на сервере. Заберите его через SSH.") + else: + await self.io.call("send_message", chat_id=e.chat, text="Архив AWG: содержит ключи VPN.", files=[InputFile(path)]) + + async def send_config(self, e, name): + data = (await self.api.call("client", "conf", name)).get("data") or {} + text = data.get("text") + if not isinstance(text, str) or not text or len(text.encode()) > FILE_MAX: + raise AwgError("Конфиг отсутствует или превышает допустимый размер.") + files = [InputFile(text.encode(), filename=name + ".conf", content_type="text/plain")] + note = "" + if len(text.encode()) <= 2800 and shutil.which("qrencode"): + proc = await asyncio.create_subprocess_exec("qrencode", "-t", "PNG", "-o", "-", "-s", "8", "-m", "2", + stdin=asyncio.subprocess.PIPE, stdout=asyncio.subprocess.PIPE, stderr=asyncio.subprocess.DEVNULL) + try: + png, _ = await asyncio.wait_for(proc.communicate(text.encode()), 10) + if proc.returncode == 0 and png.startswith(b"\x89PNG\r\n\x1a\n"): + files.append(InputFile(png, filename=name + ".png", content_type="image/png")) + else: + note = " QR не сформирован; импортируйте файл." + except (asyncio.TimeoutError, asyncio.CancelledError) as exc: + with contextlib.suppress(ProcessLookupError): + proc.kill() + await proc.wait() + if isinstance(exc, asyncio.CancelledError): + raise + note = " QR не сформирован; импортируйте файл." + else: + note = " QR недоступен или конфиг слишком длинный; импортируйте файл." + await self.io.call("send_message", chat_id=e.chat, text=f"{name} — импорт в AmneziaVPN / AmneziaWG.{note}", files=files) + + async def monitor_tick(self): + watches = list(self.state.db.execute("SELECT uid,name FROM watches")) + notify = [uid for uid in self.owners if self.state.get(f"notify:{uid}", False)] + if not watches and not notify: + return + clients = {c["name"]: c for c in await self.clients()} if watches else {} + current = {} + for uid, name in watches: + if uid not in self.owners: + continue + c = clients.get(name) + status = "удалён" if c is None else "заблокирован" if c.get("blocked") else ( + "онлайн" if c.get("handshake") and float(c.get("ago") or 0) < 300 else "офлайн") + current[(uid, name)] = status + if notify: + d = (await self.api.call("server", "info")).get("data") or {} + for uid in notify: + current[(uid, "Сервер AWG")] = "работает" if d.get("up") else "не поднят" + for key, status in current.items(): + previous = self.observed.get(key) + if previous is not None and previous != status: + row = self.state.db.execute("SELECT chat FROM chats WHERE uid=?", (key[0],)).fetchone() + if row: + await self.io.show(row[0], f"🔔 {esc(key[1])}: {esc(previous)} → {esc(status)}") + self.observed = current + + async def monitor(self): + while True: + try: + await self.monitor_tick() + except Exception as exc: + log.warning("Monitor failed: %s", type(exc).__name__) + await asyncio.sleep(60) + + async def run(self): + webhook = await self.io.call("get_webhook_info") + if webhook.get("url"): + raise ValueError("У этого бота уже настроен webhook. Используйте отдельного бота Гуся.") + await self.io.call("set_my_commands", commands=[ + {"command": "menu", "description": "Управление AWG"}, + {"command": "clients", "description": "Клиенты VPN"}, + {"command": "new", "description": "Создать клиента"}, + {"command": "cancel", "description": "Отменить ввод"}, + {"command": "help", "description": "Помощь"}]) + monitor = asyncio.create_task(self.monitor()) + delay = 1 + try: + while True: + try: + updates = await self.io.call("get_updates", offset=self.state.get("offset", 0), timeout=30, + allowed_updates=["message", "callback_query"]) + for update in updates: + await self.process(update) + delay = 1 + except GusVoiceApiError as exc: + if exc.code in {401, 403, 404, 409}: + log.error("Polling stopped: HTTP %s. Check token, enabled bots, webhook and duplicate processes.", exc.code) + raise + log.warning("Polling retry: HTTP %s", exc.code) + await asyncio.sleep(delay) + delay = min(30, delay * 2) + except (OSError, TimeoutError) as exc: + log.warning("Network retry: %s", type(exc).__name__) + await asyncio.sleep(delay) + delay = min(30, delay * 2) + finally: + monitor.cancel() + for task in self.tasks.values(): + task.cancel() + await asyncio.gather(monitor, *self.tasks.values(), return_exceptions=True) diff --git a/awg_gus/core.py b/awg_gus/core.py new file mode 100644 index 0000000..39cfa0e --- /dev/null +++ b/awg_gus/core.py @@ -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) diff --git a/awg_gus/main.py b/awg_gus/main.py new file mode 100644 index 0000000..c6e7ce7 --- /dev/null +++ b/awg_gus/main.py @@ -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) diff --git a/install.sh b/install.sh new file mode 100755 index 0000000..24c94ee --- /dev/null +++ b/install.sh @@ -0,0 +1,191 @@ +#!/usr/bin/env bash +# AWG Gus bot: install/update only this service; existing awg2 and Telegram bot stay in place. +set -Eeuo pipefail +umask 077 + +REPO_URL="${AWG_GUS_REPO_URL:-https://gitea.bvrdo.online/wenil/awg-gus-bot}" +REF="${AWG_GUS_REF:-main}" +BASE=/opt/awg-gus-bot +CONF=/etc/awg-gus-bot.json +STATE=/var/lib/awg-gus-bot +SERVICE=awg-gus-bot.service +SRC="" +RECONFIGURE=0 + +die() { printf 'Ошибка: %s\n' "$*" >&2; exit 1; } +usage() { + cat <<'EOF' +Установка/обновление отдельного AWG-бота для GusVoice: + sudo bash install.sh + sudo bash install.sh --src /path/to/awg-gus-bot + sudo bash install.sh --configure + +Нужны: Linux с systemd, Python 3.10+, установленный awg2 с JSON API. +При первой установке запросит HTTPS-адрес Гуся и токен, затем покажет код привязки. +Существующая служба awg-bot не меняется. VPN-клиенты у обоих ботов общие. +EOF +} + +while (($#)); do + case "$1" in + --src) [[ $# -ge 2 ]] || die 'После --src нужен каталог'; SRC="$2"; shift 2 ;; + --configure) RECONFIGURE=1; shift ;; + -h|--help) usage; exit 0 ;; + *) die "Неизвестный параметр: $1" ;; + esac +done + +[[ $EUID -eq 0 ]] || die 'Запустите через sudo bash install.sh' +[[ $(uname -s) == Linux ]] || die 'Устанавливается на Linux-сервер с awg2' +command -v systemctl >/dev/null || die 'Нужен systemd' +[[ -d /run/systemd/system ]] || die 'systemd не запущен' +command -v flock >/dev/null || die 'Нужен flock (пакет util-linux)' +mkdir -p /run/lock +exec 9>/run/lock/awg-gus-install.lock +flock -n 9 || die 'Другой установщик уже работает' +AWG2="$(command -v awg2 || true)" +[[ -n "$AWG2" ]] || die 'Сначала установите AWG Toolza: команда awg2 не найдена' + +missing=() +for pair in 'python3:python3' 'curl:curl' 'tar:tar' 'qrencode:qrencode'; do + command -v "${pair%%:*}" >/dev/null || missing+=("${pair#*:}") +done +if ((${#missing[@]})); then + command -v apt-get >/dev/null || die "Установите вручную: ${missing[*]} и ca-certificates" + apt-get update -qq + DEBIAN_FRONTEND=noninteractive apt-get install -y -qq "${missing[@]}" ca-certificates +fi +PYTHON=$(command -v python3) +python3 -c 'import sys; assert sys.version_info >= (3,10), "Нужен Python 3.10+"' +"$AWG2" api version | python3 -c 'import sys,json; d=json.loads(sys.stdin.read().strip().splitlines()[-1]); sys.exit(0 if d.get("ok") is True else 1)' \ + || die 'awg2 api version не отвечает. Обновите AWG Toolza штатным способом' + +TMP=$(mktemp -d /tmp/awg-gus-install.XXXXXXXX) +old="" +activated=0 +service_stopped=0 +configuring=0 +was_active=0 +systemctl is-active --quiet "$SERVICE" && was_active=1 +cleanup() { + local rc=$? + trap - EXIT + if ((rc != 0 && (activated || service_stopped || configuring))); then + printf 'Запуск новой версии не удался. Откатываю только службу Гуся.\n' >&2 + systemctl stop "$SERVICE" || true + if ((activated)) && [[ -n "$old" ]]; then + ln -sfn "$old" "$BASE/.current-rollback" + mv -Tf "$BASE/.current-rollback" "$BASE/current" + elif ((activated)); then + rm -f "$BASE/current" + fi + if [[ -f "$TMP/config.before" ]]; then + install -m 600 "$TMP/config.before" "$CONF" + fi + if [[ -f "$TMP/unit.before" ]]; then + install -m 644 "$TMP/unit.before" "/etc/systemd/system/$SERVICE" + elif ((activated)) && [[ -z "$old" ]]; then + systemctl disable "$SERVICE" >/dev/null 2>&1 || true + rm -f "/etc/systemd/system/$SERVICE" + fi + if [[ -f "$TMP/command.before" ]]; then + install -m 755 "$TMP/command.before" /usr/local/bin/awg-gus-bot + elif ((activated)) && [[ -z "$old" ]]; then + rm -f /usr/local/bin/awg-gus-bot + fi + systemctl daemon-reload + if ((was_active)) && [[ -n "$old" ]]; then systemctl start "$SERVICE" || true; fi + fi + [[ "$TMP" == /tmp/awg-gus-install.* && -d "$TMP" ]] && rm -rf -- "$TMP" + exit "$rc" +} +trap cleanup EXIT + +if [[ -z "$SRC" ]]; then + [[ "$REPO_URL" == https://* && "$REPO_URL" != *'@'* ]] || die 'Источник должен быть HTTPS без учётных данных в URL' + [[ "$REF" =~ ^[A-Za-z0-9._-]+$ ]] || die 'Некорректная ветка/версия' + printf 'Скачиваю AWG Gus bot из %s (%s)…\n' "$REPO_URL" "$REF" + curl --proto '=https' --proto-redir '=https' --tlsv1.2 -fSL --retry 3 --connect-timeout 15 --max-time 180 \ + "${REPO_URL%.git}/archive/$REF.tar.gz" -o "$TMP/source.tar.gz" + mkdir "$TMP/source" + # Reject paths escaping extraction root and any links/devices in the source archive. + python3 - "$TMP/source.tar.gz" "$TMP/source" <<'PY' +import pathlib, sys, tarfile +root = pathlib.Path(sys.argv[2]).resolve() +with tarfile.open(sys.argv[1]) as archive: + for m in archive.getmembers(): + p = (root / m.name).resolve() + if not p.is_relative_to(root) or not (m.isfile() or m.isdir()): + raise SystemExit('Небезопасный путь/тип в архиве') + archive.extractall(root) +PY + dirs=("$TMP/source"/*) + ((${#dirs[@]} == 1)) && [[ -d "${dirs[0]}" ]] || die 'Неожиданная структура архива' + SRC="${dirs[0]}" +fi +SRC=$(cd "$SRC" && pwd -P) +[[ -f "$SRC/run.py" && -f "$SRC/awg_gus/bot.py" && -f "$SRC/vendor/gusvoice_bot/client.py" && -f "$SRC/install.sh" ]] \ + || die 'В источнике нет файлов AWG Gus bot' +python3 -m compileall -q "$SRC/awg_gus" "$SRC/vendor/gusvoice_bot" "$SRC/run.py" +mkdir -p "$BASE/releases" "$STATE" +chmod 700 "$BASE" "$BASE/releases" "$STATE" +release="$BASE/releases/$(date -u +%Y%m%dT%H%M%SZ)-$$" +mkdir "$release" +cp -a "$SRC/awg_gus" "$SRC/vendor" "$SRC/run.py" "$SRC/install.sh" "$SRC/awg-gus-bot" "$SRC/README.md" "$SRC/LICENSE" "$SRC/licenses" "$release/" +chmod -R go-rwx "$release" +printf '%s\n' "$REPO_URL" > "$release/source-url" +printf '%s\n' "$REF" > "$release/source-ref" +[[ ! -f "$CONF" ]] || cp -p "$CONF" "$TMP/config.before" +[[ ! -f "/etc/systemd/system/$SERVICE" ]] || cp -p "/etc/systemd/system/$SERVICE" "$TMP/unit.before" +[[ ! -f /usr/local/bin/awg-gus-bot ]] || cp -p /usr/local/bin/awg-gus-bot "$TMP/command.before" +old=$(readlink "$BASE/current" || true) +if [[ -n "$old" && "$old" != "$BASE/releases/"* ]]; then die 'current указывает вне каталога версий'; fi + +if [[ ! -s "$CONF" ]] || ((RECONFIGURE)); then + [[ -t 0 ]] || die 'Для первого запуска нужен терминал: скачайте скрипт в файл, затем sudo bash install.sh' + # Stop only our bot to release its polling connection while pairing. + if ((was_active)); then systemctl stop "$SERVICE"; service_stopped=1; fi + configuring=1 + if ! python3 "$release/run.py" setup --config "$CONF" --awg2 "$AWG2" --state-dir "$STATE"; then + die 'Настройка не завершена' + fi +fi +chmod 600 "$CONF" +python3 "$release/run.py" check --config "$CONF" + +activated=1 +cat > "/etc/systemd/system/$SERVICE" </dev/null +systemctl restart "$SERVICE" +sleep 3 +systemctl is-active --quiet "$SERVICE" || die 'Служба не запустилась. journalctl -u awg-gus-bot -n 30' +activated=0 +printf '\nГотово. В личной беседе с ботом Гуся отправьте /menu.\n' +printf 'Служба: awg-gus-bot. Обновление: sudo awg-gus-bot update\n' +printf 'Telegram-бот и awg2 не заменялись. Оба бота управляют общими VPN-клиентами.\n' diff --git a/licenses/awg-toolza-MIT.txt b/licenses/awg-toolza-MIT.txt new file mode 100644 index 0000000..12867cf --- /dev/null +++ b/licenses/awg-toolza-MIT.txt @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2026 KavinZZ + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/licenses/gusvoice-bot-MIT.txt b/licenses/gusvoice-bot-MIT.txt new file mode 100644 index 0000000..ac19eb0 --- /dev/null +++ b/licenses/gusvoice-bot-MIT.txt @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2026 The GusVoice authors + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/run.py b/run.py new file mode 100755 index 0000000..e27ff2b --- /dev/null +++ b/run.py @@ -0,0 +1,9 @@ +#!/usr/bin/env python3 +from pathlib import Path +import sys + +sys.path.insert(0, str(Path(__file__).resolve().parent / "vendor")) +from awg_gus.main import main + +if __name__ == "__main__": + main() diff --git a/tests/test_bot.py b/tests/test_bot.py new file mode 100644 index 0000000..272b9dc --- /dev/null +++ b/tests/test_bot.py @@ -0,0 +1,290 @@ +import asyncio +from pathlib import Path +import sys +import tempfile +import unittest +from unittest.mock import AsyncMock, patch + +ROOT = Path(__file__).resolve().parents[1] +sys.path[:0] = [str(ROOT), str(ROOT / "vendor")] +from awg_gus.bot import Bot, Event +from awg_gus.core import Awg, AwgError, State, validate_command +from awg_gus.main import pair, validate_url + +OWNER = "11111111-1111-4111-8111-111111111111" +OTHER = "22222222-2222-4222-8222-222222222222" +CHAT = "33333333-3333-4333-8333-333333333333" +MSG = "44444444-4444-4444-8444-444444444444" + + +class FakeAwg: + def __init__(self): + self.calls = [] + self.rows = [{"name": "alice", "ip": "10.8.1.2", "online": True, + "handshake": 1, "ago": 10, "rx": 1024, "tx": 512}] + + async def call(self, *args, **kw): + validate_command(args) + self.calls.append(args) + data = {} + if args[:2] == ("clients", "list"): + data = self.rows + elif args[:2] == ("client", "conf"): + data = {"text": "[Interface]\nPrivateKey = test-key\n", "file": "/tmp/alice.conf"} + elif args[:2] == ("client", "add"): + self.rows.append({"name": args[2], "ip": "10.8.1.3"}) + elif args[:2] == ("client", "del"): + self.rows[:] = [r for r in self.rows if r["name"] != args[2]] + elif args == ("status",): + data = {"version": "test", "host": "test-host", "server": {"up": True, "clients": len(self.rows)}} + elif args == ("server", "info"): + data = {"up": True} + return {"ok": True, "data": data, "log": "done"} + + +class FakeDelivery: + def __init__(self): + self.sent = [] + self.acks = [] + + async def show(self, chat, text, buttons=None, message=None): + self.sent.append(dict(chat=chat, text=text, buttons=buttons, message=message)) + return {"message_id": MSG} + + async def ack(self, cid, text=None): + self.acks.append((cid, text)) + + async def call(self, method, **params): + self.sent.append({"method": method, **params}) + return {"message_id": MSG} + + +def message(n, text, uid=OWNER, kind="dm"): + return {"update_id": n, "message": {"message_id": MSG, "text": text, + "from": {"id": uid}, "chat": {"id": CHAT, "type": kind}}} + + +def callback(n, data, uid=OWNER, kind="dm"): + m = message(n, "", uid, kind)["message"] + return {"update_id": n, "callback_query": {"id": f"cb-{n}", "from": {"id": uid}, "data": data, "message": m}} + + +class BotTests(unittest.IsolatedAsyncioTestCase): + def setUp(self): + self.tmp = tempfile.TemporaryDirectory() + self.path = Path(self.tmp.name) / "state.sqlite3" + self.state = State(self.path, "test") + self.api, self.io = FakeAwg(), FakeDelivery() + self.bot = Bot({"owners": [OWNER]}, self.api, self.io, self.state) + + async def asyncTearDown(self): + for t in self.bot.tasks.values(): + t.cancel() + await asyncio.gather(*self.bot.tasks.values(), return_exceptions=True) + self.state.db.close() + self.tmp.cleanup() + + async def feed(self, u): + await self.bot.process(u) + await asyncio.gather(*self.bot.tasks.values()) + + async def test_no_access_for_other_users_or_channels(self): + await self.feed(message(1, "/menu", OTHER)) + await self.feed(message(2, "/menu", OWNER, "channel")) + await self.feed(callback(3, "delete:alice", OTHER)) + await self.feed(callback(4, "conf:alice", OWNER, "channel")) + self.assertEqual(self.api.calls, []) + self.assertEqual(self.io.sent, []) + self.assertEqual(len(self.io.acks), 2) + + async def test_owner_menu_and_callback_ack(self): + await self.feed(message(1, "/start")) + await self.feed(callback(2, "list:0")) + self.assertIn(("status",), self.api.calls) + self.assertIn(("clients", "list"), self.api.calls) + self.assertEqual(self.io.acks[0], ("cb-2", None)) + self.assertIn("alice", str(self.io.sent)) + + async def test_delete_requires_confirmation_and_is_single_use(self): + await self.feed(callback(1, "delete:alice")) + self.assertNotIn(("client", "del", "alice"), self.api.calls) + token = next(iter(self.bot.confirmations)) + await self.feed(callback(2, "yes:" + token, OTHER)) + self.assertIn(token, self.bot.confirmations) + await self.feed(callback(3, "yes:" + token)) + await self.feed(callback(4, "yes:" + token)) + self.assertEqual(self.api.calls.count(("client", "del", "alice")), 1) + + async def test_cancel_invalidates_confirmation(self): + await self.feed(callback(1, "delete:alice")) + token = next(iter(self.bot.confirmations)) + await self.feed(message(2, "/cancel")) + await self.feed(callback(3, "yes:" + token)) + self.assertNotIn(("client", "del", "alice"), self.api.calls) + + async def test_expired_confirmation_does_not_execute(self): + await self.feed(callback(1, "delete:alice")) + token = next(iter(self.bot.confirmations)) + v = self.bot.confirmations[token] + self.bot.confirmations[token] = (*v[:2], 0, *v[3:]) + await self.feed(callback(2, "yes:" + token)) + self.assertNotIn(("client", "del", "alice"), self.api.calls) + + async def test_create_flow_delivers_config_without_leaking_in_text(self): + with patch("awg_gus.bot.shutil.which", return_value=None): + await self.feed(message(1, "/new")) + await self.feed(message(2, "bob")) + await self.feed(callback(3, "newexp:+30d")) + token = next(iter(self.bot.confirmations)) + await self.feed(callback(4, "yes:" + token)) + self.assertIn(("client", "add", "bob", "mimicry=server", "expire=+30d"), self.api.calls) + sent = [m for m in self.io.sent if m.get("files")] + self.assertEqual(len(sent), 1) + self.assertEqual(sent[0]["files"][0].read()[1], "bob.conf") + self.assertNotIn("test-key", " ".join(s.get("text", "") for s in self.io.sent)) + + async def test_invalid_name_never_reaches_awg(self): + await self.feed(message(1, "/new")) + await self.feed(message(2, "alice; reboot")) + await self.feed(callback(3, "conf:../../passwd")) + self.assertFalse(any(a[:2] in {("client", "add"), ("client", "conf")} for a in self.api.calls)) + + async def test_limit_speed_and_expiry_use_validated_commands(self): + n = 0 + for action, value, expected in [ + ("limit", "50G total", ("client", "limit", "alice", "50G", "total")), + ("speed", "20", ("client", "speed", "alice", "20"))]: + n += 1 + await self.feed(callback(n, action + ":alice")) + n += 1 + await self.feed(message(n, value)) + n += 1 + await self.feed(callback(n, "yes:" + next(iter(self.bot.confirmations)))) + self.assertIn(expected, self.api.calls) + await self.feed(callback(n+1, "exp:alice:+7d")) + await self.feed(callback(n+2, "yes:" + next(iter(self.bot.confirmations)))) + self.assertIn(("client", "expire", "alice", "+7d"), self.api.calls) + + async def test_duplicate_update_stays_deduplicated_after_restart(self): + await self.feed(callback(1, "list:0")) + count = len(self.api.calls) + self.state.db.close() + self.state = State(self.path, "test") + self.bot = Bot({"owners": [OWNER]}, self.api, self.io, self.state) + await self.feed(callback(1, "list:0")) + self.assertEqual(len(self.api.calls), count) + + async def test_busy_callback_is_acknowledged_without_second_operation(self): + gate = asyncio.Event() + task = asyncio.create_task(gate.wait()) + self.bot.tasks[(OWNER, CHAT)] = task + await self.bot.process(callback(1, "delete:alice")) + self.assertIn("выполняется", self.io.acks[-1][1]) + self.assertEqual(self.api.calls, []) + gate.set() + await task + + async def test_interrupted_operation_is_preserved_on_cancellation(self): + gate = asyncio.Event() + async def blocked(*args, **kwargs): + await gate.wait() + self.bot.home = blocked + await self.bot.process(message(1, "/start")) + await asyncio.sleep(0) + task = self.bot.tasks[(OWNER, CHAT)] + task.cancel() + await asyncio.gather(task, return_exceptions=True) + self.assertEqual(self.state.db.execute("SELECT COUNT(*) FROM pending").fetchone()[0], 1) + + async def test_monitor_silent_until_state_changes_and_uses_chat_id(self): + self.state.claim(1, OWNER, CHAT) + self.state.finish(1) + self.state.watch(OWNER, "alice") + await self.bot.monitor_tick() + self.assertEqual(self.io.sent, []) + self.api.rows[0]["ago"] = 500 + await self.bot.monitor_tick() + await self.bot.monitor_tick() + self.assertEqual(len(self.io.sent), 1) + self.assertEqual(self.io.sent[0]["chat"], CHAT) + self.assertIn("офлайн", self.io.sent[0]["text"]) + + async def test_config_and_qr_sent_in_same_message(self): + proc = AsyncMock() + proc.returncode = 0 + proc.communicate.return_value = (b"\x89PNG\r\n\x1a\nmock", None) + with patch("awg_gus.bot.shutil.which", return_value="qrencode"), patch( + "awg_gus.bot.asyncio.create_subprocess_exec", return_value=proc) as spawn: + await self.bot.send_config(Event(OWNER, CHAT, 1), "alice") + self.assertEqual(len(self.io.sent[-1]["files"]), 2) + self.assertNotIn("test-key", str(spawn.call_args)) + self.assertIn(b"test-key", proc.communicate.call_args.args[0]) + + async def test_cancelled_qr_is_reaped_without_sending_files(self): + from unittest.mock import Mock + proc = AsyncMock() + proc.kill = Mock() + proc.communicate.side_effect = asyncio.CancelledError + with patch("awg_gus.bot.shutil.which", return_value="qrencode"), patch( + "awg_gus.bot.asyncio.create_subprocess_exec", return_value=proc): + with self.assertRaises(asyncio.CancelledError): + await self.bot.send_config(Event(OWNER, CHAT, 1), "alice") + proc.kill.assert_called_once() + proc.wait.assert_awaited_once() + self.assertEqual(self.io.sent, []) + + +class CoreTests(unittest.TestCase): + def test_command_allowlist_rejects_root_escape_routes(self): + for command in [("server", "reset"), ("bot", "update"), ("job", "start", "server", "reboot"), + ("client", "add", "ok", "mimicry=server", "expire=$(id)"), + ("client", "conf", "../root"), ("client", "speed", "alice", "1;id"), + ("backup", "restore", "/tmp/x")]: + with self.subTest(command=command), self.assertRaises(ValueError): + validate_command(command) + + def test_url_rejects_insecure_or_embedded_credentials(self): + for u in ["http://example.com", "https://user:pass@example.com", "https://example.com?x=1", "https://example.com/api/bot/v1"]: + with self.assertRaises(ValueError): + validate_url(u) + self.assertEqual(validate_url("https://example.com/api/"), "https://example.com") + + def test_pairing_does_not_trust_first_update_or_group_code(self): + from unittest.mock import Mock + client = Mock() + client.get_updates.side_effect = [[message(1, "Привет, бот!", OTHER), + message(2, "/start SECRET", OTHER, "channel"), + message(3, "/start wrong", OTHER), + message(4, "/start SECRET")], []] + self.assertEqual(pair(client, "SECRET"), (OWNER, CHAT, 5)) + + def test_state_cannot_be_shared_between_different_bots(self): + with tempfile.TemporaryDirectory() as d: + s = State(Path(d) / "s.db", "one") + s.db.close() + with self.assertRaises(ValueError): + State(Path(d) / "s.db", "two") + + +class ProcessTests(unittest.IsolatedAsyncioTestCase): + async def test_json_api_without_shell_or_secret_arguments(self): + proc = AsyncMock() + proc.returncode = 0 + proc.communicate.return_value = (b'progress\n{"ok":true,"data":{"version":"test"}}\n', b'') + with patch("awg_gus.core.asyncio.create_subprocess_exec", return_value=proc) as spawn: + result = await Awg("/usr/local/bin/awg2").call("version") + self.assertEqual(result["data"]["version"], "test") + self.assertEqual(spawn.call_args.args, ("/usr/local/bin/awg2", "api", "version")) + + async def test_malformed_or_failed_api_not_reported_as_success(self): + for output in [b"not json", b'{"ok":false,"error":"failed"}']: + proc = AsyncMock() + proc.returncode = 0 + proc.communicate.return_value = (output, b'') + with patch("awg_gus.core.asyncio.create_subprocess_exec", return_value=proc): + with self.assertRaises(AwgError): + await Awg("awg2").call("version") + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_transport.py b/tests/test_transport.py new file mode 100644 index 0000000..c17d025 --- /dev/null +++ b/tests/test_transport.py @@ -0,0 +1,115 @@ +"""Exercise the actual vendored SDK and native bot against a local HTTP API double.""" +import asyncio +from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer +import json +from pathlib import Path +import sys +import tempfile +import threading +import unittest +from unittest.mock import patch + +ROOT = Path(__file__).resolve().parents[1] +sys.path[:0] = [str(ROOT), str(ROOT / "vendor")] +from gusvoice_bot import AsyncClient, Client, GusVoiceApiError +from awg_gus.bot import Bot +from awg_gus.core import Delivery, State +from test_bot import CHAT, MSG, OWNER, FakeAwg, callback, message + + +class Handler(BaseHTTPRequestHandler): + def log_message(self, *args): + pass + + def do_POST(self): + body = self.rfile.read(int(self.headers.get("Content-Length", "0"))) + method = self.path.rsplit("/", 1)[-1] + self.server.received.append((method, self.headers, body)) + if self.headers.get("Authorization") != "Bot gvb_test": + code, result = 401, {"ok": False, "error_code": 401, "description": "Unauthorized"} + elif self.server.fail_next: + self.server.fail_next = False + code, result = 429, {"ok": False, "error_code": 429, "description": "slow down", "retry_after": 0} + else: + code = 200 + data = json.loads(body) if "application/json" in self.headers.get("Content-Type", "") else {} + if method in {"sendMessage", "editMessageText"}: + if data and (not isinstance(data.get("chat_id"), str) or data.get("parse_mode") not in {None, "HTML"}): + raise AssertionError("Wrong native API contract") + payload = {"message_id": MSG, "chat": {"id": CHAT, "type": "dm"}, "text": data.get("text", "")} + elif method == "getUpdates": + payload = [] + elif method == "getWebhookInfo": + payload = {"url": ""} + else: + payload = True + result = {"ok": True, "result": payload} + raw = json.dumps(result).encode() + self.send_response(code) + self.send_header("Content-Type", "application/json") + self.send_header("Content-Length", str(len(raw))) + self.end_headers() + self.wfile.write(raw) + + +class TransportTests(unittest.IsolatedAsyncioTestCase): + def setUp(self): + self.server = ThreadingHTTPServer(("127.0.0.1", 0), Handler) + self.server.received = [] + self.server.fail_next = False + self.thread = threading.Thread(target=self.server.serve_forever, daemon=True) + self.thread.start() + self.url = f"http://127.0.0.1:{self.server.server_port}" + + def tearDown(self): + self.server.shutdown() + self.server.server_close() + self.thread.join() + + async def test_real_sdk_native_menu_and_config_upload(self): + with tempfile.TemporaryDirectory() as tmp: + state = State(Path(tmp) / "state.db", "test") + client = AsyncClient("gvb_test", self.url, discover=False) + bot = Bot({"owners": [OWNER]}, FakeAwg(), Delivery(client), state) + try: + await bot.process(message(1, "/menu")) + await asyncio.gather(*bot.tasks.values()) + with patch("awg_gus.bot.shutil.which", return_value=None): + await bot.process(callback(2, "conf:alice")) + await asyncio.gather(*bot.tasks.values()) + methods = [r[0] for r in self.server.received] + self.assertIn("answerCallbackQuery", methods) + messages = [r for r in self.server.received if r[0] == "sendMessage"] + self.assertEqual(json.loads(messages[0][2])["chat_id"], CHAT) + self.assertIn(b'name="files[0]"', messages[-1][2]) + self.assertIn(b'filename="alice.conf"', messages[-1][2]) + self.assertIn("multipart/form-data", messages[-1][1]["Content-Type"]) + finally: + state.db.close() + + async def test_sdk_429_retry_preserves_uuid_and_payload(self): + self.server.fail_next = True + client = AsyncClient("gvb_test", self.url, discover=False) + await client.send_message(CHAT, "hello") + self.assertEqual(len(self.server.received), 2) + self.assertEqual(self.server.received[0][2], self.server.received[1][2]) + + async def test_sdk_unauthorized_not_retried(self): + client = AsyncClient("gvb_wrong", self.url, discover=False) + with self.assertRaises(GusVoiceApiError) as error: + await client.send_message(CHAT, "hello") + self.assertEqual(error.exception.code, 401) + self.assertEqual(len(self.server.received), 1) + + async def test_delivery_edits_by_uuid_without_arithmetic(self): + delivery = Delivery(AsyncClient("gvb_test", self.url, discover=False)) + await delivery.show(CHAT, "new screen", [[{"text": "Menu", "callback_data": "home"}]], MSG) + method, _, raw = self.server.received[0] + self.assertEqual(method, "editMessageText") + payload = json.loads(raw) + self.assertEqual(payload["message_id"], MSG) + self.assertTrue(payload["reply_markup"]["inline_keyboard"]) + + +if __name__ == "__main__": + unittest.main() diff --git a/vendor/gusvoice_bot/__init__.py b/vendor/gusvoice_bot/__init__.py new file mode 100644 index 0000000..c3cfaa6 --- /dev/null +++ b/vendor/gusvoice_bot/__init__.py @@ -0,0 +1,50 @@ +# SPDX-License-Identifier: MIT +"""GusVoice Bot API for Python — a thin client on the standard library only. + + from gusvoice_bot import Client + bot = Client(os.environ["BOT_TOKEN"], "https://voice.example.com") + bot.send_message(chat_id, "Hello") + +Long polling (`run_polling`), webhook deliveries with signature check (`verify_signature`, `webhook_handler`), inline +buttons (`InlineKeyboard`), retry on 429. No WebSocket and no voice: for those use the TypeScript SDK `@gusvoice/bot`. +""" +from ._generated import API_VERSION, METHODS, SCHEMA_VERSION, UPDATE_TYPES +from .client import AsyncClient, Client, InputFile, normalize_base +from .errors import GusVoiceApiError, GusVoiceError +from .keyboard import ( + CALLBACK_DATA_MAX_BYTES, + KEYBOARD_BUTTON_TEXT_MAX, + KEYBOARD_BUTTONS_MAX, + KEYBOARD_ROW_BUTTONS_MAX, + InlineKeyboard, +) +from .polling import run_polling +from .webhook import DEFAULT_TOLERANCE_S, SIGNATURE_HEADER, parse_signature_header, sign, verify_signature, webhook_handler + +__version__ = "0.1.0" + +__all__ = [ + "API_VERSION", + "METHODS", + "SCHEMA_VERSION", + "UPDATE_TYPES", + "AsyncClient", + "Client", + "InputFile", + "normalize_base", + "GusVoiceApiError", + "GusVoiceError", + "CALLBACK_DATA_MAX_BYTES", + "KEYBOARD_BUTTON_TEXT_MAX", + "KEYBOARD_BUTTONS_MAX", + "KEYBOARD_ROW_BUTTONS_MAX", + "InlineKeyboard", + "run_polling", + "DEFAULT_TOLERANCE_S", + "SIGNATURE_HEADER", + "parse_signature_header", + "sign", + "verify_signature", + "webhook_handler", + "__version__", +] diff --git a/vendor/gusvoice_bot/_generated.py b/vendor/gusvoice_bot/_generated.py new file mode 100644 index 0000000..7dcb585 --- /dev/null +++ b/vendor/gusvoice_bot/_generated.py @@ -0,0 +1,502 @@ +# Generated by `node scripts/gen-bot-api.mjs` from docs/bot-api/openapi.json — DO NOT EDIT. +# CI (`gen-bot-api.mjs --check`) fails when this file is out of date. +# SPDX-License-Identifier: MIT +"""Types and method wrappers of the GusVoice Bot API, generated from the OpenAPI document.""" +from __future__ import annotations + +from typing import Any, Dict, List, Literal, Optional, Sequence, TypedDict, Union + +SCHEMA_VERSION = "1.4.0" +"""Version of the OpenAPI document this module was generated from.""" +API_VERSION = 4 +"""Bot API level (`getMe.api_version`) the wrappers below know about.""" + +METHODS: Dict[str, int] = { + "getMe": 1, + "getUpdates": 1, + "setWebhook": 1, + "deleteWebhook": 1, + "getWebhookInfo": 1, + "sendMessage": 1, + "editMessageText": 1, + "deleteMessage": 1, + "sendChatAction": 1, + "getChat": 1, + "editMessageReplyMarkup": 2, + "answerCallbackQuery": 2, + "setMyCommands": 2, + "getMyCommands": 2, + "deleteMyCommands": 2, + "setMessageReaction": 3, + "pinChatMessage": 3, + "unpinChatMessage": 3, + "getChatHistory": 3, + "getChatMember": 3, + "getChatMemberCount": 3, + "getChatMembers": 3, + "getMyServers": 3, + "getServer": 3, + "addChatMemberRole": 3, + "removeChatMemberRole": 3, + "banChatMember": 3, + "unbanChatMember": 3, + "kickChatMember": 3, + "leaveChat": 3, + "joinVoice": 4, + "leaveVoice": 4, + "getVoiceChatMembers": 4, +} +"""Every Bot API method → the API level it appeared in (`getMe.api_version`).""" + +UPDATE_TYPES = ( + "message", + "edited_message", + "message_deleted", + "my_chat_member", + "callback_query", + "message_reaction", + "chat_member", + "voice_state", +) +"""Update kinds (one key besides `update_id` in every `Update`); unknown kinds may appear — ignore them.""" + + +# required: url, file_name, mime_type, file_size +Attachment = TypedDict("Attachment", { + "url": "str", + "file_name": "str", + "mime_type": "str", + "file_size": "float", +}, total=False) + +# required: command, description +BotCommand = TypedDict("BotCommand", { + "command": "str", + "description": "str", +}, total=False) + +# Supported `type`: default, all_private_chats, all_group_chats; other types are rejected with 400. +# required: type +BotCommandScope = TypedDict("BotCommandScope", { + "type": "str", +}, total=False) + +# required: id, username, display_name, bot, description, about, public +BotUser = TypedDict("BotUser", { + "id": "str", + "username": "str", + "display_name": "str", + "bot": "bool", + "instance_domain": "str", + "description": "str", + "about": "str", + "public": "bool", + "api_version": "float", + "features": "List[str]", + "dropped_update_count": "float", +}, total=False) + +# required: id, from, message, data, date +CallbackQuery = TypedDict("CallbackQuery", { + "id": "str", + "from": "User", + "message": "Message", + "data": "str", + "date": "float", +}, total=False) + +# required: id, type +Chat = TypedDict("Chat", { + "id": "str", + "type": "Literal[\"dm\", \"channel\", \"server\", \"voice\"]", + "server_id": "str", + "title": "str", +}, total=False) + +# required: id, type +ChatFullInfo = TypedDict("ChatFullInfo", { + "id": "str", + "type": "Literal[\"dm\", \"channel\", \"server\", \"voice\"]", + "server_id": "str", + "title": "str", + "user": "User", + "member_count": "float", +}, total=False) + +# required: user, status, role_ids +ChatMember = TypedDict("ChatMember", { + "user": "User", + "status": "Literal[\"creator\", \"member\", \"left\", \"kicked\"]", + "nickname": "str", + "role_ids": "List[str]", + "joined_date": "float", +}, total=False) + +# required: chat, from, date, old_status, new_status +ChatMemberUpdated = TypedDict("ChatMemberUpdated", { + "chat": "Chat", + "from": "User", + "date": "float", + "old_status": "Literal[\"creator\", \"member\", \"left\", \"kicked\"]", + "new_status": "Literal[\"creator\", \"member\", \"left\", \"kicked\"]", +}, total=False) + +# required: members +ChatMembersPage = TypedDict("ChatMembersPage", { + "members": "List[ChatMember]", + "next_offset": "str", +}, total=False) + +# required: ok, error_code, description +Error = TypedDict("Error", { + "ok": "bool", + "error_code": "float", + "description": "str", + "retry_after": "float", +}, total=False) + +# Exactly one of `callback_data` (1–64 BYTES of UTF-8, not characters) and `url` (http(s) only, no credentials in the address). +# required: text +InlineKeyboardButton = TypedDict("InlineKeyboardButton", { + "text": "str", + "callback_data": "str", + "url": "str", +}, total=False) + +# At least one row, 1–8 buttons in a row, at most 100 buttons in total. +# required: inline_keyboard +InlineKeyboardMarkup = TypedDict("InlineKeyboardMarkup", { + "inline_keyboard": "List[List[InlineKeyboardButton]]", +}, total=False) + +# required: — +InputEmbed = TypedDict("InputEmbed", { + "title": "Optional[str]", + "description": "Optional[str]", + "url": "Optional[str]", + "color": "Optional[int]", + "timestamp": "Optional[str]", + "fields": "Optional[List[Dict[str, Any]]]", + "author": "Optional[Dict[str, Any]]", + "footer": "Optional[Dict[str, Any]]", + "image": "Optional[Dict[str, Any]]", + "thumbnail": "Optional[Dict[str, Any]]", +}, total=False) + +# required: message_id, chat, from, date, text +Message = TypedDict("Message", { + "message_id": "str", + "chat": "Chat", + "from": "User", + "date": "float", + "edit_date": "float", + "text": "str", + "reply_to_message_id": "str", + "attachments": "List[Attachment]", + "embeds": "List[MessageEmbed]", + "reply_markup": "InlineKeyboardMarkup", +}, total=False) + +# required: message_id, chat, date +MessageDeleted = TypedDict("MessageDeleted", { + "message_id": "str", + "chat": "Chat", + "date": "float", +}, total=False) + +# required: — +MessageEmbed = TypedDict("MessageEmbed", { + "title": "str", + "url": "str", + "description": "str", + "color": "float", + "timestamp": "str", + "author": "Dict[str, Any]", + "footer": "Dict[str, Any]", + "fields": "List[Dict[str, Any]]", + "image": "Dict[str, Any]", + "thumbnail": "Dict[str, Any]", + "imageLink": "str", + "thumbnailLink": "str", +}, total=False) + +# required: chat, message_id, user, date, old_reaction, new_reaction +MessageReactionUpdated = TypedDict("MessageReactionUpdated", { + "chat": "Chat", + "message_id": "str", + "user": "User", + "date": "float", + "old_reaction": "List[ReactionType]", + "new_reaction": "List[ReactionType]", +}, total=False) + +# required: type, emoji +ReactionType = TypedDict("ReactionType", { + "type": "Literal[\"emoji\"]", + "emoji": "str", +}, total=False) + +# required: id, title +ServerBrief = TypedDict("ServerBrief", { + "id": "str", + "title": "str", +}, total=False) + +# required: id, title, channels, roles +ServerInfo = TypedDict("ServerInfo", { + "id": "str", + "title": "str", + "channels": "List[Dict[str, Any]]", + "roles": "List[Dict[str, Any]]", +}, total=False) + +# required: chat, from, user, date, old_status, new_status +ServerMemberUpdated = TypedDict("ServerMemberUpdated", { + "chat": "Chat", + "from": "User", + "user": "User", + "date": "float", + "old_status": "Literal[\"creator\", \"member\", \"left\", \"kicked\"]", + "new_status": "Literal[\"creator\", \"member\", \"left\", \"kicked\"]", +}, total=False) + +# required: update_id +Update = TypedDict("Update", { + "update_id": "float", + "message": "Message", + "edited_message": "Message", + "message_deleted": "MessageDeleted", + "my_chat_member": "ChatMemberUpdated", + "callback_query": "CallbackQuery", + "message_reaction": "MessageReactionUpdated", + "chat_member": "ServerMemberUpdated", + "voice_state": "VoiceStateUpdated", +}, total=False) + +# required: id, username, display_name, bot +User = TypedDict("User", { + "id": "str", + "username": "str", + "display_name": "str", + "bot": "bool", + "instance_domain": "str", +}, total=False) + +# required: user, muted, deafened, server_muted, is_bot +VoiceChatMember = TypedDict("VoiceChatMember", { + "user": "User", + "muted": "bool", + "deafened": "bool", + "server_muted": "bool", + "is_bot": "bool", +}, total=False) + +# required: url, token, room, chat, server_muted, expires_in, audio +VoiceJoinResult = TypedDict("VoiceJoinResult", { + "url": "str", + "token": "str", + "room": "str", + "chat": "Chat", + "server_muted": "bool", + "expires_in": "float", + "audio": "Dict[str, Any]", +}, total=False) + +# required: chat, user, action, date +VoiceStateUpdated = TypedDict("VoiceStateUpdated", { + "chat": "Chat", + "user": "User", + "action": "Literal[\"joined\", \"left\"]", + "date": "float", + "move_to": "Chat", +}, total=False) + +# required: url, pending_update_count, failures +WebhookInfo = TypedDict("WebhookInfo", { + "url": "str", + "pending_update_count": "float", + "failures": "float", + "last_error_date": "float", + "last_error_message": "str", + "disabled_date": "float", + "batch": "bool", + "dropped_update_count": "float", +}, total=False) + + +def _params(params: Dict[str, Any], extra: Dict[str, Any]) -> Dict[str, Any]: + out = {k: v for k, v in params.items() if v is not None} + out.update(extra) + return out + + +class GeneratedMethods: + """One wrapper per Bot API method; `None` arguments are not sent. `**extra` passes parameters newer than this SDK.""" + + def call(self, method: str, params: Optional[Dict[str, Any]] = None, *, files: Optional[Sequence[Any]] = None) -> Any: + raise NotImplementedError + + def get_me(self, **extra: Any) -> BotUser: + """`getMe` (API 1+).""" + params: Dict[str, Any] = {} + return self.call("getMe", _params(params, extra)) + + def get_updates(self, *, offset: Optional[int] = None, limit: Optional[int] = None, timeout: Optional[int] = None, allowed_updates: Optional[List[str]] = None, **extra: Any) -> List[Update]: + """`getUpdates` (API 1+). Long polling: the request is held up to `timeout` seconds (at most 50).""" + params: Dict[str, Any] = {"offset": offset, "limit": limit, "timeout": timeout, "allowed_updates": allowed_updates} + return self.call("getUpdates", _params(params, extra)) + + def set_webhook(self, url: str, secret_token: str, *, drop_pending_updates: Optional[bool] = None, batch: Optional[bool] = None, **extra: Any) -> bool: + """`setWebhook` (API 1+).""" + params: Dict[str, Any] = {"url": url, "secret_token": secret_token, "drop_pending_updates": drop_pending_updates, "batch": batch} + return self.call("setWebhook", _params(params, extra)) + + def delete_webhook(self, *, drop_pending_updates: Optional[bool] = None, **extra: Any) -> bool: + """`deleteWebhook` (API 1+).""" + params: Dict[str, Any] = {"drop_pending_updates": drop_pending_updates} + return self.call("deleteWebhook", _params(params, extra)) + + def get_webhook_info(self, **extra: Any) -> WebhookInfo: + """`getWebhookInfo` (API 1+).""" + params: Dict[str, Any] = {} + return self.call("getWebhookInfo", _params(params, extra)) + + def send_message(self, chat_id: str, text: Optional[str] = None, *, reply_to_message_id: Optional[str] = None, embeds: Optional[List[InputEmbed]] = None, disable_notification: Optional[bool] = None, disable_link_preview: Optional[bool] = None, reply_markup: Optional[InlineKeyboardMarkup] = None, client_id: Optional[str] = None, parse_mode: Optional[str] = None, files: Optional[Sequence[Any]] = None, **extra: Any) -> Message: + """`sendMessage` (API 1+).""" + params: Dict[str, Any] = {"chat_id": chat_id, "text": text, "reply_to_message_id": reply_to_message_id, "embeds": embeds, "disable_notification": disable_notification, "disable_link_preview": disable_link_preview, "reply_markup": reply_markup, "client_id": client_id, "parse_mode": parse_mode} + return self.call("sendMessage", _params(params, extra), files=files) + + def edit_message_text(self, chat_id: str, message_id: str, text: str, *, reply_markup: Optional[InlineKeyboardMarkup] = None, parse_mode: Optional[str] = None, **extra: Any) -> Message: + """`editMessageText` (API 1+).""" + params: Dict[str, Any] = {"chat_id": chat_id, "message_id": message_id, "text": text, "reply_markup": reply_markup, "parse_mode": parse_mode} + return self.call("editMessageText", _params(params, extra)) + + def delete_message(self, chat_id: str, message_id: str, **extra: Any) -> bool: + """`deleteMessage` (API 1+).""" + params: Dict[str, Any] = {"chat_id": chat_id, "message_id": message_id} + return self.call("deleteMessage", _params(params, extra)) + + def send_chat_action(self, chat_id: str, action: Literal["typing"], **extra: Any) -> bool: + """`sendChatAction` (API 1+).""" + params: Dict[str, Any] = {"chat_id": chat_id, "action": action} + return self.call("sendChatAction", _params(params, extra)) + + def get_chat(self, chat_id: str, **extra: Any) -> ChatFullInfo: + """`getChat` (API 1+).""" + params: Dict[str, Any] = {"chat_id": chat_id} + return self.call("getChat", _params(params, extra)) + + def edit_message_reply_markup(self, chat_id: str, message_id: str, *, reply_markup: Optional[InlineKeyboardMarkup] = None, **extra: Any) -> Message: + """`editMessageReplyMarkup` (API 2+).""" + params: Dict[str, Any] = {"chat_id": chat_id, "message_id": message_id, "reply_markup": reply_markup} + return self.call("editMessageReplyMarkup", _params(params, extra)) + + def answer_callback_query(self, callback_query_id: str, text: Optional[str] = None, *, show_alert: Optional[bool] = None, **extra: Any) -> bool: + """`answerCallbackQuery` (API 2+).""" + params: Dict[str, Any] = {"callback_query_id": callback_query_id, "text": text, "show_alert": show_alert} + return self.call("answerCallbackQuery", _params(params, extra)) + + def set_my_commands(self, commands: List[BotCommand], *, scope: Optional[BotCommandScope] = None, **extra: Any) -> bool: + """`setMyCommands` (API 2+).""" + params: Dict[str, Any] = {"commands": commands, "scope": scope} + return self.call("setMyCommands", _params(params, extra)) + + def get_my_commands(self, *, scope: Optional[BotCommandScope] = None, **extra: Any) -> List[BotCommand]: + """`getMyCommands` (API 2+).""" + params: Dict[str, Any] = {"scope": scope} + return self.call("getMyCommands", _params(params, extra)) + + def delete_my_commands(self, *, scope: Optional[BotCommandScope] = None, **extra: Any) -> bool: + """`deleteMyCommands` (API 2+).""" + params: Dict[str, Any] = {"scope": scope} + return self.call("deleteMyCommands", _params(params, extra)) + + def set_message_reaction(self, chat_id: str, message_id: str, *, reaction: Optional[List[ReactionType]] = None, **extra: Any) -> bool: + """`setMessageReaction` (API 3+).""" + params: Dict[str, Any] = {"chat_id": chat_id, "message_id": message_id, "reaction": reaction} + return self.call("setMessageReaction", _params(params, extra)) + + def pin_chat_message(self, chat_id: str, message_id: str, *, disable_notification: Optional[bool] = None, **extra: Any) -> bool: + """`pinChatMessage` (API 3+).""" + params: Dict[str, Any] = {"chat_id": chat_id, "message_id": message_id, "disable_notification": disable_notification} + return self.call("pinChatMessage", _params(params, extra)) + + def unpin_chat_message(self, chat_id: str, message_id: str, **extra: Any) -> bool: + """`unpinChatMessage` (API 3+).""" + params: Dict[str, Any] = {"chat_id": chat_id, "message_id": message_id} + return self.call("unpinChatMessage", _params(params, extra)) + + def get_chat_history(self, chat_id: str, *, before_message_id: Optional[str] = None, limit: Optional[int] = None, **extra: Any) -> List[Message]: + """`getChatHistory` (API 3+).""" + params: Dict[str, Any] = {"chat_id": chat_id, "before_message_id": before_message_id, "limit": limit} + return self.call("getChatHistory", _params(params, extra)) + + def get_chat_member(self, chat_id: str, user_id: str, **extra: Any) -> ChatMember: + """`getChatMember` (API 3+).""" + params: Dict[str, Any] = {"chat_id": chat_id, "user_id": user_id} + return self.call("getChatMember", _params(params, extra)) + + def get_chat_member_count(self, chat_id: str, **extra: Any) -> int: + """`getChatMemberCount` (API 3+).""" + params: Dict[str, Any] = {"chat_id": chat_id} + return self.call("getChatMemberCount", _params(params, extra)) + + def get_chat_members(self, chat_id: str, *, offset: Optional[str] = None, limit: Optional[int] = None, **extra: Any) -> ChatMembersPage: + """`getChatMembers` (API 3+).""" + params: Dict[str, Any] = {"chat_id": chat_id, "offset": offset, "limit": limit} + return self.call("getChatMembers", _params(params, extra)) + + def get_my_servers(self, **extra: Any) -> List[ServerBrief]: + """`getMyServers` (API 3+).""" + params: Dict[str, Any] = {} + return self.call("getMyServers", _params(params, extra)) + + def get_server(self, server_id: str, **extra: Any) -> ServerInfo: + """`getServer` (API 3+).""" + params: Dict[str, Any] = {"server_id": server_id} + return self.call("getServer", _params(params, extra)) + + def add_chat_member_role(self, chat_id: str, user_id: str, role_id: str, **extra: Any) -> bool: + """`addChatMemberRole` (API 3+).""" + params: Dict[str, Any] = {"chat_id": chat_id, "user_id": user_id, "role_id": role_id} + return self.call("addChatMemberRole", _params(params, extra)) + + def remove_chat_member_role(self, chat_id: str, user_id: str, role_id: str, **extra: Any) -> bool: + """`removeChatMemberRole` (API 3+).""" + params: Dict[str, Any] = {"chat_id": chat_id, "user_id": user_id, "role_id": role_id} + return self.call("removeChatMemberRole", _params(params, extra)) + + def ban_chat_member(self, chat_id: str, user_id: str, *, reason: Optional[str] = None, **extra: Any) -> bool: + """`banChatMember` (API 3+).""" + params: Dict[str, Any] = {"chat_id": chat_id, "user_id": user_id, "reason": reason} + return self.call("banChatMember", _params(params, extra)) + + def unban_chat_member(self, chat_id: str, user_id: str, **extra: Any) -> bool: + """`unbanChatMember` (API 3+).""" + params: Dict[str, Any] = {"chat_id": chat_id, "user_id": user_id} + return self.call("unbanChatMember", _params(params, extra)) + + def kick_chat_member(self, chat_id: str, user_id: str, **extra: Any) -> bool: + """`kickChatMember` (API 3+).""" + params: Dict[str, Any] = {"chat_id": chat_id, "user_id": user_id} + return self.call("kickChatMember", _params(params, extra)) + + def leave_chat(self, chat_id: str, **extra: Any) -> bool: + """`leaveChat` (API 3+).""" + params: Dict[str, Any] = {"chat_id": chat_id} + return self.call("leaveChat", _params(params, extra)) + + def join_voice(self, chat_id: str, *, bitrate: Optional[int] = None, **extra: Any) -> VoiceJoinResult: + """`joinVoice` (API 4+).""" + params: Dict[str, Any] = {"chat_id": chat_id, "bitrate": bitrate} + return self.call("joinVoice", _params(params, extra)) + + def leave_voice(self, *, chat_id: Optional[str] = None, **extra: Any) -> bool: + """`leaveVoice` (API 4+).""" + params: Dict[str, Any] = {"chat_id": chat_id} + return self.call("leaveVoice", _params(params, extra)) + + def get_voice_chat_members(self, chat_id: str, **extra: Any) -> List[VoiceChatMember]: + """`getVoiceChatMembers` (API 4+).""" + params: Dict[str, Any] = {"chat_id": chat_id} + return self.call("getVoiceChatMembers", _params(params, extra)) diff --git a/vendor/gusvoice_bot/client.py b/vendor/gusvoice_bot/client.py new file mode 100644 index 0000000..41bbaa6 --- /dev/null +++ b/vendor/gusvoice_bot/client.py @@ -0,0 +1,273 @@ +# SPDX-License-Identifier: MIT +"""HTTP client of the GusVoice Bot API on the standard library only (`urllib`). + +Every method is `POST /api/bot/v1/` with `Authorization: Bot `; the answer is +`{"ok": true, "result": ...}` or `{"ok": false, "error_code", "description", "retry_after"?}`. A 429 is retried after +`retry_after` (up to `max_retries` times); other errors raise `GusVoiceApiError`, network errors raise as they are +(`urllib.error.URLError`, `TimeoutError`). The same behaviour as the TypeScript SDK (`packages/bot-sdk/src/apiClient.ts`). +""" +from __future__ import annotations + +import asyncio +import json +import mimetypes +import os +import socket +import threading +import time +import urllib.error +import urllib.request +import uuid +from typing import Any, BinaryIO, Callable, Dict, List, Optional, Sequence, Tuple, Union +from urllib.parse import urljoin + +from ._generated import GeneratedMethods +from .errors import GusVoiceApiError + +__all__ = ["Client", "AsyncClient", "InputFile", "normalize_base"] + +DEFAULT_TIMEOUT_S = 30.0 +"""Time limit of one request, seconds (long polling adds its own `timeout` on top).""" +DISCOVERY_TIMEOUT_S = 10.0 + + +def normalize_base(url: str) -> str: + """Without trailing `/` and without a trailing `/api`: the client adds the method path itself.""" + u = str(url).strip().rstrip("/") + if u.endswith("/api"): + u = u[:-4] + if not (u.lower().startswith("http://") or u.lower().startswith("https://")): + raise ValueError(f'base_url must start with http:// or https:// (got "{url}")') + return u + + +class InputFile: + """A file for `send_message(..., files=[...])`: a path, bytes or a binary file object.""" + + def __init__(self, source: Union[str, "os.PathLike[str]", bytes, bytearray, BinaryIO], filename: Optional[str] = None, + content_type: Optional[str] = None) -> None: + self.source = source + self.filename = filename + self.content_type = content_type + + def read(self) -> Tuple[bytes, str, str]: + """`(data, file name, content type)`.""" + src = self.source + name = self.filename + if isinstance(src, (bytes, bytearray)): + data = bytes(src) + name = name or "file" + elif isinstance(src, (str, os.PathLike)): + path = os.fspath(src) + with open(path, "rb") as f: + data = f.read() + name = name or os.path.basename(path) or "file" + else: + data = src.read() + name = name or os.path.basename(str(getattr(src, "name", "") or "")) or "file" + ctype = self.content_type or mimetypes.guess_type(name)[0] or "application/octet-stream" + return data, name, ctype + + +def _json_default(o: Any) -> Any: + to_dict = getattr(o, "to_dict", None) # InlineKeyboard and similar builders + if callable(to_dict): + return to_dict() + raise TypeError(f"Object of type {type(o).__name__} is not JSON serializable") + + +def _dumps(value: Any) -> str: + return json.dumps(value, default=_json_default, ensure_ascii=False, separators=(",", ":")) + + +def _multipart(params: Dict[str, Any], files: Sequence[Any]) -> Tuple[bytes, str]: + """`multipart/form-data`: parameters as fields (objects and arrays as JSON strings), files as `files[0]`, `files[1]` ...""" + boundary = f"gusvoice-{uuid.uuid4().hex}" + out: List[bytes] = [] + + def quote(s: str) -> str: + return s.replace("\\", "\\\\").replace('"', '\\"').replace("\r", " ").replace("\n", " ") + + for k, v in params.items(): + if v is None: + continue + if isinstance(v, bool): + text = "true" if v else "false" + elif isinstance(v, (dict, list, tuple)) or callable(getattr(v, "to_dict", None)): + text = _dumps(v) + else: + text = str(v) + out.append(f'--{boundary}\r\nContent-Disposition: form-data; name="{quote(k)}"\r\n\r\n'.encode("utf-8")) + out.append(text.encode("utf-8") + b"\r\n") + for i, f in enumerate(files): + data, name, ctype = (f if isinstance(f, InputFile) else InputFile(f)).read() + out.append( + f'--{boundary}\r\nContent-Disposition: form-data; name="files[{i}]"; filename="{quote(name)}"\r\n' + f"Content-Type: {ctype}\r\n\r\n".encode("utf-8") + ) + out.append(data + b"\r\n") + out.append(f"--{boundary}--\r\n".encode("ascii")) + return b"".join(out), f"multipart/form-data; boundary={boundary}" + + +class _NoRedirect(urllib.request.HTTPRedirectHandler): + """A redirect would carry the bot token to another address: answer it as an error instead.""" + + def redirect_request(self, req, fp, code, msg, headers, newurl): # type: ignore[no-untyped-def] + return None + + +class Client(GeneratedMethods): + """Bot API client. `base_url` is the instance site (`https://voice.example.com`) or its API address. + + The API address is found once through `/config.json` (`apiUrl`), as the web client does; pass + `discover=False` to use `base_url` as is. All methods are generated from the OpenAPI document (`_generated.py`): + `send_message(chat_id, "text", reply_markup=...)`, `get_updates(offset=..., timeout=...)`, ...; any method by name — + `call("sendMessage", {...})`. Thread-safe: one client can be used from several threads. + """ + + def __init__( + self, + token: str, + base_url: str, + *, + discover: bool = True, + max_retries: int = 3, + max_retry_after: float = 60, + timeout: float = DEFAULT_TIMEOUT_S, + opener: Optional[urllib.request.OpenerDirector] = None, + sleep: Callable[[float], Any] = time.sleep, + ) -> None: + if not token or not isinstance(token, str): + raise ValueError("token is required (BotFather-style `gvb_...` from the GusVoice bot settings)") + self.token = token + self.base_url = normalize_base(base_url) + self.discover = discover + self.max_retries = max(0, int(max_retries)) + self.max_retry_after = max_retry_after + self.timeout = timeout + self._opener = opener or urllib.request.build_opener(_NoRedirect) + self._sleep = sleep + self._api: Optional[str] = None + self._api_lock = threading.Lock() + + # ── address ─────────────────────────────────────────────────────────────────────────────────────────────────── + def api_url(self) -> str: + """API address (`https://api.example.com`) after reading `config.json`; a network failure is not remembered.""" + with self._api_lock: + if self._api is None: + self._api = self._discover_api() + return self._api + + def _discover_api(self) -> str: + if not self.discover: + return self.base_url + req = urllib.request.Request(f"{self.base_url}/config.json", headers={"Accept": "application/json"}) + try: + with self._opener.open(req, timeout=DISCOVERY_TIMEOUT_S) as r: + raw = r.read() + except urllib.error.HTTPError: + return self.base_url # no config.json here: base_url already is the API + except (TimeoutError, socket.timeout): + return self.base_url + except urllib.error.URLError as e: + if isinstance(e.reason, (TimeoutError, socket.timeout)): + return self.base_url + raise + try: + cfg = json.loads(raw.decode("utf-8")) + except ValueError: + return self.base_url + api = cfg.get("apiUrl") if isinstance(cfg, dict) else None + if not isinstance(api, str) or not api.strip(): + return self.base_url + found = normalize_base(urljoin(f"{self.base_url}/", api.strip())) + # The site is https, but config.json points to plain http (spoofing or a misconfiguration): keep the token off it. + if self.base_url.lower().startswith("https:") and not found.lower().startswith("https:"): + raise ValueError( + f"config.json of {self.base_url} points apiUrl to non-HTTPS {found}; refusing to send the bot token over plain HTTP" + ) + return found + + def auth_header(self) -> str: + return f"Bot {self.token}" + + # ── methods ─────────────────────────────────────────────────────────────────────────────────────────────────── + def call(self, method: str, params: Optional[Dict[str, Any]] = None, *, files: Optional[Sequence[Any]] = None, + request_timeout: Optional[float] = None) -> Any: + """Any Bot API method by name. `params` go as JSON; with `files` — as `multipart/form-data`.""" + params = {k: v for k, v in (params or {}).items() if v is not None} + url = f"{self.api_url()}/api/bot/v1/{method}" + limit = request_timeout + if limit is None: + limit = self.timeout + if method.lower() == "getupdates": # long polling: the server holds the request up to `timeout` seconds + try: + limit += float(params.get("timeout") or 0) + except (TypeError, ValueError): + pass + attempt = 0 + while True: + if files: + body, ctype = _multipart(params, files) + else: + body, ctype = _dumps(params).encode("utf-8"), "application/json" + req = urllib.request.Request( + url, data=body, method="POST", + headers={"Authorization": self.auth_header(), "Content-Type": ctype, "Accept": "application/json"}, + ) + retry_header: Optional[str] = None + try: + with self._opener.open(req, timeout=limit) as r: + status = r.status + raw = r.read() + except urllib.error.HTTPError as e: + status = e.code + retry_header = e.headers.get("Retry-After") if e.headers else None + raw = e.read() or b"" + try: + res = json.loads(raw.decode("utf-8")) + except ValueError: + res = None + if isinstance(res, dict) and res.get("ok") is True: + return res.get("result") + err = res if isinstance(res, dict) and res.get("ok") is False else {} + code = err.get("error_code") if isinstance(err.get("error_code"), int) else status + description = err.get("description") if isinstance(err.get("description"), str) else f"HTTP {status}" + retry_after = err.get("retry_after") + if not isinstance(retry_after, int) or isinstance(retry_after, bool): + retry_after = int(retry_header) if retry_header and retry_header.strip().isdigit() else None + if code == 429 and retry_after is not None and attempt < self.max_retries and retry_after <= self.max_retry_after: + attempt += 1 + self._sleep(retry_after) + continue + raise GusVoiceApiError(code, description, retry_after, method) + + # ── receiving updates ───────────────────────────────────────────────────────────────────────────────────────── + def run_polling(self, handler: Callable[[Dict[str, Any]], Any], **options: Any) -> None: + """Long polling loop (`getUpdates`) — see `gusvoice_bot.polling.run_polling` for the options.""" + from .polling import run_polling + + run_polling(self, handler, **options) + + +class AsyncClient: + """`asyncio` wrapper over `Client`: every method runs in a worker thread (`asyncio.to_thread`). + + It is threads, not native async I/O — fine for a bot, no extra dependencies. `await bot.send_message(chat, "hi")`. + """ + + def __init__(self, token: str, base_url: str, **options: Any) -> None: + self.sync = Client(token, base_url, **options) + + def __getattr__(self, name: str) -> Any: + attr = getattr(self.sync, name) + if not callable(attr): + return attr + + async def run(*args: Any, **kwargs: Any) -> Any: + return await asyncio.to_thread(attr, *args, **kwargs) + + run.__name__ = name + run.__doc__ = getattr(attr, "__doc__", None) + return run diff --git a/vendor/gusvoice_bot/errors.py b/vendor/gusvoice_bot/errors.py new file mode 100644 index 0000000..b620b07 --- /dev/null +++ b/vendor/gusvoice_bot/errors.py @@ -0,0 +1,24 @@ +# SPDX-License-Identifier: MIT +"""Errors of the GusVoice Bot API client.""" +from __future__ import annotations + +from typing import Optional + + +class GusVoiceError(Exception): + """Base class of every error raised by this package.""" + + +class GusVoiceApiError(GusVoiceError): + """The server answered `{"ok": false, ...}` (or a non-JSON error page). + + `code` is `error_code` (or the HTTP status), `description` is the server text, `retry_after` (seconds) comes with 429. + """ + + def __init__(self, code: int, description: str, retry_after: Optional[int] = None, method: Optional[str] = None) -> None: + self.code = code + self.description = description + self.retry_after = retry_after + self.method = method + where = f"{method}: " if method else "" + super().__init__(f"{where}{code} {description}") diff --git a/vendor/gusvoice_bot/keyboard.py b/vendor/gusvoice_bot/keyboard.py new file mode 100644 index 0000000..cb2fd45 --- /dev/null +++ b/vendor/gusvoice_bot/keyboard.py @@ -0,0 +1,81 @@ +# SPDX-License-Identifier: MIT +"""Buttons under a message: `InlineKeyboard().text("Status", "st").row().url("Site", "https://example.com")`. + +Pass the keyboard as `reply_markup` as is. The server limits are checked when a button is added (the same as the +TypeScript SDK `packages/bot-sdk/src/keyboard.ts`): `callback_data` is 1-64 BYTES of UTF-8 (not characters), at most +8 buttons in a row and 100 in total, button text 1-64 characters, `url` only http(s) without credentials. +""" +from __future__ import annotations + +from typing import Any, Dict, Iterable, List, Optional +from urllib.parse import urlsplit + +CALLBACK_DATA_MAX_BYTES = 64 +KEYBOARD_ROW_BUTTONS_MAX = 8 +KEYBOARD_BUTTONS_MAX = 100 +KEYBOARD_BUTTON_TEXT_MAX = 64 + + +def _is_http_url(url: str) -> bool: + try: + u = urlsplit(url) + return u.scheme in ("http", "https") and bool(u.hostname) and u.username is None and u.password is None + except ValueError: + return False + + +class InlineKeyboard: + """Builder of `InlineKeyboardMarkup`. Empty rows are dropped when the markup is sent.""" + + def __init__(self, rows: Optional[Iterable[Iterable[Dict[str, Any]]]] = None) -> None: + self.inline_keyboard: List[List[Dict[str, Any]]] = [[]] + if rows is not None: + self.inline_keyboard = [] + for r in rows: + self.row() + for b in r: + self._add(dict(b)) + + def text(self, label: str, data: str) -> "InlineKeyboard": + """A button that sends the bot a `callback_query` with `data` (the person who presses it sees it: no secrets).""" + if not isinstance(data, str) or not data or len(data.encode("utf-8")) > CALLBACK_DATA_MAX_BYTES: + raise ValueError(f"InlineKeyboard: callback_data {data!r} must be 1-{CALLBACK_DATA_MAX_BYTES} bytes of UTF-8") + return self._add({"text": label, "callback_data": data}) + + def url(self, label: str, url: str) -> "InlineKeyboard": + """A link button (http/https only, no login in the address; the client asks "Open the link?").""" + if not isinstance(url, str) or not _is_http_url(url): + raise ValueError(f"InlineKeyboard: url {url!r} must be http(s) without credentials") + return self._add({"text": label, "url": url}) + + def row(self) -> "InlineKeyboard": + """Next buttons go to a new row.""" + if not self.inline_keyboard or self.inline_keyboard[-1]: + self.inline_keyboard.append([]) + return self + + def to_dict(self) -> Dict[str, Any]: + """The markup without empty rows (this is what goes to the server).""" + return {"inline_keyboard": [[dict(b) for b in r] for r in self.inline_keyboard if r]} + + def _add(self, b: Dict[str, Any]) -> "InlineKeyboard": + label = b.get("text") + if not isinstance(label, str) or not label.strip() or len(label.strip()) > KEYBOARD_BUTTON_TEXT_MAX: + raise ValueError(f"InlineKeyboard: button text must be 1-{KEYBOARD_BUTTON_TEXT_MAX} characters") + if ("callback_data" in b) == ("url" in b): + raise ValueError("InlineKeyboard: a button needs exactly one of callback_data and url") + if "callback_data" in b: + d = b["callback_data"] + if not isinstance(d, str) or not d or len(d.encode("utf-8")) > CALLBACK_DATA_MAX_BYTES: + raise ValueError(f"InlineKeyboard: callback_data {d!r} must be 1-{CALLBACK_DATA_MAX_BYTES} bytes of UTF-8") + elif not isinstance(b["url"], str) or not _is_http_url(b["url"]): + raise ValueError(f"InlineKeyboard: url {b['url']!r} must be http(s) without credentials") + if sum(len(r) for r in self.inline_keyboard) >= KEYBOARD_BUTTONS_MAX: + raise ValueError(f"InlineKeyboard: at most {KEYBOARD_BUTTONS_MAX} buttons") + if not self.inline_keyboard: + self.inline_keyboard.append([]) + last = self.inline_keyboard[-1] + if len(last) >= KEYBOARD_ROW_BUTTONS_MAX: + raise ValueError(f"InlineKeyboard: at most {KEYBOARD_ROW_BUTTONS_MAX} buttons in a row - call row()") + last.append(b) + return self diff --git a/vendor/gusvoice_bot/polling.py b/vendor/gusvoice_bot/polling.py new file mode 100644 index 0000000..da03e93 --- /dev/null +++ b/vendor/gusvoice_bot/polling.py @@ -0,0 +1,103 @@ +# SPDX-License-Identifier: MIT +"""Long polling loop: `getUpdates` with `offset`, one update at a time, in order. + +The offset moves past an update only AFTER the handler has returned for it, so an update in flight when the process +dies is delivered again. A handler exception goes to `on_error(update, exc)` (default: print the traceback) and the +update counts as handled — otherwise one bad update would come back forever; make `on_error` re-raise to stop the loop +without confirming that update instead. Fatal answers (401 — token revoked or bot disabled, 403 — bots are off on the +instance, 404, 409 — a webhook is set or another copy of the bot is polling) raise `GusVoiceApiError`; everything else +(network, 5xx, 400) is retried after a pause of 1 s growing to 30 s. Same rules as the TypeScript SDK `runPolling`. +""" +from __future__ import annotations + +import random +import sys +import threading +import traceback +from typing import TYPE_CHECKING, Any, Callable, Dict, Iterable, Optional + +from .errors import GusVoiceApiError + +if TYPE_CHECKING: + from .client import Client + +FATAL_CODES = frozenset({401, 403, 404, 409}) +RECONNECT_MIN_S = 1.0 +RECONNECT_MAX_S = 30.0 + + +def _default_on_error(update: Dict[str, Any], exc: BaseException) -> None: + print(f"gusvoice_bot: handler failed on update {update.get('update_id')}:", file=sys.stderr) + traceback.print_exception(type(exc), exc, exc.__traceback__, file=sys.stderr) + + +def _log(message: str) -> None: + print(f"gusvoice_bot: {message}", file=sys.stderr) + + +def run_polling( + client: "Client", + handler: Callable[[Dict[str, Any]], Any], + *, + timeout: int = 30, + limit: int = 100, + allowed_updates: Optional[Iterable[str]] = None, + on_error: Optional[Callable[[Dict[str, Any], BaseException], Any]] = None, + stop_event: Optional[threading.Event] = None, + log: Callable[[str], Any] = _log, +) -> None: + """Receive updates until `stop_event` is set (or Ctrl+C) and call `handler(update)` for each. + + `timeout` — seconds the server may hold one request (0-50). `getMe` is called first: a wrong token fails right away. + A long poll is not interrupted by `stop_event`: the loop stops when the current request returns (≤ `timeout` s). + On exit the handled updates are confirmed (`offset` without fetching anything new). + """ + on_error = on_error or _default_on_error + wait = stop_event.wait if stop_event is not None else client._sleep + poll_timeout = min(max(int(timeout), 0), 50) + allowed = list(allowed_updates) if allowed_updates is not None else None + client.get_me() + last = 0 # update_id of the last handled update + backoff = RECONNECT_MIN_S + try: + while stop_event is None or not stop_event.is_set(): + try: + updates = client.get_updates( + offset=last + 1 if last > 0 else None, limit=limit, timeout=poll_timeout, allowed_updates=allowed + ) + backoff = RECONNECT_MIN_S + except GusVoiceApiError as e: + if e.code in FATAL_CODES: + if e.code == 409: + log(f"stopped: {e.description}") + raise + log(f"getUpdates failed ({e}), retry in {round(backoff)} s") + wait(backoff * (0.8 + random.random() * 0.4)) + backoff = min(backoff * 2, RECONNECT_MAX_S) + continue + except Exception as e: # network, timeout, garbage — try again later + log(f"getUpdates failed ({e!r}), retry in {round(backoff)} s") + wait(backoff * (0.8 + random.random() * 0.4)) + backoff = min(backoff * 2, RECONNECT_MAX_S) + continue + for update in updates or []: + if stop_event is not None and stop_event.is_set(): + break + uid = update.get("update_id") if isinstance(update, dict) else None + if not isinstance(uid, (int, float)) or isinstance(uid, bool): + continue + uid = int(uid) + if uid <= last: + continue # repeat of an already handled update + try: + handler(update) + except Exception as exc: + on_error(update, exc) # re-raise here to stop without confirming this update + last = uid + finally: + if last > 0: + try: + # Confirm what was handled: an empty `allowed_updates` returns nothing but moves the offset. + client.get_updates(offset=last + 1, timeout=0, limit=1, allowed_updates=[]) + except Exception: + pass diff --git a/vendor/gusvoice_bot/py.typed b/vendor/gusvoice_bot/py.typed new file mode 100644 index 0000000..e69de29 diff --git a/vendor/gusvoice_bot/webhook.py b/vendor/gusvoice_bot/webhook.py new file mode 100644 index 0000000..90653a0 --- /dev/null +++ b/vendor/gusvoice_bot/webhook.py @@ -0,0 +1,133 @@ +# SPDX-License-Identifier: MIT +"""Receiving updates on your own address (`setWebhook`). + +Every delivery carries `X-GusVoice-Signature: t=,v1=` +(the server side is `webhookSignature` in `packages/backend/src/bots/botRules.ts`). Check it on the RAW body, before +parsing JSON: a re-serialized body does not keep the signature. +""" +from __future__ import annotations + +import hashlib +import hmac +import json +import time +from http.server import BaseHTTPRequestHandler +from typing import Any, Callable, Dict, List, Optional, Tuple, Type, Union + +SIGNATURE_HEADER = "X-GusVoice-Signature" +"""Header with the signature (header names are case-insensitive).""" +DEFAULT_TOLERANCE_S = 300 +"""Signature time window, seconds: an older (or far future) delivery is a replay and is refused.""" +MAX_BODY_BYTES = 8 << 20 +"""Largest delivery body `webhook_handler` accepts.""" + + +def parse_signature_header(header: Optional[str]) -> Optional[Tuple[int, List[str]]]: + """`(t, [v1, ...])` from the header, or `None` if it is missing or malformed (several `v1` are allowed).""" + if not header or not isinstance(header, str): + return None + t: Optional[int] = None + v1: List[str] = [] + for part in header.split(","): + k, sep, v = part.partition("=") + if not sep: + continue + k, v = k.strip(), v.strip() + if k == "t" and v.isascii() and v.isdigit() and 1 <= len(v) <= 12: + t = int(v) + elif k == "v1" and len(v) == 64 and all(c in "0123456789abcdefABCDEF" for c in v): + v1.append(v.lower()) + return (t, v1) if t is not None and v1 else None + + +def sign(secret: str, raw_body: Union[bytes, str], now: int) -> str: + """Header value the server would send for this body at `now` (for your tests).""" + body = raw_body.encode("utf-8") if isinstance(raw_body, str) else bytes(raw_body) + mac = hmac.new(secret.encode("utf-8"), str(now).encode("ascii") + b"." + body, hashlib.sha256).hexdigest() + return f"t={now},v1={mac}" + + +def verify_signature( + secret: str, + header: Optional[str], + raw_body: Union[bytes, str], + tolerance: int = DEFAULT_TOLERANCE_S, + now: Optional[float] = None, +) -> bool: + """`True` if the delivery is signed with `secret` and fresh (|now - t| <= `tolerance` seconds). + + Comparison runs in constant time (`hmac.compare_digest`). Never raises on bad input: it just returns `False`. + """ + if not secret or not isinstance(secret, str): + return False + sig = parse_signature_header(header) + if sig is None: + return False + t, candidates = sig + current = time.time() if now is None else now + if abs(current - t) > tolerance: + return False + body = raw_body.encode("utf-8") if isinstance(raw_body, str) else bytes(raw_body) + expected = hmac.new(secret.encode("utf-8"), str(t).encode("ascii") + b"." + body, hashlib.sha256).hexdigest() + ok = False + for v in candidates: + ok = hmac.compare_digest(expected, v) or ok + return ok + + +def webhook_handler( + secret: str, + on_update: Callable[[Dict[str, Any]], Any], + path: str = "/", + tolerance: int = DEFAULT_TOLERANCE_S, +) -> Type[BaseHTTPRequestHandler]: + """Request handler class for `http.server` (use `ThreadingHTTPServer`). + + `POST `: wrong signature -> 401, body larger than `MAX_BODY_BYTES` -> 413, not JSON -> 400; otherwise it + answers 200 right away and then calls `on_update(update)` for the update (or each one of a batch; keep the server fast: it waits for the answer). + Exceptions of `on_update` are printed and do not reach the server (the delivery is already acknowledged). + """ + + class _Handler(BaseHTTPRequestHandler): + def do_POST(self) -> None: # noqa: N802 (http.server naming) + if self.path.split("?", 1)[0] != path: + self._reply(404, b"not found") + return + try: + length = int(self.headers.get("Content-Length") or "0") + except ValueError: + length = -1 + if length < 0 or length > MAX_BODY_BYTES: + self._reply(413, b"too large") + return + raw = self.rfile.read(length) + if not verify_signature(secret, self.headers.get(SIGNATURE_HEADER), raw, tolerance): + self._reply(401, b"bad signature") + return + try: + update = json.loads(raw.decode("utf-8")) + except ValueError: + self._reply(400, b"bad json") + return + self._reply(200, b"ok") + # `setWebhook(batch=True)` delivers an array of updates in one request. + for u in update if isinstance(update, list) else [update]: + if not isinstance(u, dict): + continue + try: + on_update(u) + except Exception as e: # the delivery is acknowledged; report and go on + self.log_error("update handler failed: %r", e) + + def do_GET(self) -> None: # noqa: N802 + self._reply(405, b"POST only") + + def _reply(self, status: int, body: bytes) -> None: + self.send_response(status) + self.send_header("Content-Type", "text/plain; charset=utf-8") + self.send_header("Content-Length", str(len(body))) + self.end_headers() + self.wfile.write(body) + self.wfile.flush() + + return _Handler