Add standalone GusVoice AWG bot and Linux installer

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

No files matched your search

+50
View File
@@ -0,0 +1,50 @@
# SPDX-License-Identifier: MIT
"""GusVoice Bot API for Python — a thin client on the standard library only.
from gusvoice_bot import Client
bot = Client(os.environ["BOT_TOKEN"], "https://voice.example.com")
bot.send_message(chat_id, "Hello")
Long polling (`run_polling`), webhook deliveries with signature check (`verify_signature`, `webhook_handler`), inline
buttons (`InlineKeyboard`), retry on 429. No WebSocket and no voice: for those use the TypeScript SDK `@gusvoice/bot`.
"""
from ._generated import API_VERSION, METHODS, SCHEMA_VERSION, UPDATE_TYPES
from .client import AsyncClient, Client, InputFile, normalize_base
from .errors import GusVoiceApiError, GusVoiceError
from .keyboard import (
CALLBACK_DATA_MAX_BYTES,
KEYBOARD_BUTTON_TEXT_MAX,
KEYBOARD_BUTTONS_MAX,
KEYBOARD_ROW_BUTTONS_MAX,
InlineKeyboard,
)
from .polling import run_polling
from .webhook import DEFAULT_TOLERANCE_S, SIGNATURE_HEADER, parse_signature_header, sign, verify_signature, webhook_handler
__version__ = "0.1.0"
__all__ = [
"API_VERSION",
"METHODS",
"SCHEMA_VERSION",
"UPDATE_TYPES",
"AsyncClient",
"Client",
"InputFile",
"normalize_base",
"GusVoiceApiError",
"GusVoiceError",
"CALLBACK_DATA_MAX_BYTES",
"KEYBOARD_BUTTON_TEXT_MAX",
"KEYBOARD_BUTTONS_MAX",
"KEYBOARD_ROW_BUTTONS_MAX",
"InlineKeyboard",
"run_polling",
"DEFAULT_TOLERANCE_S",
"SIGNATURE_HEADER",
"parse_signature_header",
"sign",
"verify_signature",
"webhook_handler",
"__version__",
]
+502
View File
@@ -0,0 +1,502 @@
# Generated by `node scripts/gen-bot-api.mjs` from docs/bot-api/openapi.json — DO NOT EDIT.
# CI (`gen-bot-api.mjs --check`) fails when this file is out of date.
# SPDX-License-Identifier: MIT
"""Types and method wrappers of the GusVoice Bot API, generated from the OpenAPI document."""
from __future__ import annotations
from typing import Any, Dict, List, Literal, Optional, Sequence, TypedDict, Union
SCHEMA_VERSION = "1.4.0"
"""Version of the OpenAPI document this module was generated from."""
API_VERSION = 4
"""Bot API level (`getMe.api_version`) the wrappers below know about."""
METHODS: Dict[str, int] = {
"getMe": 1,
"getUpdates": 1,
"setWebhook": 1,
"deleteWebhook": 1,
"getWebhookInfo": 1,
"sendMessage": 1,
"editMessageText": 1,
"deleteMessage": 1,
"sendChatAction": 1,
"getChat": 1,
"editMessageReplyMarkup": 2,
"answerCallbackQuery": 2,
"setMyCommands": 2,
"getMyCommands": 2,
"deleteMyCommands": 2,
"setMessageReaction": 3,
"pinChatMessage": 3,
"unpinChatMessage": 3,
"getChatHistory": 3,
"getChatMember": 3,
"getChatMemberCount": 3,
"getChatMembers": 3,
"getMyServers": 3,
"getServer": 3,
"addChatMemberRole": 3,
"removeChatMemberRole": 3,
"banChatMember": 3,
"unbanChatMember": 3,
"kickChatMember": 3,
"leaveChat": 3,
"joinVoice": 4,
"leaveVoice": 4,
"getVoiceChatMembers": 4,
}
"""Every Bot API method → the API level it appeared in (`getMe.api_version`)."""
UPDATE_TYPES = (
"message",
"edited_message",
"message_deleted",
"my_chat_member",
"callback_query",
"message_reaction",
"chat_member",
"voice_state",
)
"""Update kinds (one key besides `update_id` in every `Update`); unknown kinds may appear — ignore them."""
# required: url, file_name, mime_type, file_size
Attachment = TypedDict("Attachment", {
"url": "str",
"file_name": "str",
"mime_type": "str",
"file_size": "float",
}, total=False)
# required: command, description
BotCommand = TypedDict("BotCommand", {
"command": "str",
"description": "str",
}, total=False)
# Supported `type`: default, all_private_chats, all_group_chats; other types are rejected with 400.
# required: type
BotCommandScope = TypedDict("BotCommandScope", {
"type": "str",
}, total=False)
# required: id, username, display_name, bot, description, about, public
BotUser = TypedDict("BotUser", {
"id": "str",
"username": "str",
"display_name": "str",
"bot": "bool",
"instance_domain": "str",
"description": "str",
"about": "str",
"public": "bool",
"api_version": "float",
"features": "List[str]",
"dropped_update_count": "float",
}, total=False)
# required: id, from, message, data, date
CallbackQuery = TypedDict("CallbackQuery", {
"id": "str",
"from": "User",
"message": "Message",
"data": "str",
"date": "float",
}, total=False)
# required: id, type
Chat = TypedDict("Chat", {
"id": "str",
"type": "Literal[\"dm\", \"channel\", \"server\", \"voice\"]",
"server_id": "str",
"title": "str",
}, total=False)
# required: id, type
ChatFullInfo = TypedDict("ChatFullInfo", {
"id": "str",
"type": "Literal[\"dm\", \"channel\", \"server\", \"voice\"]",
"server_id": "str",
"title": "str",
"user": "User",
"member_count": "float",
}, total=False)
# required: user, status, role_ids
ChatMember = TypedDict("ChatMember", {
"user": "User",
"status": "Literal[\"creator\", \"member\", \"left\", \"kicked\"]",
"nickname": "str",
"role_ids": "List[str]",
"joined_date": "float",
}, total=False)
# required: chat, from, date, old_status, new_status
ChatMemberUpdated = TypedDict("ChatMemberUpdated", {
"chat": "Chat",
"from": "User",
"date": "float",
"old_status": "Literal[\"creator\", \"member\", \"left\", \"kicked\"]",
"new_status": "Literal[\"creator\", \"member\", \"left\", \"kicked\"]",
}, total=False)
# required: members
ChatMembersPage = TypedDict("ChatMembersPage", {
"members": "List[ChatMember]",
"next_offset": "str",
}, total=False)
# required: ok, error_code, description
Error = TypedDict("Error", {
"ok": "bool",
"error_code": "float",
"description": "str",
"retry_after": "float",
}, total=False)
# Exactly one of `callback_data` (1–64 BYTES of UTF-8, not characters) and `url` (http(s) only, no credentials in the address).
# required: text
InlineKeyboardButton = TypedDict("InlineKeyboardButton", {
"text": "str",
"callback_data": "str",
"url": "str",
}, total=False)
# At least one row, 1–8 buttons in a row, at most 100 buttons in total.
# required: inline_keyboard
InlineKeyboardMarkup = TypedDict("InlineKeyboardMarkup", {
"inline_keyboard": "List[List[InlineKeyboardButton]]",
}, total=False)
# required: —
InputEmbed = TypedDict("InputEmbed", {
"title": "Optional[str]",
"description": "Optional[str]",
"url": "Optional[str]",
"color": "Optional[int]",
"timestamp": "Optional[str]",
"fields": "Optional[List[Dict[str, Any]]]",
"author": "Optional[Dict[str, Any]]",
"footer": "Optional[Dict[str, Any]]",
"image": "Optional[Dict[str, Any]]",
"thumbnail": "Optional[Dict[str, Any]]",
}, total=False)
# required: message_id, chat, from, date, text
Message = TypedDict("Message", {
"message_id": "str",
"chat": "Chat",
"from": "User",
"date": "float",
"edit_date": "float",
"text": "str",
"reply_to_message_id": "str",
"attachments": "List[Attachment]",
"embeds": "List[MessageEmbed]",
"reply_markup": "InlineKeyboardMarkup",
}, total=False)
# required: message_id, chat, date
MessageDeleted = TypedDict("MessageDeleted", {
"message_id": "str",
"chat": "Chat",
"date": "float",
}, total=False)
# required: —
MessageEmbed = TypedDict("MessageEmbed", {
"title": "str",
"url": "str",
"description": "str",
"color": "float",
"timestamp": "str",
"author": "Dict[str, Any]",
"footer": "Dict[str, Any]",
"fields": "List[Dict[str, Any]]",
"image": "Dict[str, Any]",
"thumbnail": "Dict[str, Any]",
"imageLink": "str",
"thumbnailLink": "str",
}, total=False)
# required: chat, message_id, user, date, old_reaction, new_reaction
MessageReactionUpdated = TypedDict("MessageReactionUpdated", {
"chat": "Chat",
"message_id": "str",
"user": "User",
"date": "float",
"old_reaction": "List[ReactionType]",
"new_reaction": "List[ReactionType]",
}, total=False)
# required: type, emoji
ReactionType = TypedDict("ReactionType", {
"type": "Literal[\"emoji\"]",
"emoji": "str",
}, total=False)
# required: id, title
ServerBrief = TypedDict("ServerBrief", {
"id": "str",
"title": "str",
}, total=False)
# required: id, title, channels, roles
ServerInfo = TypedDict("ServerInfo", {
"id": "str",
"title": "str",
"channels": "List[Dict[str, Any]]",
"roles": "List[Dict[str, Any]]",
}, total=False)
# required: chat, from, user, date, old_status, new_status
ServerMemberUpdated = TypedDict("ServerMemberUpdated", {
"chat": "Chat",
"from": "User",
"user": "User",
"date": "float",
"old_status": "Literal[\"creator\", \"member\", \"left\", \"kicked\"]",
"new_status": "Literal[\"creator\", \"member\", \"left\", \"kicked\"]",
}, total=False)
# required: update_id
Update = TypedDict("Update", {
"update_id": "float",
"message": "Message",
"edited_message": "Message",
"message_deleted": "MessageDeleted",
"my_chat_member": "ChatMemberUpdated",
"callback_query": "CallbackQuery",
"message_reaction": "MessageReactionUpdated",
"chat_member": "ServerMemberUpdated",
"voice_state": "VoiceStateUpdated",
}, total=False)
# required: id, username, display_name, bot
User = TypedDict("User", {
"id": "str",
"username": "str",
"display_name": "str",
"bot": "bool",
"instance_domain": "str",
}, total=False)
# required: user, muted, deafened, server_muted, is_bot
VoiceChatMember = TypedDict("VoiceChatMember", {
"user": "User",
"muted": "bool",
"deafened": "bool",
"server_muted": "bool",
"is_bot": "bool",
}, total=False)
# required: url, token, room, chat, server_muted, expires_in, audio
VoiceJoinResult = TypedDict("VoiceJoinResult", {
"url": "str",
"token": "str",
"room": "str",
"chat": "Chat",
"server_muted": "bool",
"expires_in": "float",
"audio": "Dict[str, Any]",
}, total=False)
# required: chat, user, action, date
VoiceStateUpdated = TypedDict("VoiceStateUpdated", {
"chat": "Chat",
"user": "User",
"action": "Literal[\"joined\", \"left\"]",
"date": "float",
"move_to": "Chat",
}, total=False)
# required: url, pending_update_count, failures
WebhookInfo = TypedDict("WebhookInfo", {
"url": "str",
"pending_update_count": "float",
"failures": "float",
"last_error_date": "float",
"last_error_message": "str",
"disabled_date": "float",
"batch": "bool",
"dropped_update_count": "float",
}, total=False)
def _params(params: Dict[str, Any], extra: Dict[str, Any]) -> Dict[str, Any]:
out = {k: v for k, v in params.items() if v is not None}
out.update(extra)
return out
class GeneratedMethods:
"""One wrapper per Bot API method; `None` arguments are not sent. `**extra` passes parameters newer than this SDK."""
def call(self, method: str, params: Optional[Dict[str, Any]] = None, *, files: Optional[Sequence[Any]] = None) -> Any:
raise NotImplementedError
def get_me(self, **extra: Any) -> BotUser:
"""`getMe` (API 1+)."""
params: Dict[str, Any] = {}
return self.call("getMe", _params(params, extra))
def get_updates(self, *, offset: Optional[int] = None, limit: Optional[int] = None, timeout: Optional[int] = None, allowed_updates: Optional[List[str]] = None, **extra: Any) -> List[Update]:
"""`getUpdates` (API 1+). Long polling: the request is held up to `timeout` seconds (at most 50)."""
params: Dict[str, Any] = {"offset": offset, "limit": limit, "timeout": timeout, "allowed_updates": allowed_updates}
return self.call("getUpdates", _params(params, extra))
def set_webhook(self, url: str, secret_token: str, *, drop_pending_updates: Optional[bool] = None, batch: Optional[bool] = None, **extra: Any) -> bool:
"""`setWebhook` (API 1+)."""
params: Dict[str, Any] = {"url": url, "secret_token": secret_token, "drop_pending_updates": drop_pending_updates, "batch": batch}
return self.call("setWebhook", _params(params, extra))
def delete_webhook(self, *, drop_pending_updates: Optional[bool] = None, **extra: Any) -> bool:
"""`deleteWebhook` (API 1+)."""
params: Dict[str, Any] = {"drop_pending_updates": drop_pending_updates}
return self.call("deleteWebhook", _params(params, extra))
def get_webhook_info(self, **extra: Any) -> WebhookInfo:
"""`getWebhookInfo` (API 1+)."""
params: Dict[str, Any] = {}
return self.call("getWebhookInfo", _params(params, extra))
def send_message(self, chat_id: str, text: Optional[str] = None, *, reply_to_message_id: Optional[str] = None, embeds: Optional[List[InputEmbed]] = None, disable_notification: Optional[bool] = None, disable_link_preview: Optional[bool] = None, reply_markup: Optional[InlineKeyboardMarkup] = None, client_id: Optional[str] = None, parse_mode: Optional[str] = None, files: Optional[Sequence[Any]] = None, **extra: Any) -> Message:
"""`sendMessage` (API 1+)."""
params: Dict[str, Any] = {"chat_id": chat_id, "text": text, "reply_to_message_id": reply_to_message_id, "embeds": embeds, "disable_notification": disable_notification, "disable_link_preview": disable_link_preview, "reply_markup": reply_markup, "client_id": client_id, "parse_mode": parse_mode}
return self.call("sendMessage", _params(params, extra), files=files)
def edit_message_text(self, chat_id: str, message_id: str, text: str, *, reply_markup: Optional[InlineKeyboardMarkup] = None, parse_mode: Optional[str] = None, **extra: Any) -> Message:
"""`editMessageText` (API 1+)."""
params: Dict[str, Any] = {"chat_id": chat_id, "message_id": message_id, "text": text, "reply_markup": reply_markup, "parse_mode": parse_mode}
return self.call("editMessageText", _params(params, extra))
def delete_message(self, chat_id: str, message_id: str, **extra: Any) -> bool:
"""`deleteMessage` (API 1+)."""
params: Dict[str, Any] = {"chat_id": chat_id, "message_id": message_id}
return self.call("deleteMessage", _params(params, extra))
def send_chat_action(self, chat_id: str, action: Literal["typing"], **extra: Any) -> bool:
"""`sendChatAction` (API 1+)."""
params: Dict[str, Any] = {"chat_id": chat_id, "action": action}
return self.call("sendChatAction", _params(params, extra))
def get_chat(self, chat_id: str, **extra: Any) -> ChatFullInfo:
"""`getChat` (API 1+)."""
params: Dict[str, Any] = {"chat_id": chat_id}
return self.call("getChat", _params(params, extra))
def edit_message_reply_markup(self, chat_id: str, message_id: str, *, reply_markup: Optional[InlineKeyboardMarkup] = None, **extra: Any) -> Message:
"""`editMessageReplyMarkup` (API 2+)."""
params: Dict[str, Any] = {"chat_id": chat_id, "message_id": message_id, "reply_markup": reply_markup}
return self.call("editMessageReplyMarkup", _params(params, extra))
def answer_callback_query(self, callback_query_id: str, text: Optional[str] = None, *, show_alert: Optional[bool] = None, **extra: Any) -> bool:
"""`answerCallbackQuery` (API 2+)."""
params: Dict[str, Any] = {"callback_query_id": callback_query_id, "text": text, "show_alert": show_alert}
return self.call("answerCallbackQuery", _params(params, extra))
def set_my_commands(self, commands: List[BotCommand], *, scope: Optional[BotCommandScope] = None, **extra: Any) -> bool:
"""`setMyCommands` (API 2+)."""
params: Dict[str, Any] = {"commands": commands, "scope": scope}
return self.call("setMyCommands", _params(params, extra))
def get_my_commands(self, *, scope: Optional[BotCommandScope] = None, **extra: Any) -> List[BotCommand]:
"""`getMyCommands` (API 2+)."""
params: Dict[str, Any] = {"scope": scope}
return self.call("getMyCommands", _params(params, extra))
def delete_my_commands(self, *, scope: Optional[BotCommandScope] = None, **extra: Any) -> bool:
"""`deleteMyCommands` (API 2+)."""
params: Dict[str, Any] = {"scope": scope}
return self.call("deleteMyCommands", _params(params, extra))
def set_message_reaction(self, chat_id: str, message_id: str, *, reaction: Optional[List[ReactionType]] = None, **extra: Any) -> bool:
"""`setMessageReaction` (API 3+)."""
params: Dict[str, Any] = {"chat_id": chat_id, "message_id": message_id, "reaction": reaction}
return self.call("setMessageReaction", _params(params, extra))
def pin_chat_message(self, chat_id: str, message_id: str, *, disable_notification: Optional[bool] = None, **extra: Any) -> bool:
"""`pinChatMessage` (API 3+)."""
params: Dict[str, Any] = {"chat_id": chat_id, "message_id": message_id, "disable_notification": disable_notification}
return self.call("pinChatMessage", _params(params, extra))
def unpin_chat_message(self, chat_id: str, message_id: str, **extra: Any) -> bool:
"""`unpinChatMessage` (API 3+)."""
params: Dict[str, Any] = {"chat_id": chat_id, "message_id": message_id}
return self.call("unpinChatMessage", _params(params, extra))
def get_chat_history(self, chat_id: str, *, before_message_id: Optional[str] = None, limit: Optional[int] = None, **extra: Any) -> List[Message]:
"""`getChatHistory` (API 3+)."""
params: Dict[str, Any] = {"chat_id": chat_id, "before_message_id": before_message_id, "limit": limit}
return self.call("getChatHistory", _params(params, extra))
def get_chat_member(self, chat_id: str, user_id: str, **extra: Any) -> ChatMember:
"""`getChatMember` (API 3+)."""
params: Dict[str, Any] = {"chat_id": chat_id, "user_id": user_id}
return self.call("getChatMember", _params(params, extra))
def get_chat_member_count(self, chat_id: str, **extra: Any) -> int:
"""`getChatMemberCount` (API 3+)."""
params: Dict[str, Any] = {"chat_id": chat_id}
return self.call("getChatMemberCount", _params(params, extra))
def get_chat_members(self, chat_id: str, *, offset: Optional[str] = None, limit: Optional[int] = None, **extra: Any) -> ChatMembersPage:
"""`getChatMembers` (API 3+)."""
params: Dict[str, Any] = {"chat_id": chat_id, "offset": offset, "limit": limit}
return self.call("getChatMembers", _params(params, extra))
def get_my_servers(self, **extra: Any) -> List[ServerBrief]:
"""`getMyServers` (API 3+)."""
params: Dict[str, Any] = {}
return self.call("getMyServers", _params(params, extra))
def get_server(self, server_id: str, **extra: Any) -> ServerInfo:
"""`getServer` (API 3+)."""
params: Dict[str, Any] = {"server_id": server_id}
return self.call("getServer", _params(params, extra))
def add_chat_member_role(self, chat_id: str, user_id: str, role_id: str, **extra: Any) -> bool:
"""`addChatMemberRole` (API 3+)."""
params: Dict[str, Any] = {"chat_id": chat_id, "user_id": user_id, "role_id": role_id}
return self.call("addChatMemberRole", _params(params, extra))
def remove_chat_member_role(self, chat_id: str, user_id: str, role_id: str, **extra: Any) -> bool:
"""`removeChatMemberRole` (API 3+)."""
params: Dict[str, Any] = {"chat_id": chat_id, "user_id": user_id, "role_id": role_id}
return self.call("removeChatMemberRole", _params(params, extra))
def ban_chat_member(self, chat_id: str, user_id: str, *, reason: Optional[str] = None, **extra: Any) -> bool:
"""`banChatMember` (API 3+)."""
params: Dict[str, Any] = {"chat_id": chat_id, "user_id": user_id, "reason": reason}
return self.call("banChatMember", _params(params, extra))
def unban_chat_member(self, chat_id: str, user_id: str, **extra: Any) -> bool:
"""`unbanChatMember` (API 3+)."""
params: Dict[str, Any] = {"chat_id": chat_id, "user_id": user_id}
return self.call("unbanChatMember", _params(params, extra))
def kick_chat_member(self, chat_id: str, user_id: str, **extra: Any) -> bool:
"""`kickChatMember` (API 3+)."""
params: Dict[str, Any] = {"chat_id": chat_id, "user_id": user_id}
return self.call("kickChatMember", _params(params, extra))
def leave_chat(self, chat_id: str, **extra: Any) -> bool:
"""`leaveChat` (API 3+)."""
params: Dict[str, Any] = {"chat_id": chat_id}
return self.call("leaveChat", _params(params, extra))
def join_voice(self, chat_id: str, *, bitrate: Optional[int] = None, **extra: Any) -> VoiceJoinResult:
"""`joinVoice` (API 4+)."""
params: Dict[str, Any] = {"chat_id": chat_id, "bitrate": bitrate}
return self.call("joinVoice", _params(params, extra))
def leave_voice(self, *, chat_id: Optional[str] = None, **extra: Any) -> bool:
"""`leaveVoice` (API 4+)."""
params: Dict[str, Any] = {"chat_id": chat_id}
return self.call("leaveVoice", _params(params, extra))
def get_voice_chat_members(self, chat_id: str, **extra: Any) -> List[VoiceChatMember]:
"""`getVoiceChatMembers` (API 4+)."""
params: Dict[str, Any] = {"chat_id": chat_id}
return self.call("getVoiceChatMembers", _params(params, extra))
+273
View File
@@ -0,0 +1,273 @@
# SPDX-License-Identifier: MIT
"""HTTP client of the GusVoice Bot API on the standard library only (`urllib`).
Every method is `POST <api>/api/bot/v1/<method>` with `Authorization: Bot <token>`; the answer is
`{"ok": true, "result": ...}` or `{"ok": false, "error_code", "description", "retry_after"?}`. A 429 is retried after
`retry_after` (up to `max_retries` times); other errors raise `GusVoiceApiError`, network errors raise as they are
(`urllib.error.URLError`, `TimeoutError`). The same behaviour as the TypeScript SDK (`packages/bot-sdk/src/apiClient.ts`).
"""
from __future__ import annotations
import asyncio
import json
import mimetypes
import os
import socket
import threading
import time
import urllib.error
import urllib.request
import uuid
from typing import Any, BinaryIO, Callable, Dict, List, Optional, Sequence, Tuple, Union
from urllib.parse import urljoin
from ._generated import GeneratedMethods
from .errors import GusVoiceApiError
__all__ = ["Client", "AsyncClient", "InputFile", "normalize_base"]
DEFAULT_TIMEOUT_S = 30.0
"""Time limit of one request, seconds (long polling adds its own `timeout` on top)."""
DISCOVERY_TIMEOUT_S = 10.0
def normalize_base(url: str) -> str:
"""Without trailing `/` and without a trailing `/api`: the client adds the method path itself."""
u = str(url).strip().rstrip("/")
if u.endswith("/api"):
u = u[:-4]
if not (u.lower().startswith("http://") or u.lower().startswith("https://")):
raise ValueError(f'base_url must start with http:// or https:// (got "{url}")')
return u
class InputFile:
"""A file for `send_message(..., files=[...])`: a path, bytes or a binary file object."""
def __init__(self, source: Union[str, "os.PathLike[str]", bytes, bytearray, BinaryIO], filename: Optional[str] = None,
content_type: Optional[str] = None) -> None:
self.source = source
self.filename = filename
self.content_type = content_type
def read(self) -> Tuple[bytes, str, str]:
"""`(data, file name, content type)`."""
src = self.source
name = self.filename
if isinstance(src, (bytes, bytearray)):
data = bytes(src)
name = name or "file"
elif isinstance(src, (str, os.PathLike)):
path = os.fspath(src)
with open(path, "rb") as f:
data = f.read()
name = name or os.path.basename(path) or "file"
else:
data = src.read()
name = name or os.path.basename(str(getattr(src, "name", "") or "")) or "file"
ctype = self.content_type or mimetypes.guess_type(name)[0] or "application/octet-stream"
return data, name, ctype
def _json_default(o: Any) -> Any:
to_dict = getattr(o, "to_dict", None) # InlineKeyboard and similar builders
if callable(to_dict):
return to_dict()
raise TypeError(f"Object of type {type(o).__name__} is not JSON serializable")
def _dumps(value: Any) -> str:
return json.dumps(value, default=_json_default, ensure_ascii=False, separators=(",", ":"))
def _multipart(params: Dict[str, Any], files: Sequence[Any]) -> Tuple[bytes, str]:
"""`multipart/form-data`: parameters as fields (objects and arrays as JSON strings), files as `files[0]`, `files[1]` ..."""
boundary = f"gusvoice-{uuid.uuid4().hex}"
out: List[bytes] = []
def quote(s: str) -> str:
return s.replace("\\", "\\\\").replace('"', '\\"').replace("\r", " ").replace("\n", " ")
for k, v in params.items():
if v is None:
continue
if isinstance(v, bool):
text = "true" if v else "false"
elif isinstance(v, (dict, list, tuple)) or callable(getattr(v, "to_dict", None)):
text = _dumps(v)
else:
text = str(v)
out.append(f'--{boundary}\r\nContent-Disposition: form-data; name="{quote(k)}"\r\n\r\n'.encode("utf-8"))
out.append(text.encode("utf-8") + b"\r\n")
for i, f in enumerate(files):
data, name, ctype = (f if isinstance(f, InputFile) else InputFile(f)).read()
out.append(
f'--{boundary}\r\nContent-Disposition: form-data; name="files[{i}]"; filename="{quote(name)}"\r\n'
f"Content-Type: {ctype}\r\n\r\n".encode("utf-8")
)
out.append(data + b"\r\n")
out.append(f"--{boundary}--\r\n".encode("ascii"))
return b"".join(out), f"multipart/form-data; boundary={boundary}"
class _NoRedirect(urllib.request.HTTPRedirectHandler):
"""A redirect would carry the bot token to another address: answer it as an error instead."""
def redirect_request(self, req, fp, code, msg, headers, newurl): # type: ignore[no-untyped-def]
return None
class Client(GeneratedMethods):
"""Bot API client. `base_url` is the instance site (`https://voice.example.com`) or its API address.
The API address is found once through `<base_url>/config.json` (`apiUrl`), as the web client does; pass
`discover=False` to use `base_url` as is. All methods are generated from the OpenAPI document (`_generated.py`):
`send_message(chat_id, "text", reply_markup=...)`, `get_updates(offset=..., timeout=...)`, ...; any method by name —
`call("sendMessage", {...})`. Thread-safe: one client can be used from several threads.
"""
def __init__(
self,
token: str,
base_url: str,
*,
discover: bool = True,
max_retries: int = 3,
max_retry_after: float = 60,
timeout: float = DEFAULT_TIMEOUT_S,
opener: Optional[urllib.request.OpenerDirector] = None,
sleep: Callable[[float], Any] = time.sleep,
) -> None:
if not token or not isinstance(token, str):
raise ValueError("token is required (BotFather-style `gvb_...` from the GusVoice bot settings)")
self.token = token
self.base_url = normalize_base(base_url)
self.discover = discover
self.max_retries = max(0, int(max_retries))
self.max_retry_after = max_retry_after
self.timeout = timeout
self._opener = opener or urllib.request.build_opener(_NoRedirect)
self._sleep = sleep
self._api: Optional[str] = None
self._api_lock = threading.Lock()
# ── address ───────────────────────────────────────────────────────────────────────────────────────────────────
def api_url(self) -> str:
"""API address (`https://api.example.com`) after reading `config.json`; a network failure is not remembered."""
with self._api_lock:
if self._api is None:
self._api = self._discover_api()
return self._api
def _discover_api(self) -> str:
if not self.discover:
return self.base_url
req = urllib.request.Request(f"{self.base_url}/config.json", headers={"Accept": "application/json"})
try:
with self._opener.open(req, timeout=DISCOVERY_TIMEOUT_S) as r:
raw = r.read()
except urllib.error.HTTPError:
return self.base_url # no config.json here: base_url already is the API
except (TimeoutError, socket.timeout):
return self.base_url
except urllib.error.URLError as e:
if isinstance(e.reason, (TimeoutError, socket.timeout)):
return self.base_url
raise
try:
cfg = json.loads(raw.decode("utf-8"))
except ValueError:
return self.base_url
api = cfg.get("apiUrl") if isinstance(cfg, dict) else None
if not isinstance(api, str) or not api.strip():
return self.base_url
found = normalize_base(urljoin(f"{self.base_url}/", api.strip()))
# The site is https, but config.json points to plain http (spoofing or a misconfiguration): keep the token off it.
if self.base_url.lower().startswith("https:") and not found.lower().startswith("https:"):
raise ValueError(
f"config.json of {self.base_url} points apiUrl to non-HTTPS {found}; refusing to send the bot token over plain HTTP"
)
return found
def auth_header(self) -> str:
return f"Bot {self.token}"
# ── methods ───────────────────────────────────────────────────────────────────────────────────────────────────
def call(self, method: str, params: Optional[Dict[str, Any]] = None, *, files: Optional[Sequence[Any]] = None,
request_timeout: Optional[float] = None) -> Any:
"""Any Bot API method by name. `params` go as JSON; with `files` — as `multipart/form-data`."""
params = {k: v for k, v in (params or {}).items() if v is not None}
url = f"{self.api_url()}/api/bot/v1/{method}"
limit = request_timeout
if limit is None:
limit = self.timeout
if method.lower() == "getupdates": # long polling: the server holds the request up to `timeout` seconds
try:
limit += float(params.get("timeout") or 0)
except (TypeError, ValueError):
pass
attempt = 0
while True:
if files:
body, ctype = _multipart(params, files)
else:
body, ctype = _dumps(params).encode("utf-8"), "application/json"
req = urllib.request.Request(
url, data=body, method="POST",
headers={"Authorization": self.auth_header(), "Content-Type": ctype, "Accept": "application/json"},
)
retry_header: Optional[str] = None
try:
with self._opener.open(req, timeout=limit) as r:
status = r.status
raw = r.read()
except urllib.error.HTTPError as e:
status = e.code
retry_header = e.headers.get("Retry-After") if e.headers else None
raw = e.read() or b""
try:
res = json.loads(raw.decode("utf-8"))
except ValueError:
res = None
if isinstance(res, dict) and res.get("ok") is True:
return res.get("result")
err = res if isinstance(res, dict) and res.get("ok") is False else {}
code = err.get("error_code") if isinstance(err.get("error_code"), int) else status
description = err.get("description") if isinstance(err.get("description"), str) else f"HTTP {status}"
retry_after = err.get("retry_after")
if not isinstance(retry_after, int) or isinstance(retry_after, bool):
retry_after = int(retry_header) if retry_header and retry_header.strip().isdigit() else None
if code == 429 and retry_after is not None and attempt < self.max_retries and retry_after <= self.max_retry_after:
attempt += 1
self._sleep(retry_after)
continue
raise GusVoiceApiError(code, description, retry_after, method)
# ── receiving updates ─────────────────────────────────────────────────────────────────────────────────────────
def run_polling(self, handler: Callable[[Dict[str, Any]], Any], **options: Any) -> None:
"""Long polling loop (`getUpdates`) — see `gusvoice_bot.polling.run_polling` for the options."""
from .polling import run_polling
run_polling(self, handler, **options)
class AsyncClient:
"""`asyncio` wrapper over `Client`: every method runs in a worker thread (`asyncio.to_thread`).
It is threads, not native async I/O — fine for a bot, no extra dependencies. `await bot.send_message(chat, "hi")`.
"""
def __init__(self, token: str, base_url: str, **options: Any) -> None:
self.sync = Client(token, base_url, **options)
def __getattr__(self, name: str) -> Any:
attr = getattr(self.sync, name)
if not callable(attr):
return attr
async def run(*args: Any, **kwargs: Any) -> Any:
return await asyncio.to_thread(attr, *args, **kwargs)
run.__name__ = name
run.__doc__ = getattr(attr, "__doc__", None)
return run
+24
View File
@@ -0,0 +1,24 @@
# SPDX-License-Identifier: MIT
"""Errors of the GusVoice Bot API client."""
from __future__ import annotations
from typing import Optional
class GusVoiceError(Exception):
"""Base class of every error raised by this package."""
class GusVoiceApiError(GusVoiceError):
"""The server answered `{"ok": false, ...}` (or a non-JSON error page).
`code` is `error_code` (or the HTTP status), `description` is the server text, `retry_after` (seconds) comes with 429.
"""
def __init__(self, code: int, description: str, retry_after: Optional[int] = None, method: Optional[str] = None) -> None:
self.code = code
self.description = description
self.retry_after = retry_after
self.method = method
where = f"{method}: " if method else ""
super().__init__(f"{where}{code} {description}")
+81
View File
@@ -0,0 +1,81 @@
# SPDX-License-Identifier: MIT
"""Buttons under a message: `InlineKeyboard().text("Status", "st").row().url("Site", "https://example.com")`.
Pass the keyboard as `reply_markup` as is. The server limits are checked when a button is added (the same as the
TypeScript SDK `packages/bot-sdk/src/keyboard.ts`): `callback_data` is 1-64 BYTES of UTF-8 (not characters), at most
8 buttons in a row and 100 in total, button text 1-64 characters, `url` only http(s) without credentials.
"""
from __future__ import annotations
from typing import Any, Dict, Iterable, List, Optional
from urllib.parse import urlsplit
CALLBACK_DATA_MAX_BYTES = 64
KEYBOARD_ROW_BUTTONS_MAX = 8
KEYBOARD_BUTTONS_MAX = 100
KEYBOARD_BUTTON_TEXT_MAX = 64
def _is_http_url(url: str) -> bool:
try:
u = urlsplit(url)
return u.scheme in ("http", "https") and bool(u.hostname) and u.username is None and u.password is None
except ValueError:
return False
class InlineKeyboard:
"""Builder of `InlineKeyboardMarkup`. Empty rows are dropped when the markup is sent."""
def __init__(self, rows: Optional[Iterable[Iterable[Dict[str, Any]]]] = None) -> None:
self.inline_keyboard: List[List[Dict[str, Any]]] = [[]]
if rows is not None:
self.inline_keyboard = []
for r in rows:
self.row()
for b in r:
self._add(dict(b))
def text(self, label: str, data: str) -> "InlineKeyboard":
"""A button that sends the bot a `callback_query` with `data` (the person who presses it sees it: no secrets)."""
if not isinstance(data, str) or not data or len(data.encode("utf-8")) > CALLBACK_DATA_MAX_BYTES:
raise ValueError(f"InlineKeyboard: callback_data {data!r} must be 1-{CALLBACK_DATA_MAX_BYTES} bytes of UTF-8")
return self._add({"text": label, "callback_data": data})
def url(self, label: str, url: str) -> "InlineKeyboard":
"""A link button (http/https only, no login in the address; the client asks "Open the link?")."""
if not isinstance(url, str) or not _is_http_url(url):
raise ValueError(f"InlineKeyboard: url {url!r} must be http(s) without credentials")
return self._add({"text": label, "url": url})
def row(self) -> "InlineKeyboard":
"""Next buttons go to a new row."""
if not self.inline_keyboard or self.inline_keyboard[-1]:
self.inline_keyboard.append([])
return self
def to_dict(self) -> Dict[str, Any]:
"""The markup without empty rows (this is what goes to the server)."""
return {"inline_keyboard": [[dict(b) for b in r] for r in self.inline_keyboard if r]}
def _add(self, b: Dict[str, Any]) -> "InlineKeyboard":
label = b.get("text")
if not isinstance(label, str) or not label.strip() or len(label.strip()) > KEYBOARD_BUTTON_TEXT_MAX:
raise ValueError(f"InlineKeyboard: button text must be 1-{KEYBOARD_BUTTON_TEXT_MAX} characters")
if ("callback_data" in b) == ("url" in b):
raise ValueError("InlineKeyboard: a button needs exactly one of callback_data and url")
if "callback_data" in b:
d = b["callback_data"]
if not isinstance(d, str) or not d or len(d.encode("utf-8")) > CALLBACK_DATA_MAX_BYTES:
raise ValueError(f"InlineKeyboard: callback_data {d!r} must be 1-{CALLBACK_DATA_MAX_BYTES} bytes of UTF-8")
elif not isinstance(b["url"], str) or not _is_http_url(b["url"]):
raise ValueError(f"InlineKeyboard: url {b['url']!r} must be http(s) without credentials")
if sum(len(r) for r in self.inline_keyboard) >= KEYBOARD_BUTTONS_MAX:
raise ValueError(f"InlineKeyboard: at most {KEYBOARD_BUTTONS_MAX} buttons")
if not self.inline_keyboard:
self.inline_keyboard.append([])
last = self.inline_keyboard[-1]
if len(last) >= KEYBOARD_ROW_BUTTONS_MAX:
raise ValueError(f"InlineKeyboard: at most {KEYBOARD_ROW_BUTTONS_MAX} buttons in a row - call row()")
last.append(b)
return self
+103
View File
@@ -0,0 +1,103 @@
# SPDX-License-Identifier: MIT
"""Long polling loop: `getUpdates` with `offset`, one update at a time, in order.
The offset moves past an update only AFTER the handler has returned for it, so an update in flight when the process
dies is delivered again. A handler exception goes to `on_error(update, exc)` (default: print the traceback) and the
update counts as handled — otherwise one bad update would come back forever; make `on_error` re-raise to stop the loop
without confirming that update instead. Fatal answers (401 — token revoked or bot disabled, 403 — bots are off on the
instance, 404, 409 — a webhook is set or another copy of the bot is polling) raise `GusVoiceApiError`; everything else
(network, 5xx, 400) is retried after a pause of 1 s growing to 30 s. Same rules as the TypeScript SDK `runPolling`.
"""
from __future__ import annotations
import random
import sys
import threading
import traceback
from typing import TYPE_CHECKING, Any, Callable, Dict, Iterable, Optional
from .errors import GusVoiceApiError
if TYPE_CHECKING:
from .client import Client
FATAL_CODES = frozenset({401, 403, 404, 409})
RECONNECT_MIN_S = 1.0
RECONNECT_MAX_S = 30.0
def _default_on_error(update: Dict[str, Any], exc: BaseException) -> None:
print(f"gusvoice_bot: handler failed on update {update.get('update_id')}:", file=sys.stderr)
traceback.print_exception(type(exc), exc, exc.__traceback__, file=sys.stderr)
def _log(message: str) -> None:
print(f"gusvoice_bot: {message}", file=sys.stderr)
def run_polling(
client: "Client",
handler: Callable[[Dict[str, Any]], Any],
*,
timeout: int = 30,
limit: int = 100,
allowed_updates: Optional[Iterable[str]] = None,
on_error: Optional[Callable[[Dict[str, Any], BaseException], Any]] = None,
stop_event: Optional[threading.Event] = None,
log: Callable[[str], Any] = _log,
) -> None:
"""Receive updates until `stop_event` is set (or Ctrl+C) and call `handler(update)` for each.
`timeout` — seconds the server may hold one request (0-50). `getMe` is called first: a wrong token fails right away.
A long poll is not interrupted by `stop_event`: the loop stops when the current request returns (≤ `timeout` s).
On exit the handled updates are confirmed (`offset` without fetching anything new).
"""
on_error = on_error or _default_on_error
wait = stop_event.wait if stop_event is not None else client._sleep
poll_timeout = min(max(int(timeout), 0), 50)
allowed = list(allowed_updates) if allowed_updates is not None else None
client.get_me()
last = 0 # update_id of the last handled update
backoff = RECONNECT_MIN_S
try:
while stop_event is None or not stop_event.is_set():
try:
updates = client.get_updates(
offset=last + 1 if last > 0 else None, limit=limit, timeout=poll_timeout, allowed_updates=allowed
)
backoff = RECONNECT_MIN_S
except GusVoiceApiError as e:
if e.code in FATAL_CODES:
if e.code == 409:
log(f"stopped: {e.description}")
raise
log(f"getUpdates failed ({e}), retry in {round(backoff)} s")
wait(backoff * (0.8 + random.random() * 0.4))
backoff = min(backoff * 2, RECONNECT_MAX_S)
continue
except Exception as e: # network, timeout, garbage — try again later
log(f"getUpdates failed ({e!r}), retry in {round(backoff)} s")
wait(backoff * (0.8 + random.random() * 0.4))
backoff = min(backoff * 2, RECONNECT_MAX_S)
continue
for update in updates or []:
if stop_event is not None and stop_event.is_set():
break
uid = update.get("update_id") if isinstance(update, dict) else None
if not isinstance(uid, (int, float)) or isinstance(uid, bool):
continue
uid = int(uid)
if uid <= last:
continue # repeat of an already handled update
try:
handler(update)
except Exception as exc:
on_error(update, exc) # re-raise here to stop without confirming this update
last = uid
finally:
if last > 0:
try:
# Confirm what was handled: an empty `allowed_updates` returns nothing but moves the offset.
client.get_updates(offset=last + 1, timeout=0, limit=1, allowed_updates=[])
except Exception:
pass
View File
Whitespace-only changes.
+133
View File
@@ -0,0 +1,133 @@
# SPDX-License-Identifier: MIT
"""Receiving updates on your own address (`setWebhook`).
Every delivery carries `X-GusVoice-Signature: t=<unix seconds>,v1=<hex HMAC-SHA256(secret, t + "." + raw body)>`
(the server side is `webhookSignature` in `packages/backend/src/bots/botRules.ts`). Check it on the RAW body, before
parsing JSON: a re-serialized body does not keep the signature.
"""
from __future__ import annotations
import hashlib
import hmac
import json
import time
from http.server import BaseHTTPRequestHandler
from typing import Any, Callable, Dict, List, Optional, Tuple, Type, Union
SIGNATURE_HEADER = "X-GusVoice-Signature"
"""Header with the signature (header names are case-insensitive)."""
DEFAULT_TOLERANCE_S = 300
"""Signature time window, seconds: an older (or far future) delivery is a replay and is refused."""
MAX_BODY_BYTES = 8 << 20
"""Largest delivery body `webhook_handler` accepts."""
def parse_signature_header(header: Optional[str]) -> Optional[Tuple[int, List[str]]]:
"""`(t, [v1, ...])` from the header, or `None` if it is missing or malformed (several `v1` are allowed)."""
if not header or not isinstance(header, str):
return None
t: Optional[int] = None
v1: List[str] = []
for part in header.split(","):
k, sep, v = part.partition("=")
if not sep:
continue
k, v = k.strip(), v.strip()
if k == "t" and v.isascii() and v.isdigit() and 1 <= len(v) <= 12:
t = int(v)
elif k == "v1" and len(v) == 64 and all(c in "0123456789abcdefABCDEF" for c in v):
v1.append(v.lower())
return (t, v1) if t is not None and v1 else None
def sign(secret: str, raw_body: Union[bytes, str], now: int) -> str:
"""Header value the server would send for this body at `now` (for your tests)."""
body = raw_body.encode("utf-8") if isinstance(raw_body, str) else bytes(raw_body)
mac = hmac.new(secret.encode("utf-8"), str(now).encode("ascii") + b"." + body, hashlib.sha256).hexdigest()
return f"t={now},v1={mac}"
def verify_signature(
secret: str,
header: Optional[str],
raw_body: Union[bytes, str],
tolerance: int = DEFAULT_TOLERANCE_S,
now: Optional[float] = None,
) -> bool:
"""`True` if the delivery is signed with `secret` and fresh (|now - t| <= `tolerance` seconds).
Comparison runs in constant time (`hmac.compare_digest`). Never raises on bad input: it just returns `False`.
"""
if not secret or not isinstance(secret, str):
return False
sig = parse_signature_header(header)
if sig is None:
return False
t, candidates = sig
current = time.time() if now is None else now
if abs(current - t) > tolerance:
return False
body = raw_body.encode("utf-8") if isinstance(raw_body, str) else bytes(raw_body)
expected = hmac.new(secret.encode("utf-8"), str(t).encode("ascii") + b"." + body, hashlib.sha256).hexdigest()
ok = False
for v in candidates:
ok = hmac.compare_digest(expected, v) or ok
return ok
def webhook_handler(
secret: str,
on_update: Callable[[Dict[str, Any]], Any],
path: str = "/",
tolerance: int = DEFAULT_TOLERANCE_S,
) -> Type[BaseHTTPRequestHandler]:
"""Request handler class for `http.server` (use `ThreadingHTTPServer`).
`POST <path>`: wrong signature -> 401, body larger than `MAX_BODY_BYTES` -> 413, not JSON -> 400; otherwise it
answers 200 right away and then calls `on_update(update)` for the update (or each one of a batch; keep the server fast: it waits for the answer).
Exceptions of `on_update` are printed and do not reach the server (the delivery is already acknowledged).
"""
class _Handler(BaseHTTPRequestHandler):
def do_POST(self) -> None: # noqa: N802 (http.server naming)
if self.path.split("?", 1)[0] != path:
self._reply(404, b"not found")
return
try:
length = int(self.headers.get("Content-Length") or "0")
except ValueError:
length = -1
if length < 0 or length > MAX_BODY_BYTES:
self._reply(413, b"too large")
return
raw = self.rfile.read(length)
if not verify_signature(secret, self.headers.get(SIGNATURE_HEADER), raw, tolerance):
self._reply(401, b"bad signature")
return
try:
update = json.loads(raw.decode("utf-8"))
except ValueError:
self._reply(400, b"bad json")
return
self._reply(200, b"ok")
# `setWebhook(batch=True)` delivers an array of updates in one request.
for u in update if isinstance(update, list) else [update]:
if not isinstance(u, dict):
continue
try:
on_update(u)
except Exception as e: # the delivery is acknowledged; report and go on
self.log_error("update handler failed: %r", e)
def do_GET(self) -> None: # noqa: N802
self._reply(405, b"POST only")
def _reply(self, status: int, body: bytes) -> None:
self.send_response(status)
self.send_header("Content-Type", "text/plain; charset=utf-8")
self.send_header("Content-Length", str(len(body)))
self.end_headers()
self.wfile.write(body)
self.wfile.flush()
return _Handler