Add standalone GusVoice AWG bot and Linux installer

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

No files matched your search

+2
View File
@@ -0,0 +1,2 @@
* text=auto eol=lf
*.png binary
+7
View File
@@ -0,0 +1,7 @@
__pycache__/
*.py[cod]
.venv/
*.sqlite3*
*.log
config.json
.env
+21
View File
@@ -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.
+150
View File
@@ -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
View File
@@ -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
+1
View File
@@ -0,0 +1 @@
__version__ = "0.1.0"
+484
View File
@@ -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
View File
@@ -0,0 +1,215 @@
"""Local AWG API, private state, and GusVoice delivery. No shell commands from chat."""
from __future__ import annotations
import asyncio
import contextlib
import html
import json
import logging
import os
from pathlib import Path
import re
import signal
import sqlite3
import time
from gusvoice_bot import AsyncClient, GusVoiceApiError
log = logging.getLogger("awg-gus")
NAME = re.compile(r"[A-Za-z0-9_-]{1,32}\Z")
LIMIT = re.compile(r"(?:[1-9][0-9]{0,5}[MG]|off)\Z")
SPEED = re.compile(r"(?:[1-9][0-9]{0,5}|off)\Z")
EXPIRE = re.compile(r"\+[1-9][0-9]{0,3}[dh]\Z")
def esc(value):
return html.escape(str(value if value is not None else ""))
def redacted(value):
text = str(value)
text = re.sub(r"(?im)(PrivateKey|PresharedKey|BOT_TOKEN|password)\s*[:=]\s*\S+", r"\1=[скрыто]", text)
return re.sub(r"gvb_[A-Za-z0-9_-]+", "[токен скрыт]", text)
def size(value):
n = float(value or 0)
for unit in ("Б", "КиБ", "МиБ", "ГиБ", "ТиБ"):
if abs(n) < 1024 or unit == "ТиБ":
return f"{n:.1f} {unit}"
n /= 1024
class AwgError(RuntimeError):
pass
def validate_command(args):
"""Second allow-list immediately before a privileged subprocess starts."""
a = tuple(args)
if a in {("version",), ("status",), ("server", "info"), ("server", "restart"),
("clients", "list"), ("clients", "export"), ("diag", "status"),
("backup", "create"), ("backup", "list"), ("tunnels", "status")}:
return
if len(a) >= 3 and a[0] == "client" and NAME.fullmatch(a[2]):
op = a[1]
if len(a) == 3 and op in {"conf", "del", "reissue", "unexpire", "limit-reset"}:
return
if len(a) == 4:
if op == "rename" and NAME.fullmatch(a[3]):
return
if op == "expire" and EXPIRE.fullmatch(a[3]):
return
if op == "speed" and SPEED.fullmatch(a[3]):
return
if len(a) == 5 and op == "limit" and LIMIT.fullmatch(a[3]) and a[4] in {"month", "total"}:
return
if op == "add" and len(a) in {4, 5} and a[3] == "mimicry=server":
if len(a) == 4 or (a[4].startswith("expire=") and EXPIRE.fullmatch(a[4][7:])):
return
if len(a) == 4 and a[:2] == ("traffic", "daily") and (a[2] == "all" or NAME.fullmatch(a[2])) and a[3] == "7":
return
raise ValueError("Недопустимая операция awg2")
class Awg:
def __init__(self, binary="/usr/local/bin/awg2"):
self.binary = binary
self.lock = asyncio.Lock()
async def call(self, *args, timeout=120):
validate_command(args)
# Upstream awg2 also uses a flock shared with the Telegram bot.
async with self.lock:
try:
proc = await asyncio.create_subprocess_exec(
self.binary, "api", *args, stdin=asyncio.subprocess.DEVNULL,
stdout=asyncio.subprocess.PIPE, stderr=asyncio.subprocess.PIPE,
start_new_session=(os.name == "posix"))
except OSError as e:
raise AwgError("Не удалось запустить awg2. Проверьте установку и путь к программе.") from e
try:
out, _ = await asyncio.wait_for(proc.communicate(), timeout)
except (asyncio.TimeoutError, asyncio.CancelledError) as e:
with contextlib.suppress(ProcessLookupError):
if os.name == "posix":
os.killpg(proc.pid, signal.SIGTERM)
else:
proc.kill()
try:
await asyncio.wait_for(proc.communicate(), 5)
except asyncio.TimeoutError:
with contextlib.suppress(ProcessLookupError):
if os.name == "posix":
os.killpg(proc.pid, signal.SIGKILL)
else:
proc.kill()
await proc.communicate()
if isinstance(e, asyncio.CancelledError):
raise
raise AwgError("awg2 не ответил вовремя. Проверьте результат в меню: операция могла выполниться.") from e
try:
result = json.loads(out.decode("utf-8", errors="replace").strip().splitlines()[-1])
if not isinstance(result, dict) or not isinstance(result.get("ok"), bool):
raise ValueError
except (ValueError, IndexError) as e:
raise AwgError("awg2 вернул ответ без JSON API. Нужна версия AWG Toolza с командой awg2 api.") from e
if not result["ok"] or proc.returncode:
raise AwgError(redacted(result.get("error") or "awg2 завершился с ошибкой")[:500])
return result
class State:
def __init__(self, path, identity):
path = Path(path)
path.parent.mkdir(parents=True, exist_ok=True, mode=0o700)
self.db = sqlite3.connect(path)
if os.name == "posix":
path.chmod(0o600)
self.db.executescript("""
CREATE TABLE IF NOT EXISTS kv(key TEXT PRIMARY KEY, value TEXT NOT NULL);
CREATE TABLE IF NOT EXISTS chats(uid TEXT PRIMARY KEY, chat TEXT NOT NULL);
CREATE TABLE IF NOT EXISTS watches(uid TEXT, name TEXT, PRIMARY KEY(uid,name));
CREATE TABLE IF NOT EXISTS pending(id INTEGER PRIMARY KEY, uid TEXT, chat TEXT);
""")
old = self.get("identity")
if old is not None and old != identity:
self.db.close()
raise ValueError("Состояние принадлежит другому боту/инстансу. Укажите отдельный state_dir.")
self.set("identity", identity)
def get(self, key, default=None):
row = self.db.execute("SELECT value FROM kv WHERE key=?", (key,)).fetchone()
return json.loads(row[0]) if row else default
def set(self, key, value):
with self.db:
self.db.execute("INSERT OR REPLACE INTO kv VALUES (?,?)", (key, json.dumps(value)))
def claim(self, update_id, uid=None, chat=None):
if update_id < self.get("offset", 0):
return False
# Persist BEFORE any mutation. Interrupted operations are reported, never replayed.
with self.db:
self.db.execute("INSERT OR REPLACE INTO kv VALUES ('offset',?)", (json.dumps(update_id + 1),))
if uid and chat:
self.db.execute("INSERT INTO pending VALUES (?,?,?)", (update_id, uid, chat))
self.db.execute("INSERT OR REPLACE INTO chats VALUES (?,?)", (uid, chat))
return True
def finish(self, update_id):
with self.db:
self.db.execute("DELETE FROM pending WHERE id=?", (update_id,))
def watch(self, uid, name):
with self.db:
if self.watched(uid, name):
self.db.execute("DELETE FROM watches WHERE uid=? AND name=?", (uid, name))
return False
self.db.execute("INSERT INTO watches VALUES (?,?)", (uid, name))
return True
def watched(self, uid, name):
return bool(self.db.execute("SELECT 1 FROM watches WHERE uid=? AND name=?", (uid, name)).fetchone())
class Delivery:
"""Throttle sends/edits together; acknowledge callbacks independently."""
def __init__(self, client):
self.client = client
self.locks = {}
self.next_send = {}
async def call(self, method, **params):
chat = params.get("chat_id")
if chat and method in {"send_message", "edit_message_text", "edit_message_reply_markup"}:
lock = self.locks.setdefault(chat, asyncio.Lock())
async with lock:
await asyncio.sleep(max(0, self.next_send.get(chat, 0) - time.monotonic()))
try:
return await getattr(self.client, method)(**params)
finally:
self.next_send[chat] = time.monotonic() + 1.1
return await getattr(self.client, method)(**params)
async def ack(self, callback_id, text=None):
try:
await self.call("answer_callback_query", callback_query_id=callback_id, text=text)
except GusVoiceApiError as e:
if e.code != 400:
raise
async def show(self, chat, text, buttons=None, message=None):
params = dict(chat_id=chat, text=text, parse_mode="HTML",
reply_markup={"inline_keyboard": buttons} if buttons else None)
if message:
try:
return await self.call("edit_message_text", message_id=message, **params)
except GusVoiceApiError as e:
if e.code != 400:
raise
return await self.call("send_message", **params)
def client_for(config):
return AsyncClient(config["token"], config["url"], discover=False, max_retries=3)
+213
View File
@@ -0,0 +1,213 @@
from __future__ import annotations
import argparse
import asyncio
import contextlib
import getpass
import hmac
import json
import logging
import os
from pathlib import Path
import secrets
import signal
import sys
import time
from urllib.parse import urlsplit
import uuid
from gusvoice_bot import Client, GusVoiceApiError
from . import __version__
from .bot import Bot
from .core import Awg, Delivery, State, client_for
DEFAULT_CONFIG = "/etc/awg-gus-bot.json"
DEFAULT_STATE = "/var/lib/awg-gus-bot"
def validate_url(url):
u = urlsplit(url.strip())
if u.scheme != "https" or not u.hostname or u.username or u.password or u.query or u.fragment:
raise ValueError("Укажите HTTPS-адрес Гуся без логина, пароля и параметров.")
if u.path.rstrip("/") not in {"", "/api"}:
raise ValueError("Нужен адрес сайта/сервера API, без /api/bot/v1.")
return url.strip().rstrip("/").removesuffix("/api")
def validate_config(d):
validate_url(d["url"])
if not isinstance(d.get("token"), str) or not d["token"].startswith("gvb_"):
raise ValueError("Требуется токен бота Гуся gvb_…")
if not isinstance(d.get("owners"), list) or not d["owners"]:
raise ValueError("Не задан владелец")
for owner in d["owners"]:
uuid.UUID(owner)
uuid.UUID(d["bot_id"])
for key in ("awg2", "state_dir"):
if not Path(d[key]).is_absolute():
raise ValueError(f"{key}: нужен абсолютный путь")
return d
def load_config(path):
with open(path, encoding="utf-8") as f:
return validate_config(json.load(f))
def save_config(path, data):
path = Path(path)
path.parent.mkdir(parents=True, exist_ok=True)
tmp = path.with_name(path.name + ".new")
fd = os.open(tmp, os.O_WRONLY | os.O_CREAT | os.O_TRUNC, 0o600)
try:
with os.fdopen(fd, "w", encoding="utf-8") as f:
json.dump(data, f, ensure_ascii=False, indent=2)
f.write("\n")
f.flush()
os.fsync(f.fileno())
if os.name == "posix":
tmp.chmod(0o600)
os.replace(tmp, path)
finally:
with contextlib.suppress(FileNotFoundError):
tmp.unlink()
def check_features(me):
uuid.UUID(me["id"])
# HTML rendering was introduced with API 3; do not silently send HTML to an older server.
if int(me.get("api_version") or 0) < 3:
raise ValueError("Нужен GusVoice Bot API 3 или новее (кнопки, команды и HTML). Обновите Гуся.")
features = set(me.get("features") or [])
if not {"keyboards", "commands", "parse_mode"}.issubset(features):
raise ValueError("Инстанс не объявляет необходимые возможности: keyboards, commands, parse_mode.")
def pair(client, code, *, duration=600, now=time.monotonic):
deadline = now() + duration
offset = 0
while now() < deadline:
updates = client.get_updates(offset=offset, timeout=10, allowed_updates=["message"])
for u in updates:
offset = max(offset, int(u["update_id"]) + 1)
msg = u.get("message") or {}
chat, author = msg.get("chat") or {}, msg.get("from") or {}
text = (msg.get("text") or "").strip()
if chat.get("type") != "dm" or author.get("bot"):
continue
if not hmac.compare_digest(text.encode("utf-8"), ("/start " + code).encode("utf-8")):
continue
try:
uuid.UUID(author["id"])
uuid.UUID(chat["id"])
except (KeyError, ValueError, TypeError, AttributeError):
continue
client.get_updates(offset=offset, timeout=0, allowed_updates=[])
return author["id"], chat["id"], offset
raise ValueError("Время привязки истекло. Запустите настройку ещё раз.")
def configure(args):
if not sys.stdin.isatty():
raise ValueError("Настройка требует терминала. Скачайте install.sh в файл и запустите sudo bash install.sh.")
print("AWG для Гуся: настройка отдельного бота. Mini App не используется.")
url = validate_url(input("HTTPS-адрес Гуся: "))
token = getpass.getpass("Токен нового бота gvb_… (ввод скрыт): ").strip()
if not token.startswith("gvb_"):
raise ValueError("Токен должен начинаться с gvb_")
client = Client(token, url)
me = client.get_me()
check_features(me)
if client.get_webhook_info().get("url"):
raise ValueError("У этого бота настроен webhook. Создайте отдельного бота для AWG.")
resolved = validate_url(client.api_url())
code = secrets.token_urlsafe(18)
print(f"\nОткройте личную беседу с @{me.get('username', '')} в Гусе.")
print("Отправьте следующее сообщение (код действует 10 минут):\n")
print("/start " + code)
print("\nЖду сообщение…", flush=True)
owner, chat, offset = pair(client, code)
d = {"url": resolved, "token": token, "owners": [owner], "bot_id": me["id"],
"awg2": args.awg2, "state_dir": args.state_dir}
validate_config(d)
state = State(Path(args.state_dir) / "state.sqlite3", resolved + "|" + me["id"])
state.set("offset", offset)
with state.db:
state.db.execute("INSERT OR REPLACE INTO chats VALUES (?,?)", (owner, chat))
state.db.close()
save_config(args.config, d)
# Pairing is complete at this point; a delivery failure must not discard the valid config.
with contextlib.suppress(Exception):
client.send_message(chat, "✅ Аккаунт привязан. После завершения установки отправьте /menu.")
print("Владелец привязан. Конфигурация сохранена.")
async def check(args):
d = load_config(args.config)
me = await client_for(d).get_me()
check_features(me)
if me["id"] != d["bot_id"]:
raise ValueError("Токен принадлежит другому боту. Повторите настройку с отдельным state_dir.")
if (await client_for(d).get_webhook_info()).get("url"):
raise ValueError("Задан webhook: нужен отдельный бот для polling.")
await Awg(d["awg2"]).call("version", timeout=30)
print(f"OK: AWG для Гуся {__version__}; GusVoice API {me['api_version']}; awg2 api отвечает.")
async def run(args):
d = load_config(args.config)
client = client_for(d)
me = await client.get_me()
check_features(me)
if me["id"] != d["bot_id"]:
raise ValueError("Неверная привязка бота. Выполните setup.")
state = State(Path(d["state_dir"]) / "state.sqlite3", d["url"] + "|" + d["bot_id"])
# flock prevents accidental second local process; the API prevents remote duplicate consumers.
import fcntl
lock = open(Path(d["state_dir"]) / "process.lock", "a")
try:
fcntl.flock(lock, fcntl.LOCK_EX | fcntl.LOCK_NB)
except BlockingIOError as e:
raise ValueError("Этот бот уже запущен") from e
bot = Bot(d, Awg(d["awg2"]), Delivery(client), state)
task = asyncio.create_task(bot.run())
loop = asyncio.get_running_loop()
for sig in (signal.SIGTERM, signal.SIGINT):
loop.add_signal_handler(sig, task.cancel)
logging.info("AWG Gus bot %s started", __version__)
try:
await task
except asyncio.CancelledError:
pass
finally:
state.db.close()
lock.close()
def main():
parser = argparse.ArgumentParser(description="AWG Toolza bot for GusVoice")
parser.add_argument("command", choices=["run", "setup", "check"])
parser.add_argument("--config", default=DEFAULT_CONFIG)
parser.add_argument("--awg2", default="/usr/local/bin/awg2")
parser.add_argument("--state-dir", default=DEFAULT_STATE)
args = parser.parse_args()
os.umask(0o077)
logging.basicConfig(level=logging.INFO, format="%(asctime)s %(levelname)s %(message)s")
try:
if args.command == "setup":
configure(args)
else:
asyncio.run(check(args) if args.command == "check" else run(args))
except GusVoiceApiError as e:
print(f"Ошибка GusVoice API: HTTP {e.code}. Проверьте адрес, токен и включение ботов.", file=sys.stderr)
sys.exit(1)
except (ValueError, FileNotFoundError, KeyError) as e:
# No raw request URLs or token-bearing exception repr in logs.
print(str(e) if isinstance(e, ValueError) else "Не найден или неверно заполнен конфигурационный файл.", file=sys.stderr)
sys.exit(1)
except KeyboardInterrupt:
sys.exit(130)
except Exception as e:
print(f"Ошибка: {type(e).__name__}. Проверьте сеть, сертификаты и доступность awg2.", file=sys.stderr)
sys.exit(1)
Executable
+191
View File
@@ -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'
+21
View File
@@ -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.
+21
View File
@@ -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.
Executable
+9
View File
@@ -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()
+290
View File
@@ -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()
+115
View File
@@ -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()
+50
View File
@@ -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__",
]
+502
View File
@@ -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))
+273
View File
@@ -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
+24
View File
@@ -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}")
+81
View File
@@ -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
+103
View File
@@ -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
View File
Whitespace-only changes.
+133
View File
@@ -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