Add standalone GusVoice AWG bot and Linux installer
This commit is contained in:
commit
38c6a1a682
23 files changed
+2924
No files matched your search
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