Add standalone GusVoice AWG bot and Linux installer
This commit is contained in:
commit
38c6a1a682
23 files changed
+2924
No files matched your search
@@ -0,0 +1,2 @@
|
|||||||
|
* text=auto eol=lf
|
||||||
|
*.png binary
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
__pycache__/
|
||||||
|
*.py[cod]
|
||||||
|
.venv/
|
||||||
|
*.sqlite3*
|
||||||
|
*.log
|
||||||
|
config.json
|
||||||
|
.env
|
||||||
@@ -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.
|
||||||
@@ -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
|
||||||
Executable
+18
@@ -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
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
__version__ = "0.1.0"
|
||||||
+484
@@ -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: <code>{esc(uid)}</code>\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: <code>{esc(e.uid)}</code>\nДоступ: владелец.")
|
||||||
|
elif command == "/help":
|
||||||
|
await self.screen(e, "<b>AWG для Гуся</b>\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"<b>🪿 AWG для Гуся · {__version__}</b>\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"<b>👥 Клиенты: {len(rows)}</b>\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"<b>👤 {esc(name)}</b>\n{client_status(c)}\nIP: <code>{esc(c.get('ip', ''))}</code>\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"<b>Срок: {esc(arg)}</b>\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}: <b>{esc(arg)}</b>?", ["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"Создать <b>{esc(draft['name'])}</b>?\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 = "<b>🖥 Сервер AWG</b>\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, "<b>🩺 Диагностика</b>\n<pre>" + esc(text[-1800:]) + "</pre>")
|
||||||
|
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, "<b>🔔 Уведомления</b>\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"<b>AWG для Гуся {__version__}</b>\n"
|
||||||
|
"Отдельная служба: awg-gus-bot. Управление тем же awg2, что и в Telegram.\n"
|
||||||
|
"Обновление на сервере: <code>sudo awg-gus-bot update</code>\n"
|
||||||
|
"Проверка: <code>sudo awg-gus-bot check</code>\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"Срок нового клиента <b>{esc(text)}</b>:", [
|
||||||
|
[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"🔔 <b>{esc(key[1])}</b>: {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)
|
||||||
+215
@@ -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)
|
||||||
+213
@@ -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)
|
||||||
Executable
+191
@@ -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" <<EOF
|
||||||
|
[Unit]
|
||||||
|
Description=AWG Toolza bot for GusVoice
|
||||||
|
After=network-online.target
|
||||||
|
Wants=network-online.target
|
||||||
|
StartLimitIntervalSec=300
|
||||||
|
StartLimitBurst=5
|
||||||
|
|
||||||
|
[Service]
|
||||||
|
Type=simple
|
||||||
|
User=root
|
||||||
|
WorkingDirectory=$BASE/current
|
||||||
|
ExecStart=$PYTHON $BASE/current/run.py run --config $CONF
|
||||||
|
Environment=PYTHONUNBUFFERED=1
|
||||||
|
UMask=0077
|
||||||
|
Restart=on-failure
|
||||||
|
RestartSec=10
|
||||||
|
TimeoutStopSec=20
|
||||||
|
KillMode=control-group
|
||||||
|
|
||||||
|
[Install]
|
||||||
|
WantedBy=multi-user.target
|
||||||
|
EOF
|
||||||
|
ln -sfn "$release" "$BASE/.current-next"
|
||||||
|
mv -Tf "$BASE/.current-next" "$BASE/current"
|
||||||
|
install -m 755 "$release/awg-gus-bot" /usr/local/bin/awg-gus-bot
|
||||||
|
systemctl daemon-reload
|
||||||
|
systemctl enable "$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'
|
||||||
@@ -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.
|
||||||
@@ -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.
|
||||||
@@ -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()
|
||||||
@@ -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()
|
||||||
@@ -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()
|
||||||
Vendored
+50
@@ -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__",
|
||||||
|
]
|
||||||
Vendored
+502
@@ -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))
|
||||||
Vendored
+273
@@ -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>/api/bot/v1/<method>` with `Authorization: Bot <token>`; 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 `<base_url>/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
|
||||||
Vendored
+24
@@ -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}")
|
||||||
Vendored
+81
@@ -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
|
||||||
Vendored
+103
@@ -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
|
||||||
Vendored
Whitespace-only changes.
Vendored
+133
@@ -0,0 +1,133 @@
|
|||||||
|
# SPDX-License-Identifier: MIT
|
||||||
|
"""Receiving updates on your own address (`setWebhook`).
|
||||||
|
|
||||||
|
Every delivery carries `X-GusVoice-Signature: t=<unix seconds>,v1=<hex HMAC-SHA256(secret, t + "." + raw body)>`
|
||||||
|
(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 <path>`: 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
|
||||||
Reference in new issue
Block a user