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