├── .github └── workflows │ ├── build-tdlib.yml │ └── publish-to-pypi.yml ├── .gitignore ├── LICENSE ├── MANIFEST.in ├── README.md ├── examples ├── README.md ├── chatIDBot.py ├── echobot.py └── keyboardBot.py ├── generate_files.py ├── generate_json.py ├── pytdbot ├── __init__.py ├── client.py ├── client_manager.py ├── exception │ └── __init__.py ├── filters.py ├── handlers │ ├── __init__.py │ ├── decorators.py │ ├── handler.py │ └── td_updates.py ├── methods │ ├── __init__.py │ ├── methods.py │ └── td_functions.py ├── tdjson │ ├── __init__.py │ └── tdjson.py ├── types │ ├── __init__.py │ ├── plugins │ │ └── __init__.py │ └── td_types │ │ ├── __init__.py │ │ ├── bound_methods │ │ ├── __init__.py │ │ ├── callback_query.py │ │ ├── chatActions.py │ │ ├── file.py │ │ └── message.py │ │ └── types.py └── utils │ ├── __init__.py │ ├── asyncio_utils.py │ ├── escape.py │ ├── json_utils.py │ ├── obj_encoder.py │ ├── strings.py │ ├── text_format.py │ └── webapps.py ├── requirements.txt ├── setup.py ├── td_api.json └── td_api.tl /.github/workflows/build-tdlib.yml: -------------------------------------------------------------------------------- 1 | name: Build TDLib 2 | on: 3 | workflow_dispatch: 4 | 5 | concurrency: 6 | group: ${{ github.ref }} 7 | cancel-in-progress: true 8 | 9 | jobs: 10 | build: 11 | runs-on: ubuntu-latest 12 | 13 | steps: 14 | - name: Clone Pytdbot 15 | uses: actions/checkout@v4 16 | with: 17 | fetch-depth: 1 18 | 19 | - name: Clone TDLib 20 | uses: actions/checkout@v4 21 | with: 22 | repository: tdlib/td 23 | fetch-depth: 1 24 | path: "td/" 25 | 26 | - name: Install Python 3.9 27 | uses: actions/setup-python@v5 28 | with: 29 | python-version: "3.9" 30 | 31 | # - name: Install PHP 7.4 32 | # uses: shivammathur/setup-php@v2 33 | # with: 34 | # php-version: "7.4" 35 | 36 | # - name: Install CMake 3.25 37 | # uses: jwlawson/actions-setup-cmake@v1.12 38 | # with: 39 | # cmake-version: "3.25.1" 40 | 41 | - name: Increase swap 42 | run: | 43 | free -h 44 | export SWAP_PATH=$(sudo swapon --show=NAME | tail -1) 45 | sudo swapoff -a 46 | sudo fallocate -l 7G $SWAP_PATH 47 | sudo mkswap $SWAP_PATH 48 | sudo swapon $SWAP_PATH 49 | free -h 50 | 51 | - name: Install TDLib dependencies 52 | id: td 53 | run: | 54 | sudo apt-get update 55 | # sudo sudo apt-get install make zlib1g-dev libssl-dev gperf clang-6.0 libc++-dev libc++abi-dev -y 56 | # rm -rf td/build 57 | # mkdir td/build 58 | 59 | - name: Setup variables 60 | id: vars 61 | run: | 62 | echo "CURRENT_TDLIB_COMMIT_HASH=$(python -c "import json; print(json.loads(open('td_api.json').read())['commit_hash'])")" >> $GITHUB_OUTPUT 63 | echo "CURRENT_TDLIB_VERSION=$(python -c "import json; print(json.loads(open('td_api.json').read())['version'])")" >> $GITHUB_OUTPUT 64 | 65 | cd $GITHUB_WORKSPACE/td 66 | 67 | echo "LATEST_TDLIB_COMMIT_HASH=$(git log -1 --pretty=%H)" >> $GITHUB_OUTPUT 68 | echo "LATEST_TDLIB_VERSION=$( 69 | cat CMakeLists.txt | 70 | sed -nr 's/.*project\(TDLib VERSION (.*) LANGUAGES CXX C\).*/\1/p' 71 | )" >> $GITHUB_OUTPUT 72 | 73 | - name: TDLib version 74 | run: | 75 | echo "Current TDLib version: ${{ steps.vars.outputs.CURRENT_TDLIB_VERSION }} (${{ steps.vars.outputs.CURRENT_TDLIB_COMMIT_HASH }})" 76 | echo "Latest TDLib version: ${{ steps.vars.outputs.LATEST_TDLIB_VERSION }} (${{ steps.vars.outputs.LATEST_TDLIB_COMMIT_HASH }})" 77 | 78 | # - name: Compile 79 | # run: | 80 | # cd td/build 81 | # CXXFLAGS="-stdlib=libc++" CC=/usr/bin/clang-6.0 CXX=/usr/bin/clang++-6.0 cmake -DCMAKE_BUILD_TYPE=Release -DCMAKE_INSTALL_PREFIX:PATH=../tdlib -DTD_ENABLE_LTO=ON -DCMAKE_AR=/usr/bin/llvm-ar-6.0 -DCMAKE_NM=/usr/bin/llvm-nm-6.0 -DCMAKE_OBJDUMP=/usr/bin/llvm-objdump-6.0 -DCMAKE_RANLIB=/usr/bin/llvm-ranlib-6.0 .. 82 | # cmake --build . --target prepare_cross_compiling 83 | # cmake --build . --target install -j45 84 | 85 | - name: Copy TDLib TL 86 | run: | 87 | cp td/td/generate/scheme/td_api.tl td_api.tl 88 | 89 | # - name: Move libtdjson.so 90 | # run: | 91 | # mkdir -p pytdbot/lib 92 | # mv td/tdlib/lib/libtdjson.so.${{ steps.vars.outputs.LATEST_TDLIB_VERSION }} pytdbot/lib/libtdjson.so 93 | 94 | - name: Generate Pytdbot files 95 | run: | 96 | python generate_json.py "${{ steps.vars.outputs.LATEST_TDLIB_VERSION }}" "${{ steps.vars.outputs.LATEST_TDLIB_COMMIT_HASH }}" 97 | python generate_files.py 98 | python -m pip install ruff 99 | python -m ruff format . 100 | 101 | CURRENT_VERSION=${{ steps.vars.outputs.CURRENT_TDLIB_VERSION }} 102 | sed --binary -i "s/${CURRENT_VERSION//./\\.}/${{ steps.vars.outputs.LATEST_TDLIB_VERSION }}/g" README.md 103 | 104 | - name: Commit TDLib files 105 | uses: EndBug/add-and-commit@v9 106 | with: 107 | message: "Update TDLib to ${{ steps.vars.outputs.LATEST_TDLIB_VERSION }} (tdlib/td@${{ steps.vars.outputs.LATEST_TDLIB_COMMIT_HASH }})" 108 | add: '["td_api.tl", "td_api.json"]' 109 | committer_name: GitHub Actions 110 | committer_email: 41898282+github-actions[bot]@users.noreply.github.com 111 | 112 | - name: Commit generated Pytdbot files 113 | uses: EndBug/add-and-commit@v9 114 | with: 115 | message: "Generate Pytdbot files" 116 | add: '["README.md", "pytdbot/"]' 117 | committer_name: GitHub Actions 118 | committer_email: 41898282+github-actions[bot]@users.noreply.github.com 119 | -------------------------------------------------------------------------------- /.github/workflows/publish-to-pypi.yml: -------------------------------------------------------------------------------- 1 | name: Publish to pypi 2 | on: 3 | workflow_dispatch: 4 | push: 5 | branches: 6 | - "main" 7 | 8 | paths: 9 | - "pytdbot/__init__.py" 10 | 11 | jobs: 12 | build: 13 | runs-on: ubuntu-latest 14 | permissions: 15 | id-token: write 16 | 17 | steps: 18 | - uses: actions/checkout@v4 19 | with: 20 | fetch-depth: 1 21 | 22 | - name: Build pytdbot 23 | uses: actions/setup-python@v5 24 | with: 25 | python-version: "3.9" 26 | - run: | 27 | python -m pip install --upgrade pip 28 | python -m pip install --upgrade setuptools wheel twine 29 | python setup.py sdist 30 | 31 | - name: Upload to pypi 32 | uses: pypa/gh-action-pypi-publish@release/v1 33 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Byte-compiled / optimized / DLL files 2 | __pycache__/ 3 | *.py[cod] 4 | 5 | # Distribution / packaging 6 | build/ 7 | develop-eggs/ 8 | dist/ 9 | eggs/ 10 | *.egg-info/ 11 | .installed.cfg 12 | *.egg 13 | 14 | # Installer logs 15 | pip-log.txt 16 | pip-delete-this-directory.txt 17 | 18 | *.log 19 | docs/ 20 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2022-2025 Pytdbot, AYMENJD 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /MANIFEST.in: -------------------------------------------------------------------------------- 1 | include README.md LICENSE requirements.txt pytdbot/td_api.* 2 | recursive-include pytdbot *.py 3 | exclude examples/ 4 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Pytdbot [![Version](https://img.shields.io/pypi/v/Pytdbot?style=flat&logo=pypi)](https://pypi.org/project/Pytdbot) [![TDLib version](https://img.shields.io/badge/TDLib-v1.8.49-blue?logo=telegram)](https://github.com/tdlib/td) [![Downloads](https://static.pepy.tech/personalized-badge/pytdbot?period=month&units=none&left_color=grey&right_color=brightgreen&left_text=Downloads)](https://pepy.tech/project/pytdbot) [![Telegram Chat](https://img.shields.io/badge/Pytdbot%20chat-blue?logo=telegram&label=Telegram)](https://t.me/pytdbotchat) 2 | 3 | Pytdbot (Python TDLib) is an asynchronous [**TDLib**](https://github.com/tdlib/td) wrapper for **Telegram** users/bots written in **Python**. 4 | 5 | ### Features 6 | 7 | `Pytdbot` offers numerous advantages, including: 8 | 9 | - **Easy to Use**: Designed with simplicity in mind, making it accessible for developers 10 | - **Performance**: Fast and powerful, making it ready to fight 11 | - **Asynchronous**: Fully asynchronous that allows for non-blocking requests and improved responsiveness 12 | - **Scalable**: Easily scalable using [TDLib Server](https://github.com/pytdbot/tdlib-server) 13 | - **Well-typed**: Provides clear and well-defined methods and types to enhance developer experience 14 | - **Decorator-Based Updates**: Simplifies the implementation of update handlers through a decorator pattern 15 | - **Bound Methods**: Features types bound methods for improved usability 16 | - **Unlimited Support**: Supports **Plugins**, [**filters**](pytdbot/filters.py#L23), [**TDLib**](https://github.com/tdlib/td) types/functions and much more 17 | 18 | ### Requirements 19 | 20 | - Python 3.9+ 21 | - Telegram [API key](https://my.telegram.org/apps) 22 | - [tdjson](https://github.com/AYMENJD/tdjson) or [TDLib](https://github.com/tdlib/td#building) 23 | - [deepdiff](https://github.com/seperman/deepdiff) 24 | - [aio-pika](https://github.com/mosquito/aio-pika) 25 | 26 | ### Installation 27 | 28 | > For better performance, it's recommended to install [orjson](https://github.com/ijl/orjson#install) or [ujson](https://github.com/ultrajson/ultrajson#ultrajson). 29 | 30 | You can install Pytdbot with TDLib included using pip: 31 | 32 | ```bash 33 | pip install --upgrade pytdbot[tdjson] 34 | ``` 35 | 36 | If the installation fails, then install without **pre-built** TDLib: 37 | 38 | ```bash 39 | pip install pytdbot 40 | ``` 41 | 42 | Then you need to build TDLib from [source](https://github.com/tdlib/td#building) and pass it to `Client.lib_path`. 43 | 44 | You could also install the development version using the following command: 45 | 46 | ```bash 47 | pip install --pre pytdbot 48 | ``` 49 | 50 | ### Examples 51 | 52 | Basic example: 53 | 54 | ```python 55 | 56 | import asyncio 57 | 58 | from pytdbot import Client, types 59 | 60 | client = Client( 61 | token="1088394097:AAQX2DnWiw4ihwiJUhIHOGog8gGOI", # Your bot token 62 | api_id=0, 63 | api_hash="API_HASH", 64 | files_directory="BotDB", # Path where to store TDLib files 65 | database_encryption_key="1234echobot$", 66 | td_verbosity=2, # TDLib verbosity level 67 | td_log=types.LogStreamFile("tdlib.log", 104857600), # Set TDLib log file path 68 | ) 69 | 70 | 71 | @client.on_updateNewMessage() 72 | async def print_message(c: Client, message: types.UpdateNewMessage): 73 | print(message) 74 | 75 | 76 | @client.on_message() 77 | async def say_hello(c: Client, message: types.Message): 78 | msg = await message.reply_text(f"Hey {await message.mention(parse_mode='html')}! I'm cooking up a surprise... 🍳👨‍🍳", parse_mode="html") 79 | 80 | async with message.action("choose_sticker"): 81 | await asyncio.sleep(5) 82 | 83 | await msg.edit_text("Boo! 👻 Just kidding.") 84 | 85 | 86 | 87 | # Run the client 88 | client.run() 89 | 90 | ``` 91 | 92 | For more examples, check the [examples](https://github.com/pytdbot/client/tree/main/examples) folder. 93 | 94 | # Thanks to 95 | 96 | - You for viewing or using this project. 97 | 98 | - [@levlam](https://github.com/levlam) for maintaining [TDLib](https://github.com/tdlib/td) and for the help to create [Pytdbot](https://github.com/pytdbot/client). 99 | 100 | # License 101 | 102 | MIT [License](https://github.com/pytdbot/client/blob/main/LICENSE) 103 | -------------------------------------------------------------------------------- /examples/README.md: -------------------------------------------------------------------------------- 1 | # Examples 2 | Here is some examples of using [Pytdbot](https://github.com/pytdbot/client) 3 | 4 | ## chatIDBot.py 5 | An example of a bot that prints the ID of any chat selected by the user using `request_user/chat` button. 6 | 7 | Real example: https://t.me/WhatChatIDBot. 8 | 9 | ## keyboardBot.py 10 | An example of a bot that sends reply markup buttons. 11 | 12 | ## echobot.py 13 | An example of a bot that echo content sent by the user. 14 | -------------------------------------------------------------------------------- /examples/chatIDBot.py: -------------------------------------------------------------------------------- 1 | from pytdbot import Client, filters, types, utils 2 | import logging, asyncio 3 | 4 | logging.basicConfig( 5 | level=logging.INFO, 6 | # level=logging.DEBUG, 7 | format="[%(levelname)s][p %(process)d %(threadName)s][%(created)f][%(filename)s:%(lineno)d][%(funcName)s] %(message)s", 8 | ) 9 | is_private_filter = filters.create(lambda _, message: message.chat_id > 0) 10 | lock = asyncio.Lock() 11 | 12 | client = Client( 13 | api_id=0, 14 | api_hash="", 15 | token="", 16 | database_encryption_key="WhatIDisThat", 17 | files_directory="ChatIDbot", 18 | options={ 19 | "disable_network_statistics": True, 20 | "disable_time_adjustment_protection": True, 21 | "ignore_inline_thumbnails": True, 22 | "ignore_background_updates": True, 23 | "message_unload_delay": 60, 24 | "disable_persistent_network_statistics": True, 25 | }, 26 | use_chat_info_database=False, 27 | use_file_database=False, 28 | use_message_database=False, 29 | default_parse_mode="markdownv2", 30 | ) 31 | 32 | request_buttons = types.ReplyMarkupShowKeyboard( 33 | [ 34 | [ 35 | types.KeyboardButton( 36 | text="Channel", 37 | type=types.KeyboardButtonTypeRequestChat( 38 | id=1, 39 | chat_is_channel=True, 40 | restrict_chat_is_forum=False, 41 | chat_is_forum=False, 42 | restrict_chat_has_username=False, 43 | chat_has_username=False, 44 | chat_is_created=False, 45 | ), 46 | ), 47 | types.KeyboardButton( 48 | text="Group", 49 | type=types.KeyboardButtonTypeRequestChat( 50 | id=2, 51 | chat_is_channel=False, 52 | restrict_chat_is_forum=False, 53 | chat_is_forum=False, 54 | restrict_chat_has_username=False, 55 | chat_has_username=False, 56 | chat_is_created=False, 57 | ), 58 | ), 59 | ], 60 | [ 61 | types.KeyboardButton( 62 | text="User", 63 | type=types.KeyboardButtonTypeRequestUsers( 64 | id=3, 65 | max_quantity=1, 66 | restrict_user_is_bot=True, 67 | user_is_bot=False, 68 | restrict_user_is_premium=False, 69 | user_is_premium=False, 70 | ), 71 | ), 72 | types.KeyboardButton( 73 | text="Bot", 74 | type=types.KeyboardButtonTypeRequestUsers( 75 | id=4, 76 | max_quantity=1, 77 | restrict_user_is_bot=True, 78 | user_is_bot=True, 79 | restrict_user_is_premium=False, 80 | user_is_premium=False, 81 | ), 82 | ), 83 | ], 84 | ], 85 | resize_keyboard=True, 86 | input_field_placeholder="Select chat type", 87 | ) 88 | 89 | 90 | async def increase_usage(by: int = 1): 91 | async with lock: 92 | if "x_usage" not in client.options: 93 | await client.call_method( 94 | "setOption", 95 | name="x_usage", 96 | value={"@type": "optionValueInteger", "value": by}, 97 | ) 98 | else: 99 | await client.call_method( 100 | "setOption", 101 | name="x_usage", 102 | value={ 103 | "@type": "optionValueInteger", 104 | "value": client.options["x_usage"] + by, 105 | }, 106 | ) 107 | 108 | 109 | @client.on_message(is_private_filter) 110 | async def start(client: Client, message: types.Message): 111 | if message.text == "/start": 112 | await message.reply_text( 113 | "*Your ID*: {}".format( 114 | utils.code(str(message.from_id)), 115 | ), 116 | reply_markup=request_buttons, 117 | ) 118 | await increase_usage() 119 | elif message.text == "/usage": 120 | await message.reply_text( 121 | "*Bot usage*: {}".format(client.options.get("x_usage", 0)) 122 | ) 123 | 124 | 125 | @client.on_message(is_private_filter) 126 | async def handle_shared_chat(client: Client, message: types.Message): 127 | if isinstance(message.content, types.MessageUsersShared): 128 | if message.content.button_id == 3: 129 | user_type_text = "*User ID*" 130 | elif message.content.button_id == 4: 131 | user_type_text = "*Bot ID*" 132 | 133 | await message.reply_text( 134 | "{}: {}".format( 135 | user_type_text, 136 | utils.code(str(message.content.users[0].user_id)), 137 | ), 138 | ) 139 | await increase_usage() 140 | elif isinstance(message.content, types.MessageChatShared): 141 | await message.reply_text( 142 | "*Chat ID*: {}".format( 143 | utils.code(str(message.content.chat.chat_id)), 144 | ), 145 | ) 146 | await increase_usage() 147 | 148 | 149 | @client.on_updateChatMember() 150 | async def chat_member(client: Client, update: types.UpdateChatMember): 151 | if ( 152 | isinstance(update.new_chat_member.member_id, types.MessageSenderUser) 153 | and update.new_chat_member.member_id.user_id == client.options["my_id"] 154 | ): 155 | if isinstance(update.new_chat_member.status, types.ChatMemberStatusMember): 156 | await client.sendTextMessage( 157 | update.chat_id, 158 | "*Chat ID*: {}".format( 159 | utils.code(str(update.chat_id)), 160 | ), 161 | ) 162 | await client.leaveChat(update.chat_id) 163 | await increase_usage() 164 | 165 | 166 | # Run the bot 167 | client.run() 168 | -------------------------------------------------------------------------------- /examples/echobot.py: -------------------------------------------------------------------------------- 1 | from pytdbot import Client, types 2 | import logging 3 | 4 | logging.basicConfig( 5 | level=logging.INFO, 6 | format="[%(levelname)s][p %(process)d %(threadName)s][%(created)f][%(filename)s:%(lineno)d][%(funcName)s] %(message)s", 7 | ) 8 | 9 | 10 | client = Client( 11 | token="1088394097:AAQX2DnWiw4ihwiJUhIHOGog8gGOI", # Your bot token 12 | api_id=0, 13 | api_hash="API_HASH", 14 | files_directory="BotDB", # Path where to store TDLib files 15 | database_encryption_key="1234echobot$", 16 | td_verbosity=2, # TDLib verbosity level 17 | td_log=types.LogStreamFile("tdlib.log", 104857600), # Set TDLib log file path 18 | ) 19 | 20 | 21 | @client.on_updateNewMessage() 22 | async def print_message(_: Client, message: types.Message): 23 | print(message) 24 | 25 | 26 | @client.on_message() 27 | async def echo(_: Client, message: types.Message): 28 | if isinstance(message.content, types.MessageText): 29 | await message.reply_text(message.text, entities=message.entities) 30 | 31 | elif isinstance(message.content, types.MessageAnimation): 32 | await message.reply_animation( 33 | message.remote_file_id, 34 | caption=message.caption, 35 | caption_entities=message.entities, 36 | ) 37 | 38 | elif isinstance(message.content, types.MessageAudio): 39 | await message.reply_audio( 40 | message.remote_file_id, 41 | caption=message.caption, 42 | caption_entities=message.entities, 43 | ) 44 | 45 | elif isinstance(message.content, types.MessageDocument): 46 | await message.reply_document( 47 | message.remote_file_id, 48 | caption=message.caption, 49 | caption_entities=message.entities, 50 | ) 51 | 52 | elif isinstance(message.content, types.MessagePhoto): 53 | await message.reply_photo( 54 | message.remote_file_id, 55 | caption=message.caption, 56 | caption_entities=message.entities, 57 | ) 58 | 59 | elif isinstance(message.content, types.MessageSticker): 60 | await message.reply_sticker(message.remote_file_id) 61 | 62 | elif isinstance(message.content, types.MessageVideo): 63 | await message.reply_video( 64 | message.remote_file_id, 65 | caption=message.caption, 66 | caption_entities=message.entities, 67 | ) 68 | 69 | elif isinstance(message.content, types.MessageVoiceNote): 70 | await message.reply_voice( 71 | message.remote_file_id, 72 | caption=message.caption, 73 | caption_entities=message.entities, 74 | ) 75 | else: 76 | await message.reply_text("Oops! i don't know how to handle this message.") 77 | 78 | 79 | # Run the client 80 | client.run() 81 | -------------------------------------------------------------------------------- /examples/keyboardBot.py: -------------------------------------------------------------------------------- 1 | from pytdbot import Client, types 2 | import logging 3 | 4 | logging.basicConfig( 5 | level=logging.INFO, 6 | format="[%(levelname)s][p %(process)d %(threadName)s][%(created)f][%(filename)s:%(lineno)d][%(funcName)s] %(message)s", 7 | ) 8 | 9 | client = Client( 10 | api_id=0, # Your api_id. You can get it from https://my.telegram.org/ 11 | api_hash="API_HASH", # Your api_hash. You can get it from https://my.telegram.org/ 12 | database_encryption_key="1234echobot$", # Your database encryption key 13 | token="1088394097:AAQX2DnWiw4ihwiJUhIHOGog8gGOI", # Your bot token. You can get it from https://t.me/botfather 14 | files_directory="BotDB", # Path where to store TDLib files 15 | workers=2, # Number of workers 16 | td_verbosity=2, # TDLib verbosity level 17 | td_log=types.LogStreamFile("tdlib.log", 104857600), # Set TDLib log file path 18 | ) 19 | 20 | 21 | @client.on_message() 22 | async def start(c: Client, message: types.Message): 23 | if message.text == "/start": 24 | text = "Hello {}!\n".format(await message.mention("html")) 25 | text += "Here is some bot commands:\n\n" 26 | text += "- /keyboard - show keyboard\n" 27 | text += "- /inline - show inline keyboard\n" 28 | text += "- /remove - remove keyboard\n" 29 | text += "- /force - force reply" 30 | 31 | await message.reply_text( 32 | text, 33 | reply_markup=types.ReplyMarkupInlineKeyboard( 34 | [ 35 | [ 36 | types.InlineKeyboardButton( 37 | text="GitHub", 38 | type=types.InlineKeyboardButtonTypeUrl( 39 | "https://github.com/pytdbot/client" 40 | ), 41 | ) 42 | ] 43 | ] 44 | ), 45 | ) 46 | 47 | 48 | @client.on_message() 49 | async def commands(c: Client, message: types.Message): 50 | if message.text == "/inline": 51 | await message.reply_text( 52 | "This is a Inline keyboard", 53 | reply_markup=types.ReplyMarkupInlineKeyboard( 54 | [ 55 | [ 56 | types.InlineKeyboardButton( 57 | text="OwO", 58 | type=types.InlineKeyboardButtonTypeCallback(b"OwO"), 59 | ), 60 | types.InlineKeyboardButton( 61 | text="UwU", 62 | type=types.InlineKeyboardButtonTypeCallback(b"UwU"), 63 | ), 64 | ], 65 | ] 66 | ), 67 | ) 68 | elif message.text == "/keyboard": 69 | await message.reply_text( 70 | "This is a keyboard", 71 | reply_markup=types.ReplyMarkupShowKeyboard( 72 | [ 73 | [ 74 | types.KeyboardButton( 75 | "OwO", type=types.KeyboardButtonTypeText() 76 | ), 77 | types.KeyboardButton( 78 | "UwU", type=types.KeyboardButtonTypeText() 79 | ), 80 | ], 81 | ], 82 | one_time=True, 83 | resize_keyboard=True, 84 | ), 85 | ) 86 | elif message.text == "/remove": 87 | await message.reply_text( 88 | "Keyboards removed", 89 | reply_markup=types.ReplyMarkupRemoveKeyboard(), 90 | ) 91 | elif message.text == "/force": 92 | await message.reply_text( 93 | "This is a force reply", 94 | reply_markup=types.ReplyMarkupForceReply(), 95 | ) 96 | elif message.text: 97 | if "/start" not in message.text: 98 | await message.reply_text('You said "{}"'.format(message.text)) 99 | 100 | 101 | @client.on_updateNewCallbackQuery() 102 | async def callback_query(c: Client, message: types.UpdateNewCallbackQuery): 103 | if message.payload.data: 104 | await c.editTextMessage( 105 | message.chat_id, 106 | message.message_id, 107 | "You pressed {}".format(message.payload.data.decode()), 108 | reply_markup=types.ReplyMarkupInlineKeyboard( 109 | [ 110 | [ 111 | types.InlineKeyboardButton( 112 | text="GitHub", 113 | type=types.InlineKeyboardButtonTypeUrl( 114 | "https://github.com/pytdbot/client" 115 | ), 116 | ) 117 | ] 118 | ] 119 | ), 120 | ) 121 | 122 | 123 | # Run the client 124 | client.run() 125 | -------------------------------------------------------------------------------- /generate_files.py: -------------------------------------------------------------------------------- 1 | import json 2 | import keyword 3 | 4 | indent = " " 5 | bound_methods_class = { 6 | "Message": "MessageBoundMethods", 7 | "File": "FileBoundMethods", 8 | "RemoteFile": "FileBoundMethods", 9 | "UpdateNewCallbackQuery": "CallbackQueryBoundMethods", 10 | } 11 | 12 | 13 | def escape_quotes(text: str): 14 | return "".join( 15 | "\\" + c if c in r"\_*[]()~`>#+-=|{}.!" else c for c in text 16 | ).replace('"', '\\"') 17 | 18 | 19 | def to_camel_case(input_str: str, delimiter: str = ".", is_class: bool = True) -> str: 20 | if not input_str: 21 | return "" 22 | 23 | parts = input_str.split(delimiter) 24 | camel_case_str = "" 25 | 26 | for i, part in enumerate(parts): 27 | if i > 0: 28 | camel_case_str += part[0].upper() + part[1:] 29 | else: 30 | camel_case_str += part 31 | 32 | if camel_case_str: 33 | camel_case_str = ( 34 | camel_case_str[0].upper() if is_class else camel_case_str[0].lower() 35 | ) + camel_case_str[1:] 36 | 37 | return camel_case_str 38 | 39 | 40 | def getArgTypePython(type_name: str, is_function: bool = False): 41 | if type_name == "double": 42 | return "float" 43 | elif type_name in {"string", "secureString"}: 44 | return "str" 45 | elif type_name in {"int32", "int53", "int64", "int256"}: 46 | return "int" 47 | elif type_name in {"bytes", "secureBytes"}: 48 | return "bytes" 49 | elif type_name == "Bool": 50 | return "bool" 51 | elif type_name in {"Object", "Function"}: 52 | return "dict" 53 | elif type_name == "#": 54 | return "int" 55 | elif "?" in type_name: 56 | return getArgTypePython(type_name.split("?")[-1], is_function) 57 | elif type_name.startswith("("): 58 | type_name = type_name.removeprefix("(").removesuffix(")") 59 | a, b = type_name.split(" ") 60 | if a == "vector": 61 | return f"List[{getArgTypePython(b, is_function)}]" 62 | else: 63 | raise Exception(f"Unknown data type {a}/{b}") 64 | elif type_name.startswith("vector<"): 65 | inner_type_start = type_name.find("<") + 1 66 | inner_type_end = type_name.rfind(">") 67 | inner_type = type_name[inner_type_start:inner_type_end] 68 | return f"List[{getArgTypePython(inner_type, is_function)}]" 69 | else: 70 | return ( 71 | to_camel_case(type_name, is_class=True) 72 | if not is_function 73 | else '"types.' + to_camel_case(type_name, is_class=True) + '"' 74 | ) 75 | 76 | 77 | def generate_arg_value(arg_type, arg_name): 78 | if arg_type == "int": 79 | arg_value = f"int({arg_name})" 80 | elif arg_type == "float": 81 | arg_value = f"float({arg_name})" 82 | elif arg_type == "bool": 83 | arg_value = f"bool({arg_name})" 84 | elif arg_type.startswith("List[") or arg_type == "list": 85 | arg_value = f"{arg_name} or []" 86 | else: 87 | arg_value = arg_name 88 | 89 | return arg_value 90 | 91 | 92 | def generate_arg_default(arg_type): 93 | if arg_type == "int": 94 | arg_value = "0" 95 | elif arg_type == "str": 96 | arg_value = '""' 97 | elif arg_type == "float": 98 | arg_value = "0.0" 99 | elif arg_type == "bytes": 100 | arg_value = 'b""' 101 | elif arg_type == "bool": 102 | arg_value = "False" 103 | else: 104 | arg_value = "None" 105 | 106 | return arg_value 107 | 108 | 109 | def generate_args_def(args, is_function: bool = False): 110 | args_list = ["self"] 111 | for arg_name, arg_data in args.items(): 112 | if arg_name in keyword.kwlist: 113 | arg_name += "_" 114 | 115 | arg_type = getArgTypePython(arg_data["type"], is_function) 116 | 117 | args_list.append(f"{arg_name}: {arg_type} = {generate_arg_default(arg_type)}") 118 | 119 | return ", ".join(args_list) 120 | 121 | 122 | def generate_union_types(arg_type, arg_type_name, classes, noneable=True): 123 | unions = [arg_type] 124 | 125 | if ( 126 | arg_type_name in classes 127 | ): # The arg type is a class which has subclasses and we need to include them 128 | unions.pop(0) 129 | 130 | for type_name in classes[arg_type_name]["types"]: 131 | unions.append(to_camel_case(type_name, is_class=True)) 132 | 133 | if noneable: 134 | unions.append("None") 135 | 136 | return f"Union[{', '.join(unions)}]" 137 | 138 | 139 | def generate_self_args(args, classes): 140 | args_list = [] 141 | for arg_name, arg_data in args.items(): 142 | if arg_name in keyword.kwlist: 143 | arg_name += "_" 144 | 145 | arg_type = getArgTypePython(arg_data["type"]) 146 | arg_value = generate_arg_value(arg_type, arg_name) 147 | if arg_value == arg_name: # a.k.a field can be None 148 | arg_type = generate_union_types(arg_type, arg_data["type"], classes) 149 | 150 | args_list.append( 151 | f'self.{arg_name}: {arg_type} = {arg_value}\n{indent * 2}r"""{escape_quotes(arg_data["description"])}"""' 152 | ) 153 | if not args_list: 154 | return "pass" 155 | return f"\n{indent * 2}".join(args_list) 156 | 157 | 158 | def generate_to_dict_return(args): 159 | args_list = ['"@type": self.getType()'] 160 | for arg_name, _ in args.items(): 161 | if arg_name in keyword.kwlist: 162 | arg_name += "_" 163 | args_list.append(f'"{arg_name}": self.{arg_name}') 164 | 165 | return ", ".join(args_list) 166 | 167 | 168 | def generate_from_dict_kwargs(args): 169 | args_list = [] 170 | for arg_name, arg_data in args.items(): 171 | if arg_name in keyword.kwlist: 172 | arg_name += "_" 173 | 174 | arg_type = getArgTypePython(arg_data["type"]) 175 | 176 | if arg_type == "bytes": 177 | args_list.append( 178 | f'data_class.{arg_name} = b64decode(data.get("{arg_name}", b""))' 179 | ) 180 | elif arg_type == "int": # Some values are int but in string format 181 | args_list.append(f'data_class.{arg_name} = int(data.get("{arg_name}", 0))') 182 | else: 183 | args_list.append( 184 | f'data_class.{arg_name} = data.get("{arg_name}", {generate_arg_default(arg_type)})' 185 | ) 186 | 187 | return "; ".join(args_list) 188 | 189 | 190 | def generate_function_invoke_args(args): 191 | args_list = [] 192 | for arg_name, _ in args.items(): 193 | if arg_name in keyword.kwlist: 194 | arg_name += "_" 195 | args_list.append(f'"{arg_name}": {arg_name}') 196 | 197 | return ", ".join(args_list) 198 | 199 | 200 | def generate_function_docstring_args(function_data): 201 | if not function_data["args"]: 202 | return "" 203 | args_list = [] 204 | for arg_name, arg_data in function_data["args"].items(): 205 | args_list.append( 206 | f"{indent * 3}{arg_name} (:class:`{getArgTypePython(arg_data['type'], True)}`):\n{indent * 4 + escape_quotes(arg_data['description'])}" 207 | ) 208 | return f"\n{indent * 2}Parameters:\n" + "\n\n".join(args_list) + "\n" 209 | 210 | 211 | def generate_inherited_class(class_name, type_data, classes): 212 | inherited = ["TlObject"] 213 | if type_data["type"] in classes: 214 | inherited.append(to_camel_case(type_data["type"], is_class=True)) 215 | 216 | if class_name in bound_methods_class: 217 | inherited.append(bound_methods_class[class_name]) 218 | return ", ".join(inherited) 219 | 220 | 221 | class_template = """class {class_name}: 222 | r\"\"\"{docstring}\"\"\" 223 | 224 | pass""" 225 | 226 | 227 | def generate_classes(f, classes): 228 | for class_name in classes.keys(): 229 | f.write( 230 | class_template.format( 231 | class_name=to_camel_case(class_name, is_class=True), 232 | docstring=escape_quotes(classes[class_name]["description"]), 233 | ) 234 | + "\n\n" 235 | ) 236 | 237 | 238 | types_template = """class {class_name}({inherited_class}): 239 | r\"\"\"{docstring} 240 | {docstring_args} 241 | \"\"\" 242 | 243 | def __init__({init_args}) -> None: 244 | {self_args} 245 | 246 | def __str__(self): 247 | return str(pytdbot.utils.obj_to_json(self, indent=4)) 248 | 249 | def getType(self) -> Literal["{type_name}"]: 250 | return "{type_name}" 251 | 252 | def getClass(self) -> Literal["{class_type_name}"]: 253 | return "{class_type_name}" 254 | 255 | def to_dict(self) -> dict: 256 | return {{{to_dict_return}}} 257 | 258 | @classmethod 259 | def from_dict(cls, data: dict) -> Union["{class_name}", None]: 260 | if data: 261 | data_class = cls() 262 | {from_dict_kwargs} 263 | 264 | return data_class""" 265 | 266 | 267 | def generate_types(f, types, updates, classes): 268 | def gen(t): 269 | for type_name, type_data in t.items(): 270 | args_def = generate_args_def(type_data["args"]) 271 | self_args = generate_self_args(type_data["args"], classes) 272 | to_return_dict = generate_to_dict_return(type_data["args"]) 273 | from_dict_kwargs = generate_from_dict_kwargs(type_data["args"]) 274 | class_name = to_camel_case(type_name, is_class=True) 275 | 276 | f.write( 277 | types_template.format( 278 | class_name=class_name, 279 | inherited_class=generate_inherited_class( 280 | class_name, type_data, classes 281 | ), 282 | class_type_name=type_data["type"], 283 | docstring=escape_quotes(type_data["description"]), 284 | docstring_args=generate_function_docstring_args(type_data), 285 | init_args=args_def, 286 | self_args=self_args, 287 | type_name=type_name, 288 | to_dict_return=to_return_dict, 289 | from_dict_kwargs=from_dict_kwargs, 290 | ) 291 | + "\n\n" 292 | ) 293 | 294 | gen(types) 295 | gen(updates) 296 | 297 | 298 | functions_template = """async def {function_name}({function_args}) -> Union["types.Error", "types.{return_type}"]: 299 | r\"\"\"{docstring} 300 | {docstring_args} 301 | Returns: 302 | :class:`~pytdbot.types.{return_type}` 303 | \"\"\" 304 | 305 | return await self.invoke({{'@type': '{method_name}', {function_invoke_args}}})""" 306 | 307 | 308 | def generate_functions(f, types): 309 | for function_name, function_data in types.items(): 310 | args_def = generate_args_def(function_data["args"], True) 311 | invoke_args = generate_function_invoke_args(function_data["args"]) 312 | 313 | f.write( 314 | indent 315 | + functions_template.format( 316 | function_name=to_camel_case(function_name, is_class=False), 317 | function_args=args_def, 318 | return_type=to_camel_case(function_data["type"], is_class=True), 319 | docstring=escape_quotes(function_data["description"]), 320 | docstring_args=generate_function_docstring_args(function_data), 321 | method_name=function_name, 322 | function_invoke_args=invoke_args, 323 | ) 324 | + "\n\n" 325 | ) 326 | 327 | 328 | updates_template = """ def on_{update_name}( 329 | self: "pytdbot.Client" = None, 330 | filters: "pytdbot.filters.Filter" = None, 331 | position: int = None, 332 | ) -> Callable: 333 | r\"\"\"{description} 334 | 335 | Parameters: 336 | filters (:class:`pytdbot.filters.Filter`, *optional*): 337 | An update filter 338 | 339 | position (``int``, *optional*): 340 | The function position in handlers list. Default is ``None`` (append) 341 | 342 | Raises: 343 | :py:class:`TypeError` 344 | \"\"\" 345 | 346 | def decorator(func: Callable) -> Callable: 347 | if hasattr(func, "_handler"): 348 | return func 349 | elif isinstance(self, pytdbot.Client): 350 | if iscoroutinefunction(func): 351 | self.add_handler("{update_name}", func, filters, position) 352 | else: 353 | raise TypeError("Handler must be async") 354 | elif isinstance(self, pytdbot.filters.Filter): 355 | func._handler = Handler(func, "{update_name}", self, position) 356 | else: 357 | func._handler = Handler(func, "{update_name}", filters, position) 358 | return func 359 | 360 | return decorator 361 | 362 | """ 363 | 364 | 365 | def generate_updates(f, updates): 366 | for k, v in updates.items(): 367 | f.write( 368 | updates_template.format( 369 | update_name=k, 370 | description=escape_quotes(v["description"]), 371 | ) 372 | ) 373 | 374 | 375 | if __name__ == "__main__": 376 | with open("td_api.json", "r") as f: 377 | tl_json = json.loads(f.read()) 378 | 379 | with open("pytdbot/types/td_types/types.py", "w") as types_file: 380 | types_file.write("from typing import Union, Literal, List\n") 381 | types_file.write("from base64 import b64decode\n") 382 | types_file.write( 383 | f"from .bound_methods import {', '.join(set(bound_methods_class.values()))}\n" 384 | ) 385 | types_file.write("import pytdbot\n\n") 386 | types_file.write( 387 | """class TlObject: 388 | \"\"\"Base class for TL Objects\"\"\" 389 | 390 | def __getitem__(self, item): 391 | if item == "@type": 392 | return self.getType() 393 | 394 | return self.__dict__[item] 395 | 396 | def __setitem__(self, item, value): 397 | self.__dict__[item] = value 398 | 399 | def __bool__(self): 400 | return not isinstance(self, Error) 401 | 402 | @property 403 | def is_error(self): # for backward compatibility 404 | return isinstance(self, Error) 405 | 406 | @property 407 | def limited_seconds(self): 408 | if self.is_error and self.code == 429: 409 | return pytdbot.utils.get_retry_after_time(self.message) 410 | else: 411 | return 0 412 | 413 | def getType(self): 414 | raise NotImplementedError 415 | 416 | def getClass(self): 417 | raise NotImplementedError 418 | 419 | def to_dict(self) -> dict: 420 | raise NotImplementedError 421 | 422 | @classmethod 423 | def from_dict(cls, data: dict): 424 | raise NotImplementedError\n\n""" 425 | ) 426 | 427 | generate_classes(types_file, tl_json["classes"]) 428 | generate_types( 429 | types_file, tl_json["types"], tl_json["updates"], tl_json["classes"] 430 | ) 431 | 432 | with open("pytdbot/types/__init__.py", "w") as types_init_file: 433 | types_names = [ 434 | to_camel_case(name, is_class=True) 435 | for section in ("classes", "types", "updates") 436 | for name in tl_json[section].keys() 437 | ] 438 | 439 | all_classes = ( 440 | '__all__ = ["TlObject", "Plugins", ' 441 | + ", ".join(f'"{name}"' for name in types_names) 442 | + "]\n\n" 443 | ) 444 | types_init_file.write(all_classes) 445 | 446 | classes_import = f"from .td_types import TlObject, {', '.join(types_names)}\nfrom .plugins import Plugins" 447 | types_init_file.write(classes_import) 448 | types_init_file.write('\n\nTDLIB_VERSION = "{}"'.format(tl_json["version"])) 449 | 450 | with open("pytdbot/methods/td_functions.py", "w") as functions_file: 451 | functions_file.write("from typing import Union, List\nfrom .. import types\n\n") 452 | 453 | functions_file.write("class TDLibFunctions:\n") 454 | functions_file.write( 455 | f'{indent}"""A class that include all TDLib functions"""\n\n' 456 | ) 457 | 458 | generate_functions(functions_file, tl_json["functions"]) 459 | 460 | with open("pytdbot/handlers/td_updates.py", "w") as updates_file: 461 | updates_file.write( 462 | 'import pytdbot\n\nfrom .handler import Handler\nfrom typing import Callable\nfrom asyncio import iscoroutinefunction\nfrom logging import getLogger\n\nlogger = getLogger(__name__)\n\n\nclass Updates:\n """Auto generated TDLib updates"""\n\n' 463 | ) 464 | 465 | generate_updates(updates_file, tl_json["updates"]) 466 | -------------------------------------------------------------------------------- /generate_json.py: -------------------------------------------------------------------------------- 1 | import json 2 | import sys 3 | import re 4 | from pathlib import Path 5 | 6 | optional_tags = ("; may be null", "; pass null", "; may be empty", "If non-empty,") 7 | 8 | 9 | def is_optional(description): 10 | return any(tag in description for tag in optional_tags) 11 | 12 | 13 | def main(): 14 | if len(sys.argv) != 3: 15 | print("Usage: generate_json.py ") 16 | exit(1) 17 | 18 | data = { 19 | "name": "Auto-generated JSON TDLib API for Pytdbot ~ https://github.com/pytdbot/client", 20 | "version": sys.argv[1], 21 | "commit_hash": sys.argv[2], 22 | "classes": {}, 23 | "types": {}, 24 | "updates": {}, 25 | "functions": {}, 26 | } 27 | params = {} 28 | 29 | start = False 30 | is_functions = False 31 | 32 | description = "" 33 | 34 | class_regex = re.compile( 35 | r"//@class\s(?P.*)\s@description\s(?P.*)" 36 | ) 37 | parameter_regex = re.compile(r"@(.*?)\s+([^@]+)") 38 | end_param_regex = re.compile(r"(?P\w+):(?P[<\w>]+)") 39 | end_regex = re.compile(r"^(?P.*?)\s(?P.*)=\s(?P\w+);$") 40 | 41 | tl = Path("td_api.tl").read_text().replace("\n//-", " ") 42 | 43 | for line in tl.splitlines(): 44 | if "--functions--" in line: 45 | is_functions = True 46 | continue 47 | 48 | if line.startswith("//"): 49 | start = True 50 | 51 | if line != "" and start: 52 | if _class := class_regex.match(line): 53 | data["classes"][_class.group("name").strip()] = { 54 | "description": _class.group("description").strip(), 55 | "types": [], 56 | "functions": [], 57 | } 58 | elif _param := parameter_regex.findall(line): 59 | for name, _description in _param: 60 | if name.strip() == "description": 61 | description = _description.strip() 62 | else: 63 | params[name.replace("param_", "").strip()] = ( 64 | _description.strip() 65 | ) 66 | elif _end := end_regex.match(line): 67 | _data = { 68 | "description": description, 69 | "args": {}, 70 | "type": _end.group("type").strip(), 71 | } 72 | 73 | for keyv in end_param_regex.finditer(_end.group("params")): 74 | k, v = keyv.group("name").strip(), keyv.group("type").strip() 75 | _data["args"][k] = { 76 | "description": params[k], 77 | "is_optional": is_optional(params[k]), 78 | "type": v, 79 | } 80 | 81 | json_name = _end.group("name").strip() 82 | if is_functions: 83 | data["functions"][json_name] = _data 84 | if _data["type"] in data["classes"]: 85 | data["classes"][_data["type"]]["functions"].append(json_name) 86 | elif json_name.startswith("update"): 87 | data["updates"][json_name] = _data 88 | if _data["type"] in data["classes"]: 89 | data["classes"][_data["type"]]["types"].append(json_name) 90 | else: 91 | data["types"][json_name] = _data 92 | if _data["type"] in data["classes"]: 93 | data["classes"][_data["type"]]["types"].append(json_name) 94 | 95 | params = {} 96 | description = "" 97 | 98 | with open("td_api.json", "w") as f: 99 | f.write(json.dumps(data, indent=4)) 100 | print( 101 | "Classes: {}\nTypes: {}\nFunctions: {}\nUpdates: {}".format( 102 | len(data["classes"]), 103 | len(data["types"]), 104 | len(data["functions"]), 105 | len(data["updates"]), 106 | ) 107 | ) 108 | 109 | 110 | if __name__ == "__main__": 111 | main() 112 | -------------------------------------------------------------------------------- /pytdbot/__init__.py: -------------------------------------------------------------------------------- 1 | from . import types, utils, filters, exception 2 | from .tdjson import TdJson 3 | from .client_manager import ClientManager 4 | from .client import Client 5 | 6 | __all__ = [ 7 | "types", 8 | "utils", 9 | "filters", 10 | "exception", 11 | "TdJson", 12 | "ClientManager", 13 | "Client", 14 | ] 15 | 16 | __version__ = "0.9.3" 17 | __copyright__ = "Copyright (c) 2022-2025 Pytdbot, AYMENJD" 18 | __license__ = "MIT License" 19 | 20 | VERSION = __version__ 21 | -------------------------------------------------------------------------------- /pytdbot/client.py: -------------------------------------------------------------------------------- 1 | import asyncio 2 | import signal 3 | from importlib import import_module 4 | from json import dumps 5 | from logging import DEBUG, getLogger 6 | from os.path import join as join_path 7 | from pathlib import Path 8 | from platform import python_implementation, python_version 9 | from threading import current_thread, main_thread 10 | from typing import Callable, Dict, Union 11 | 12 | import aio_pika 13 | from deepdiff import DeepDiff 14 | 15 | import pytdbot 16 | 17 | from . import types 18 | from .client_manager import ClientManager 19 | from .exception import AuthorizationError, StopHandlers 20 | from .filters import Filter 21 | from .handlers import Decorators, Handler 22 | from .methods import Methods 23 | from .types import LogStream, Plugins 24 | from .utils import ( 25 | create_extra_id, 26 | dict_to_obj, 27 | get_bot_id_from_token, 28 | get_running_loop, 29 | json_dumps, 30 | json_loads, 31 | obj_to_dict, 32 | ) 33 | 34 | 35 | class Client(Decorators, Methods): 36 | r"""Pytdbot, a TDLib client 37 | 38 | Parameters: 39 | token (``str``, *optional*): 40 | Bot token 41 | 42 | api_id (``int``, *optional*): 43 | Identifier for Telegram API access, which can be obtained at https://my.telegram.org 44 | 45 | api_hash (``str``, *optional*): 46 | Identifier hash for Telegram API access, which can be obtained at https://my.telegram.org 47 | 48 | rabbitmq_url (``str``, *optional*): 49 | URL for RabbitMQ server connection 50 | 51 | instance_id (``str``, *optional*): 52 | Instance ID for RabbitMQ connections and queues. Default is ``None`` (random) 53 | 54 | lib_path (``str``, *optional*): 55 | Path to TDLib library. Default is ``None`` (auto-detect) 56 | 57 | plugins (:class:`~pytdbot.types.Plugins`, *optional*): 58 | Plugins to load 59 | 60 | default_parse_mode (``str``, *optional*): 61 | The default ``parse_mode`` for methods: :meth:`~pytdbot.Client.sendTextMessage`, :meth:`~pytdbot.Client.sendPhoto`, :meth:`~pytdbot.Client.sendAudio`, :meth:`~pytdbot.Client.sendVideo`, :meth:`~pytdbot.Client.sendDocument`, :meth:`~pytdbot.Client.sendAnimation`, :meth:`~pytdbot.Client.sendVoice`, :meth:`~pytdbot.Client.sendCopy`, :meth:`~pytdbot.Client.editTextMessage`; Default is ``None`` (Don\'t parse) 62 | Supported values: ``markdown``, ``markdownv2``, ``html`` 63 | 64 | system_language_code (``str``, *optional*): 65 | System language code. Default is ``en`` 66 | 67 | device_model (``str``, *optional*): 68 | Device model. Default is ``None`` (auto-detect) 69 | 70 | files_directory (``str``, *optional*): 71 | Directory for storing files and database 72 | 73 | database_encryption_key (``str`` | ``bytes``): 74 | Encryption key for database encryption 75 | 76 | use_test_dc (``bool``, *optional*): 77 | If set to true, the Telegram test environment will be used instead of the production environment. Default is ``False`` 78 | 79 | use_file_database (``bool``, *optional*): 80 | If set to true, information about downloaded and uploaded files will be saved between application restarts. Default is ``True`` 81 | 82 | use_chat_info_database (``bool``, *optional*): 83 | If set to true, the library will maintain a cache of users, basic groups, supergroups, channels and secret chats. Implies ``use_file_database``. Default is ``True`` 84 | 85 | use_message_database (``bool``, *optional*): 86 | If set to true, the library will maintain a cache of chats and messages. Implies use_chat_info_database. Default is ``True`` 87 | 88 | loop (:py:class:`asyncio.AbstractEventLoop`, *optional*): 89 | Event loop. Default is ``None`` (auto-detect) 90 | 91 | options (``dict``, *optional*): 92 | Pass key-value dictionary to set TDLib options. Check the list of available options at https://core.telegram.org/tdlib/options 93 | 94 | workers (``int``, *optional*): 95 | Number of workers to handle updates. Default is ``5``. If set to ``None``, updates will be immediately handled instead of being queued, which can impact performance. 96 | 97 | no_updates (``bool``, *optional*): 98 | Whether the client should handle updates or not. Applicable only when using [TDLib Server](https://github.com/pytdbot/tdlib-server). Default is ``False`` 99 | 100 | td_verbosity (``int``, *optional*): 101 | Verbosity level of TDLib. Default is ``2`` 102 | 103 | td_log (:class:`~pytdbot.types.LogStream`, *optional*): 104 | Log stream. Default is ``None`` (Log to ``stdout``) 105 | """ 106 | 107 | def __init__( 108 | self, 109 | token: str = None, 110 | api_id: int = None, 111 | api_hash: str = None, 112 | rabbitmq_url: str = None, 113 | instance_id: str = None, 114 | lib_path: str = None, 115 | plugins: Plugins = None, 116 | default_parse_mode: str = None, 117 | system_language_code: str = "en", 118 | device_model: str = None, 119 | files_directory: str = None, 120 | database_encryption_key: Union[str, bytes] = None, 121 | use_test_dc: bool = False, 122 | use_file_database: bool = True, 123 | use_chat_info_database: bool = True, 124 | use_message_database: bool = True, 125 | loop: asyncio.AbstractEventLoop = None, 126 | options: dict = None, 127 | workers: int = 5, 128 | no_updates: bool = False, 129 | td_verbosity: int = 2, 130 | td_log: LogStream = None, 131 | user_bot: bool = False, 132 | ) -> None: 133 | self.__api_id = api_id 134 | self.__api_hash = api_hash 135 | self.__rabbitmq_url = rabbitmq_url 136 | self._rabbitmq_instance_id = ( 137 | instance_id if isinstance(instance_id, str) else create_extra_id(4) 138 | ) 139 | self.__token = token 140 | self.__database_encryption_key = database_encryption_key 141 | self.files_directory = files_directory 142 | self.lib_path = lib_path 143 | self.plugins = plugins 144 | self.default_parse_mode = ( 145 | default_parse_mode 146 | if isinstance(default_parse_mode, str) 147 | and default_parse_mode.lower() in {"markdown", "markdownv2", "html"} 148 | else None 149 | ) 150 | self.system_language_code = system_language_code 151 | self.device_model = device_model 152 | self.use_test_dc = use_test_dc 153 | self.use_file_database = use_file_database 154 | self.use_chat_info_database = use_chat_info_database 155 | self.use_message_database = use_message_database 156 | self.td_options = options 157 | self.workers = workers 158 | self.no_updates = no_updates 159 | self.queue = asyncio.Queue() 160 | self.user_bot = user_bot 161 | self.my_id = ( 162 | get_bot_id_from_token(self.__token) 163 | if isinstance(self.__token, str) 164 | else None 165 | ) 166 | self.client_id = None 167 | self.client_manager = None 168 | self.logger = getLogger(f"{__name__}:{self.my_id or 0}") 169 | self.td_verbosity = td_verbosity 170 | self.td_log = td_log 171 | self.connection_state: str = None 172 | self.is_running = None 173 | self.me: types.User = None 174 | self.is_authenticated = False 175 | self.is_rabbitmq = True if rabbitmq_url else False 176 | self.options = {} 177 | self.allow_outgoing_message_types: tuple = (types.MessagePaymentRefunded,) 178 | 179 | self._check_init_args() 180 | 181 | self._handlers = {"initializer": [], "finalizer": []} 182 | self._results: Dict[str, asyncio.Future] = {} 183 | self._workers_tasks = None 184 | self.__authorization_state = None 185 | self.__cache = {"is_coro_filter": {}} 186 | self.__local_handlers = { 187 | "updateAuthorizationState": self.__handle_authorization_state, 188 | "updateMessageSendSucceeded": self.__handle_update_message_succeeded, 189 | "updateMessageSendFailed": self.__handle_update_message_failed, 190 | "updateConnectionState": self.__handle_connection_state, 191 | "updateOption": self.__handle_update_option, 192 | "updateUser": self.__handle_update_user, 193 | } 194 | self.__is_queue_worker = False 195 | self.__is_closing = False 196 | 197 | # RabbitMQ 198 | self.__rqueues = None 199 | self.__rconnection = None 200 | self.__rchannel = None 201 | 202 | self.loop = ( 203 | loop if isinstance(loop, asyncio.AbstractEventLoop) else get_running_loop() 204 | ) 205 | 206 | if plugins is not None: 207 | self._load_plugins() 208 | 209 | self.logger.info(f"Pytdbot v{pytdbot.VERSION}") 210 | 211 | async def __aenter__(self): 212 | await self.start() 213 | return self 214 | 215 | async def __aexit__(self, exc_type, exc_val, exc_tb): 216 | try: 217 | await self.stop() 218 | except Exception: 219 | pass 220 | 221 | @property 222 | def authorization_state(self) -> str: 223 | r"""Current authorization state""" 224 | 225 | return self.__authorization_state 226 | 227 | async def start(self) -> None: 228 | r"""Start pytdbot client""" 229 | 230 | if not self.is_running: 231 | self.logger.info("Starting pytdbot client...") 232 | 233 | if not self.client_manager: 234 | self.client_manager = ClientManager( 235 | self, self.lib_path, self.td_verbosity, loop=self.loop 236 | ) 237 | await self.client_manager.start() 238 | 239 | if isinstance(self.td_log, LogStream) and not self.is_rabbitmq: 240 | await self.__send( 241 | {"@type": "setLogStream", "log_stream": obj_to_dict(self.td_log)} 242 | ) 243 | 244 | if isinstance(self.workers, int): 245 | self._workers_tasks = [ 246 | self.loop.create_task(self._queue_update_worker()) 247 | for _ in range(self.workers) 248 | ] 249 | self.__is_queue_worker = True 250 | 251 | self.logger.info(f"Started with {self.workers} workers") 252 | else: 253 | self.__is_queue_worker = False 254 | self.logger.info("Started with unlimited updates processes") 255 | 256 | if self.is_rabbitmq: 257 | await self.__start_rabbitmq() 258 | else: # client_manager 259 | self.is_running = True 260 | 261 | self.loop.create_task( 262 | self.getOption("version") 263 | ) # Ping TDLib to start processing updates 264 | 265 | def add_handler( 266 | self, 267 | update_type: str, 268 | func: Callable, 269 | filters: pytdbot.filters.Filter = None, 270 | position: int = None, 271 | inner_object: bool = False, 272 | ) -> None: 273 | r"""Add an update handler 274 | 275 | Parameters: 276 | update_type (``str``): 277 | An update type 278 | 279 | func (``Callable``): 280 | A callable function 281 | 282 | filters (:class:`~pytdbot.filters.Filter`, *optional*): 283 | message filter 284 | 285 | position (``int``, *optional*): 286 | The function position in handlers list. Default is ``None`` (append) 287 | 288 | inner_object (``bool``, *optional*): 289 | Wether to pass an inner object of update or not; for example ``UpdateNewMessage.message``. Default is ``False`` 290 | 291 | Raises: 292 | TypeError 293 | """ 294 | 295 | if not isinstance(update_type, str): 296 | raise TypeError("update_type must be str") 297 | elif not isinstance(func, Callable): 298 | raise TypeError("func must be callable") 299 | elif filters is not None and not isinstance(filters, Filter): 300 | raise TypeError("filters must be instance of pytdbot.filters.Filter") 301 | else: 302 | func = Handler(func, update_type, filters, position, inner_object) 303 | if update_type not in self._handlers: 304 | self._handlers[update_type] = [] 305 | if isinstance(position, int): 306 | self._handlers[update_type].insert(position, func) 307 | else: 308 | self._handlers[update_type].append(func) 309 | self._handlers[update_type].sort(key=lambda x: (x.position is None, x.position)) 310 | 311 | def remove_handler(self, func: Callable) -> bool: 312 | r"""Remove an update handler 313 | 314 | Parameters: 315 | func (``Callable``): 316 | A callable function 317 | 318 | Raises: 319 | TypeError 320 | 321 | Returns: 322 | :py:class:`bool`: True if handler was removed, False otherwise 323 | """ 324 | 325 | if not isinstance(func, Callable): 326 | raise TypeError("func must be callable") 327 | for _, handlers in self._handlers.items(): 328 | for handler in handlers.copy(): 329 | if handler.func == func: 330 | handlers.remove(handler) 331 | handlers.sort(key=lambda x: (x.position is None, x.position)) 332 | return True 333 | return False 334 | 335 | async def invoke( 336 | self, 337 | request: dict, 338 | ) -> types.TlObject: 339 | r"""Invoke a new TDLib request 340 | 341 | Example: 342 | .. code-block:: python 343 | 344 | from pytdbot import Client 345 | 346 | async with Client(...) as client: 347 | res = await client.invoke({"@type": "getOption", "name": "version"}) 348 | if not isinstance(res, types.Error): 349 | print(res) 350 | 351 | Parameters: 352 | request (``dict``): 353 | The request to be sent 354 | 355 | Returns: 356 | :class:`~pytdbot.types.Result` 357 | """ 358 | 359 | request = obj_to_dict(request) 360 | 361 | request["@extra"] = {"id": create_extra_id()} 362 | 363 | future = self._create_request_future(request) 364 | 365 | if ( 366 | self.logger.root.level >= DEBUG or self.logger.level >= DEBUG 367 | ): # dumping all requests may create performance issues 368 | self.logger.debug(f"Sending: {dumps(request, indent=4)}") 369 | 370 | is_chat_attempted_load = request["@type"].lower() == "getchat" 371 | 372 | while True: 373 | future = self._create_request_future(request) 374 | await self.__send(request) 375 | result = await future 376 | 377 | if isinstance(result, types.Error): 378 | if result.code == 400: 379 | if result.message.startswith( 380 | "Failed to parse JSON object as TDLib request:" 381 | ): 382 | raise ValueError(result.message) 383 | 384 | if not is_chat_attempted_load and ( 385 | result.message == "Chat not found" and "chat_id" in request 386 | ): 387 | is_chat_attempted_load = True 388 | 389 | chat_id = request["chat_id"] 390 | 391 | self.logger.debug(f"Attempt to load chat {chat_id}") 392 | 393 | load_chat = await self.getChat(chat_id) 394 | 395 | if not isinstance(load_chat, types.Error): 396 | self.logger.debug(f"Chat {chat_id} is loaded") 397 | 398 | reply_to_message_id = (request.get("reply_to") or {}).get( 399 | "message_id", 0 400 | ) 401 | 402 | # if the request is a reply to another message 403 | # load the replied message to avoid "Message not found" 404 | if reply_to_message_id > 0: 405 | await self.getMessage(chat_id, reply_to_message_id) 406 | 407 | continue 408 | 409 | self.logger.error(f"Couldn't load chat {chat_id}") 410 | 411 | break 412 | 413 | return result 414 | 415 | async def call_method(self, method: str, **kwargs) -> types.TlObject: 416 | r"""Call a method. with keyword arguments (``kwargs``) support 417 | 418 | Example: 419 | .. code-block:: python 420 | 421 | from pytdbot import Client 422 | 423 | async with Client(...) as client: 424 | res = await client.call_method("getOption", name="version"}) 425 | if not isinstance(res, types.Error): 426 | print(res) 427 | 428 | Parameters: 429 | method (``str``): 430 | TDLib method name 431 | 432 | Returns: 433 | Any :class:`~pytdbot.types.TlObject` 434 | """ 435 | 436 | kwargs["@type"] = method 437 | 438 | return await self.invoke(kwargs) 439 | 440 | def run(self) -> None: 441 | r"""Start the client and block until the client is stopped 442 | 443 | Example: 444 | .. code-block:: python 445 | 446 | from pytdbot import Client 447 | 448 | client = Client(...) 449 | 450 | @client.on_updateNewMessage() 451 | async def new_message(c,update): 452 | await update.reply_text('Hello!') 453 | 454 | client.run() 455 | """ 456 | 457 | self._register_signal_handlers() 458 | 459 | self.loop.run_until_complete(self.start()) 460 | self.loop.run_until_complete(self.idle()) 461 | 462 | async def idle(self): 463 | r"""Idle and wait until the client is stopped.""" 464 | 465 | while self.is_running: 466 | await asyncio.sleep(1) 467 | 468 | async def stop(self) -> bool: 469 | r"""Stop the client 470 | 471 | Raises: 472 | `RuntimeError`: 473 | If the instance is already stopped 474 | 475 | Returns: 476 | :py:class:`bool`: ``True`` on success 477 | """ 478 | 479 | if ( 480 | self.is_running is False 481 | and self.authorization_state == "authorizationStateClosed" 482 | ): 483 | raise RuntimeError("Instance is not running") 484 | 485 | self.logger.info("Waiting for TDLib to close...") 486 | 487 | self.__is_closing = True 488 | 489 | if self.authorization_state not in { 490 | "authorizationStateClosing", 491 | "authorizationStateClosed", 492 | }: 493 | await self.close() 494 | 495 | while self.authorization_state != "authorizationStateClosed": 496 | await asyncio.sleep(0.1) 497 | 498 | if self.is_rabbitmq: 499 | await self.__rchannel.close() 500 | await self.__rconnection.close() 501 | 502 | self.__stop_client() 503 | 504 | if not self.client_manager.start_clients_on_add: 505 | await self.client_manager.close() 506 | 507 | self.logger.info("Instance closed") 508 | 509 | return True 510 | 511 | def _create_request_future( 512 | self, request: dict, result_id: str = None, handle_result: bool = True 513 | ) -> asyncio.Future: 514 | result = asyncio.Future() 515 | 516 | result.request = request 517 | 518 | if handle_result: 519 | self._results[ 520 | result_id if result_id is not None else request["@extra"]["id"] 521 | ] = result 522 | return result 523 | 524 | async def __send(self, request: dict) -> None: 525 | if self.is_rabbitmq: 526 | await self.__rchannel.default_exchange.publish( 527 | aio_pika.Message( 528 | json_dumps(request, encode=True), 529 | reply_to=self.__rqueues["responses"].name, 530 | ), 531 | routing_key=self.__rqueues["requests"].name, 532 | ) 533 | else: 534 | self.client_manager.send(self.client_id, request) 535 | 536 | def _check_init_args(self): 537 | if self.user_bot: 538 | return 539 | 540 | if not self.is_rabbitmq: 541 | if not isinstance(self.__api_id, int): 542 | raise TypeError("api_id must be an int") 543 | if not isinstance(self.__api_hash, str): 544 | raise TypeError("api_hash must be a str") 545 | if not isinstance(self.__database_encryption_key, (str, bytes)): 546 | raise TypeError("database_encryption_key must be str or bytes") 547 | if not isinstance(self.files_directory, str): 548 | raise TypeError("files_directory must be a str") 549 | if not isinstance(self.td_verbosity, int): 550 | raise TypeError("td_verbosity must be an int") 551 | 552 | if self.__token and not self.my_id: 553 | raise ValueError("Invalid bot token") 554 | 555 | if isinstance(self.workers, int) and self.workers < 1: 556 | raise ValueError("workers must be greater than 0") 557 | 558 | def _load_plugins(self): 559 | count = 0 560 | handlers = 0 561 | plugin_paths = sorted(Path(self.plugins.folder).rglob("*.py")) 562 | 563 | if self.plugins.include: 564 | plugin_paths = [ 565 | path 566 | for path in plugin_paths 567 | if ".".join(path.parent.parts + (path.stem,)) in self.plugins.include 568 | ] 569 | elif self.plugins.exclude: 570 | plugin_paths = [ 571 | path 572 | for path in plugin_paths 573 | if ".".join(path.parent.parts + (path.stem,)) 574 | not in self.plugins.exclude 575 | ] 576 | 577 | for path in plugin_paths: 578 | module_path = ".".join(path.parent.parts + (path.stem,)) 579 | 580 | try: 581 | module = import_module(module_path) 582 | except Exception: 583 | self.logger.exception(f"Failed to import plugin {module_path}") 584 | continue 585 | 586 | plugin_handlers_count = 0 587 | handlers_to_load = [] 588 | handlers_to_load += [ 589 | obj._handler 590 | for obj in vars(module).values() 591 | if hasattr(obj, "_handler") 592 | and isinstance(obj._handler, Handler) 593 | and obj._handler not in handlers_to_load 594 | ] 595 | 596 | for handler in handlers_to_load: 597 | if asyncio.iscoroutinefunction(handler.func): 598 | self.add_handler( 599 | handler.update_type, 600 | handler.func, 601 | handler.filter, 602 | handler.position, 603 | handler.inner_object, 604 | ) 605 | handlers += 1 606 | plugin_handlers_count += 1 607 | 608 | self.logger.debug( 609 | f"Handler {handler.func} added from {module_path}" 610 | ) 611 | else: 612 | self.logger.warning( 613 | f"Handler {handler.func} is not an async function from module {module_path}" 614 | ) 615 | count += 1 616 | 617 | self.logger.debug( 618 | f"Plugin {module_path} is fully imported with {plugin_handlers_count} handlers" 619 | ) 620 | 621 | self.logger.info(f"From {count} plugins got {handlers} handlers") 622 | 623 | def is_coro_filter(self, func: Callable) -> bool: 624 | if func in self.__cache["is_coro_filter"]: 625 | return self.__cache["is_coro_filter"][func] 626 | else: 627 | is_coro = asyncio.iscoroutinefunction(func) 628 | self.__cache["is_coro_filter"][func] = is_coro 629 | return is_coro 630 | 631 | async def process_update(self, update): 632 | if not update: 633 | self.logger.warning("Received None update") 634 | return 635 | 636 | if ( 637 | self.logger.root.level >= DEBUG or self.logger.level >= DEBUG 638 | ): # dumping all results may create performance issues 639 | self.logger.debug(f"Received: {dumps(update, indent=4)}") 640 | 641 | if "@extra" in update: 642 | if result := self._results.pop(update["@extra"]["id"], None): 643 | obj = dict_to_obj(update, self) 644 | 645 | result.set_result(obj) 646 | elif update["@type"] == "error" and "option" in update["@extra"]: 647 | self.logger.error(f"{update['@extra']['option']}: {update['message']}") 648 | 649 | else: 650 | update_handler = self.__local_handlers.get(update["@type"]) 651 | update = dict_to_obj(update, self) 652 | 653 | if update_handler: 654 | self.loop.create_task(update_handler(update)) 655 | 656 | if self.__is_queue_worker: 657 | self.queue.put_nowait(update) 658 | else: 659 | await self._handle_update(update) 660 | 661 | def get_inner_object(self, update: types.TlObject): 662 | if isinstance(update, types.UpdateNewMessage): 663 | return update.message 664 | return update 665 | 666 | async def __run_initializers(self, update): 667 | inner_object = self.get_inner_object(update) 668 | 669 | for initializer in self._handlers["initializer"]: 670 | try: 671 | handler_value = inner_object if initializer.inner_object else update 672 | 673 | if initializer.filter is not None: 674 | filter_function = initializer.filter.func 675 | 676 | if self.is_coro_filter(filter_function): 677 | if not await filter_function(self, handler_value): 678 | continue 679 | elif not filter_function(self, handler_value): 680 | continue 681 | 682 | await initializer(self, handler_value) 683 | except StopHandlers as e: 684 | raise e 685 | except Exception: 686 | self.logger.exception(f"Initializer {initializer} failed") 687 | 688 | async def __run_handlers(self, update): 689 | inner_object = self.get_inner_object(update) 690 | 691 | for handler in self._handlers[update.getType()]: 692 | try: 693 | handler_value = inner_object if handler.inner_object else update 694 | 695 | if handler.filter is not None: 696 | filter_function = handler.filter.func 697 | if self.is_coro_filter(filter_function): 698 | if not await filter_function(self, handler_value): 699 | continue 700 | elif not filter_function(self, handler_value): 701 | continue 702 | 703 | await handler(self, handler_value) 704 | except StopHandlers as e: 705 | raise e 706 | except Exception: 707 | self.logger.exception(f"Exception in {handler}") 708 | 709 | async def __run_finalizers(self, update): 710 | inner_object = self.get_inner_object(update) 711 | 712 | for finalizer in self._handlers["finalizer"]: 713 | try: 714 | handler_value = inner_object if finalizer.inner_object else update 715 | 716 | if finalizer.filter is not None: 717 | filter_function = finalizer.filter.func 718 | 719 | if self.is_coro_filter(filter_function): 720 | if not await filter_function(self, handler_value): 721 | continue 722 | elif not filter_function(self, handler_value): 723 | continue 724 | 725 | await finalizer(self, handler_value) 726 | except StopHandlers as e: 727 | raise e 728 | except Exception: 729 | self.logger.exception(f"Finalizer {finalizer} failed") 730 | 731 | async def _handle_update(self, update): 732 | if update.getType() in self._handlers: 733 | if ( 734 | not self.user_bot 735 | and isinstance(update, types.UpdateNewMessage) 736 | and not isinstance( 737 | update.message.content, self.allow_outgoing_message_types 738 | ) 739 | and update.message.is_outgoing 740 | ): 741 | return 742 | 743 | try: 744 | await self.__run_initializers(update) 745 | await self.__run_handlers(update) 746 | except StopHandlers: 747 | pass 748 | finally: 749 | await self.__run_finalizers(update) 750 | 751 | async def _queue_update_worker(self): 752 | self.is_running = True 753 | while self.is_running: 754 | try: 755 | await self._handle_update(await self.queue.get()) 756 | except Exception: 757 | self.logger.exception("Got worker exception") 758 | 759 | async def set_td_parameters(self): 760 | r"""Make a call to :meth:`~pytdbot.Client.setTdlibParameters` with the current client init parameters 761 | 762 | Raises: 763 | `AuthorizationError` 764 | """ 765 | 766 | if isinstance(self.__database_encryption_key, str): 767 | self.__database_encryption_key = self.__database_encryption_key.encode( 768 | "utf-8" 769 | ) 770 | 771 | res = await self.setTdlibParameters( 772 | use_test_dc=self.use_test_dc, 773 | api_id=self.__api_id, 774 | api_hash=self.__api_hash, 775 | system_language_code=self.system_language_code, 776 | device_model=f"{python_implementation()} {python_version()}", 777 | use_file_database=self.use_file_database, 778 | use_chat_info_database=self.use_chat_info_database, 779 | use_message_database=self.use_message_database, 780 | use_secret_chats=False, 781 | system_version=None, 782 | files_directory=self.files_directory, 783 | database_encryption_key=self.__database_encryption_key, 784 | database_directory=join_path(self.files_directory, "database"), 785 | application_version=f"Pytdbot {pytdbot.__version__}", 786 | ) 787 | if isinstance(res, types.Error): 788 | await self.stop() 789 | raise AuthorizationError(res.message) 790 | 791 | async def _set_options(self): 792 | if not isinstance(self.td_options, dict): 793 | return 794 | 795 | for k, v in self.td_options.items(): 796 | v_type = type(v) 797 | 798 | if v_type is str: 799 | data = {"@type": "optionValueString", "value": v} 800 | elif v_type is int: 801 | data = {"@type": "optionValueInteger", "value": v} 802 | elif v_type is bool: 803 | data = {"@type": "optionValueBoolean", "value": v} 804 | else: 805 | raise ValueError(f"Option {k} has unsupported type {v_type}") 806 | 807 | await self.__send( 808 | { 809 | "@type": "setOption", 810 | "name": k, 811 | "value": data, 812 | "@extra": {"option": k, "value": v, "id": ""}, 813 | } 814 | ) 815 | self.logger.debug(f"Option {k} sent with value {v}") 816 | 817 | async def __handle_authorization_state( 818 | self, update: types.UpdateAuthorizationState 819 | ): 820 | self.__authorization_state = update.authorization_state.getType() 821 | 822 | self.logger.info( 823 | f"Authorization state changed to {self.authorization_state.removeprefix('authorizationState')}" 824 | ) 825 | 826 | if self.authorization_state == "authorizationStateWaitTdlibParameters": 827 | await self._set_options() 828 | await self.set_td_parameters() 829 | elif self.authorization_state == "authorizationStateWaitPhoneNumber": 830 | self._print_welcome() 831 | await self.__handle_authorization_state_wait_phone_number() 832 | elif self.authorization_state == "authorizationStateReady": 833 | self.is_authenticated = True 834 | 835 | self.me = await self.getMe() 836 | if isinstance(self.me, types.Error): 837 | self.logger.error(f"Get me error: {self.me.message}") 838 | 839 | self.logger.info( 840 | f"Logged in as {self.me.first_name} " 841 | f"{str(self.me.id) if not self.me.usernames else '@' + self.me.usernames.editable_username}" 842 | ) 843 | 844 | if ( 845 | self.authorization_state == "authorizationStateClosed" 846 | and self.__is_closing is False 847 | ): 848 | await self.stop() 849 | 850 | async def __handle_connection_state(self, update: types.UpdateConnectionState): 851 | self.connection_state: str = update.state.getType() 852 | self.logger.info( 853 | f"Connection state changed to {self.connection_state.removeprefix('connectionState')}" 854 | ) 855 | 856 | async def __handle_update_message_succeeded( 857 | self, update: types.UpdateMessageSendSucceeded 858 | ): 859 | m_id = f"{update.message.chat_id}:{update.old_message_id}" 860 | 861 | if result := self._results.pop(m_id, None): 862 | result.set_result(update.message) 863 | 864 | async def __handle_update_message_failed( 865 | self, update: types.UpdateMessageSendFailed 866 | ): 867 | m_id = f"{update.message.chat_id}:{update.old_message_id}" 868 | 869 | if result := self._results.pop(m_id, None): 870 | result.set_result(update.error) 871 | 872 | async def __handle_update_option(self, update: types.UpdateOption): 873 | if isinstance(update.value, types.OptionValueBoolean): 874 | self.options[update.name] = bool(update.value.value) 875 | elif isinstance(update.value, types.OptionValueEmpty): 876 | self.options[update.name] = None 877 | elif isinstance(update.value, types.OptionValueInteger): 878 | self.options[update.name] = int(update.value.value) 879 | else: 880 | self.options[update.name] = update.value.value 881 | 882 | if update.name == "my_id": 883 | self.my_id = str(update.value.value) 884 | 885 | if self.is_authenticated: 886 | self.logger.info( 887 | f"Option {update.name} changed to {self.options[update.name]}" 888 | ) 889 | 890 | async def __get_updates_queue(self, retries=10, delay=2): 891 | for attempt in range(retries): 892 | try: 893 | return await self.__rchannel.get_queue(self.my_id + "_updates") 894 | except aio_pika.exceptions.ChannelNotFoundEntity: 895 | self.logger.warning( 896 | f"Attempt {attempt + 1}: TDLib Server is not running. Retrying in {delay} seconds..." 897 | ) 898 | await asyncio.sleep(delay) 899 | self.logger.error( 900 | f"Could not connect to TDLib Server after {retries} attempts." 901 | ) 902 | raise AuthorizationError( 903 | f"Could not connect to TDLib Server after {delay * retries} seconds timeout" 904 | ) 905 | 906 | async def __start_rabbitmq(self): 907 | self.__rconnection = await aio_pika.connect_robust( 908 | self.__rabbitmq_url, 909 | client_properties={ 910 | "connection_name": f"Pytdbot instance {self._rabbitmq_instance_id}" 911 | }, 912 | ) 913 | self.__rchannel = await self.__rconnection.channel() 914 | 915 | updates_queue = await self.__get_updates_queue() 916 | 917 | notify_queue = await self.__rchannel.declare_queue( 918 | f"notify_{self._rabbitmq_instance_id}", exclusive=True 919 | ) 920 | await notify_queue.bind(await self.__rchannel.get_exchange("broadcast")) 921 | 922 | responses_queue = await self.__rchannel.declare_queue( 923 | f"res_{self._rabbitmq_instance_id}", exclusive=True 924 | ) 925 | 926 | self.__rqueues = { 927 | "updates": updates_queue, 928 | "requests": await self.__rchannel.get_queue(self.my_id + "_requests"), 929 | "notify": notify_queue, 930 | "responses": responses_queue, 931 | } 932 | 933 | self.is_running = True 934 | 935 | await self.__rqueues["responses"].consume(self.__on_update, no_ack=True) 936 | 937 | await self._set_options() 938 | 939 | res = await self.getCurrentState() 940 | for update in res.updates: 941 | # when using obj_to_dict the key "@client_id" won't exists 942 | # since it's not part of the object 943 | await self.process_update(obj_to_dict(update)) 944 | 945 | if not self.no_updates: 946 | await self.__rqueues["updates"].consume(self.__on_update, no_ack=True) 947 | 948 | await self.__rqueues["notify"].consume(self.__on_update, no_ack=True) 949 | 950 | async def __handle_rabbitmq_message(self, message: aio_pika.IncomingMessage): 951 | await self.process_update(json_loads(message.body)) 952 | 953 | async def __on_update(self, update): 954 | self.loop.create_task(self.__handle_rabbitmq_message(update)) 955 | 956 | async def __handle_update_user(self, update: types.UpdateUser): 957 | if self.is_authenticated and self.me and update.user.id == self.me.id: 958 | self.logger.info( 959 | f"Updating {self.me.first_name} " 960 | f"({str(self.me.id) if not self.me.usernames else '@' + self.me.usernames.editable_username}) info" 961 | ) 962 | 963 | try: 964 | deepdiff(self, obj_to_dict(self.me), obj_to_dict(update.user)) 965 | except Exception: 966 | self.logger.exception("deepdiff failed") 967 | self.me = update.user 968 | 969 | async def __handle_authorization_state_wait_phone_number(self): 970 | if ( 971 | self.authorization_state != "authorizationStateWaitPhoneNumber" 972 | or not self.__token 973 | ): 974 | return 975 | 976 | res = await self.checkAuthenticationBotToken(self.__token) 977 | 978 | if isinstance(res, types.Error): 979 | await self.stop() 980 | raise AuthorizationError(res.message) 981 | 982 | def __stop_client(self) -> None: 983 | self.is_authenticated = False 984 | self.is_running = False 985 | 986 | if self.__is_queue_worker: 987 | for worker_task in self._workers_tasks: 988 | worker_task.cancel() 989 | 990 | def _register_signal_handlers(self): 991 | def _handle_signal(): 992 | self.loop.create_task(self.stop()) 993 | for sig in ( 994 | signal.SIGINT, 995 | signal.SIGTERM, 996 | signal.SIGABRT, 997 | signal.SIGSEGV, 998 | ): 999 | self.loop.remove_signal_handler(sig) 1000 | 1001 | if current_thread() is main_thread(): 1002 | try: 1003 | for sig in ( 1004 | signal.SIGINT, 1005 | signal.SIGTERM, 1006 | signal.SIGABRT, 1007 | signal.SIGSEGV, 1008 | ): 1009 | self.loop.add_signal_handler(sig, _handle_signal) 1010 | except NotImplementedError: # Windows dosen't support add_signal_handler 1011 | pass 1012 | 1013 | def _print_welcome(self): 1014 | print(f"Welcome to Pytdbot (v{pytdbot.__version__}). {pytdbot.__copyright__}") 1015 | print( 1016 | f"Pytdbot is free software and comes with ABSOLUTELY NO WARRANTY. Licensed under the terms of {pytdbot.__license__}.\n\n" 1017 | ) 1018 | 1019 | 1020 | def deepdiff(self, d1, d2): 1021 | d1 = obj_to_dict(d1) 1022 | if not isinstance(d1, dict) or not isinstance(d2, dict): 1023 | return d1 == d2 1024 | 1025 | deep = DeepDiff(d1, d2, ignore_order=True, view="tree") 1026 | 1027 | for parent, diffs in deep.items(): 1028 | for diff in diffs: 1029 | difflist = diff.path(output_format="list") 1030 | key = ".".join(map(str, difflist)) 1031 | 1032 | if parent in ("dictionary_item_added", "values_changed"): 1033 | self.logger.info(f"{key} changed to {diff.t2}") 1034 | elif parent == "dictionary_item_removed": 1035 | self.logger.info(f"{key} removed") 1036 | -------------------------------------------------------------------------------- /pytdbot/client_manager.py: -------------------------------------------------------------------------------- 1 | import asyncio 2 | import logging 3 | from typing import List 4 | from concurrent.futures import ThreadPoolExecutor 5 | 6 | import pytdbot 7 | from .tdjson import TdJson 8 | 9 | logger = logging.getLogger(__name__) 10 | 11 | 12 | class ClientManager: 13 | """Client manager for Pytdbot""" 14 | 15 | def __init__( 16 | self, 17 | clients: List["pytdbot.Client"] = None, 18 | lib_path: str = None, 19 | verbosity: int = 2, 20 | loop: asyncio.AbstractEventLoop = None, 21 | ) -> None: 22 | """Manage multiple Pytdbot clients 23 | 24 | Example: 25 | .. code-block:: python 26 | 27 | >>> from pytdbot import ClientManager, Client 28 | >>> clients = [Client(...), Client(...), Client(...)] 29 | >>> client_manager = ClientManager(clients) 30 | >>> await client_manager.start() 31 | 32 | Parameters: 33 | clients (``List[pytdbot.Client]``, *optional*): 34 | List of clients to manage 35 | 36 | lib_path (``str``, *optional*): 37 | Path to TDlib library 38 | 39 | verbosity (``int``, *optional*): 40 | Verbosity level of TDlib. Default is ``2`` 41 | 42 | loop (``asyncio.AbstractEventLoop``, *optional*): 43 | Event loop to use 44 | """ 45 | 46 | if clients and not isinstance(clients, (list, pytdbot.Client)): 47 | raise TypeError("clients must be a list of pytdbot.Client") 48 | 49 | self.loop = loop or pytdbot.utils.get_running_loop() 50 | self.__tdjson = TdJson(lib_path, verbosity) 51 | 52 | self.__clients: dict[int, pytdbot.Client] = {} 53 | 54 | if isinstance(clients, list): 55 | self.__pending_clients = clients 56 | self.start_clients_on_add = True 57 | elif isinstance(clients, pytdbot.Client): 58 | self.__pending_clients = [clients] 59 | self.start_clients_on_add = False 60 | else: 61 | self.__pending_clients = None 62 | self.start_clients_on_add = False 63 | 64 | self.__receiver_task = None 65 | self.__should_exit = False 66 | self.is_running = False 67 | 68 | async def start(self) -> None: 69 | """Start the Client Manager""" 70 | 71 | if self.is_running: 72 | return 73 | 74 | self.__receiver_task = self.loop.create_task(self.__td_receiver_loop()) 75 | 76 | for client in self.__pending_clients: 77 | await self.add_client(client, start_client=self.start_clients_on_add) 78 | 79 | self.__pending_clients = None 80 | 81 | async def add_client( 82 | self, client: "pytdbot.Client", start_client: bool = False 83 | ) -> None: 84 | """Add a client to the manager 85 | 86 | Parameters: 87 | client (``pytdbot.Client``): 88 | Client to add 89 | 90 | start_client (``bool``, *optional*): 91 | Whether to start the client immediately. Default is ``False`` 92 | """ 93 | 94 | if not isinstance(client, pytdbot.Client): 95 | raise TypeError("client must be an instance of pytdbot.Client") 96 | 97 | client_id = self.__tdjson.create_client_id() 98 | client.client_id = client_id 99 | client.client_manager = self 100 | 101 | self.__clients[client_id] = client 102 | 103 | if start_client: 104 | await client.start() 105 | 106 | logger.debug(f"Client {client_id} added") 107 | 108 | async def delete_client(self, client_id: int, close_client: bool = False) -> None: 109 | """Remove a client from the manager 110 | 111 | Parameters: 112 | client_id (``int``): 113 | ID of client to remove 114 | 115 | close_client (``bool``, *optional*): 116 | Whether to close the client before removing. Default is ``False`` 117 | """ 118 | 119 | client = self.__clients.pop(client_id, None) 120 | if not client: 121 | raise ValueError(f"Client with ID {client_id} not found") 122 | 123 | if close_client: 124 | await client.stop() 125 | logger.debug(f"Client {client_id} deleted") 126 | 127 | def send(self, client_id: int, request: dict) -> None: 128 | """Send a request to TDlib 129 | 130 | Parameters: 131 | client_id (``int``): 132 | ID of client to send request from 133 | 134 | request (``dict``): 135 | Request to send 136 | """ 137 | 138 | self.__tdjson.send(client_id, request) 139 | 140 | async def __td_receiver_loop(self) -> None: 141 | with ThreadPoolExecutor( 142 | max_workers=1, thread_name_prefix="ClientManager" 143 | ) as executor: 144 | try: 145 | self.is_running = True 146 | logger.info("ClientManager started") 147 | 148 | while not self.__should_exit: 149 | update = await self.loop.run_in_executor( 150 | executor, 151 | self.__tdjson.receive, 152 | 100000.0, # Seconds 153 | ) 154 | 155 | if not update or self.__should_exit: 156 | continue 157 | 158 | client = self.__clients.get(update["@client_id"]) 159 | if client: 160 | self.loop.create_task(client.process_update(update)) 161 | else: 162 | logger.warning( 163 | f"Unknown client ID in update: {update['@client_id']}" 164 | ) 165 | 166 | except Exception: 167 | logger.exception("Error in td_receiver") 168 | finally: 169 | self.is_running = False 170 | logger.debug("ClientManager stopped") 171 | 172 | async def close(self, close_all_clients: bool = False) -> bool: 173 | """Close the Client Manager 174 | 175 | Parameters: 176 | close_all_clients (``bool``, *optional*): 177 | Whether to close all managed clients. Default is ``False`` 178 | 179 | Returns: 180 | ``bool`` 181 | """ 182 | if self.__should_exit: 183 | return True 184 | 185 | self.__should_exit = True 186 | 187 | if close_all_clients: 188 | for client_id in list(self.__clients.keys()): 189 | await self.delete_client(client_id, close_client=True) 190 | 191 | # Send dummy request to wake up receiver 192 | self.send(0, {"@type": "getOption", "name": "version"}) 193 | 194 | await self.__receiver_task 195 | 196 | logger.info("ClientManager closed") 197 | return True 198 | -------------------------------------------------------------------------------- /pytdbot/exception/__init__.py: -------------------------------------------------------------------------------- 1 | __all__ = ("StopHandlers", "AuthorizationError") 2 | 3 | 4 | class StopHandlers(Exception): 5 | r"""An exception to stop handlers from execution""" 6 | 7 | pass 8 | 9 | 10 | class AuthorizationError(Exception): 11 | r"""An exception for authorization errors""" 12 | 13 | pass 14 | 15 | 16 | class WebAppDataInvalid(Exception): 17 | r"""An exception for invalid webapp data""" 18 | 19 | pass 20 | 21 | 22 | class WebAppDataOutdated(Exception): 23 | r"""An exception for outdated webapp data""" 24 | 25 | pass 26 | 27 | 28 | class WebAppDataMismatch(Exception): 29 | r"""An exception for mismatched webapp data""" 30 | 31 | pass 32 | -------------------------------------------------------------------------------- /pytdbot/filters.py: -------------------------------------------------------------------------------- 1 | from typing import Callable 2 | 3 | 4 | class Filter: 5 | r"""Filter class 6 | 7 | A filter is a function that takes a request and returns a boolean. If the returned value is ``True`` then the handler will be called. 8 | See :func:`~pytdbot.filters.create` for more information 9 | """ 10 | 11 | def __init__(self, func: Callable): 12 | self.func = func 13 | if not callable(func): 14 | raise TypeError("func must be callable") 15 | 16 | def __str__(self) -> str: 17 | return f"Filter(func={self.func})" 18 | 19 | def __repr__(self) -> str: 20 | return str(self) 21 | 22 | 23 | def create(func: Callable) -> Filter: 24 | r"""A factory to create a filter 25 | 26 | Example: 27 | 28 | .. code-block:: python 29 | 30 | from pytdbot import filters, Client 31 | 32 | client = Client(...) 33 | 34 | # Create a filter by a decorator 35 | @filters.create 36 | async def filter_photo(_, event) -> bool: 37 | if event.content_type == "messagePhoto": 38 | return True 39 | return False 40 | 41 | # Or by a function 42 | 43 | filter_photo = filters.create(filter_photo) 44 | 45 | # Or by lambda 46 | 47 | filter_photo = filters.create(lambda _, event: event.content_type == "messagePhoto") 48 | 49 | @client.on_updateNewMessage(filters=filter_photo) 50 | async def photo_handler(c,update): 51 | await update.reply_text('I got a photo!') 52 | 53 | client.run() 54 | 55 | Parameters: 56 | func (``Callable``): 57 | The filter function 58 | 59 | """ 60 | 61 | return Filter(func) 62 | -------------------------------------------------------------------------------- /pytdbot/handlers/__init__.py: -------------------------------------------------------------------------------- 1 | __all__ = ("Decorators", "Handler") 2 | 3 | from .decorators import Decorators 4 | from .handler import Handler 5 | -------------------------------------------------------------------------------- /pytdbot/handlers/decorators.py: -------------------------------------------------------------------------------- 1 | import logging 2 | from asyncio import iscoroutinefunction 3 | from typing import Callable 4 | 5 | import pytdbot 6 | 7 | from .handler import Handler 8 | from .td_updates import Updates 9 | 10 | logger = logging.getLogger(__name__) 11 | 12 | 13 | class Decorators(Updates): 14 | """Decorators class.""" 15 | 16 | def initializer( 17 | self: "pytdbot.Client" = None, 18 | filters: "pytdbot.filters.Filter" = None, 19 | position: int = None, 20 | inner_object: bool = False, 21 | ) -> None: 22 | r"""A decorator to initialize an event object before running other handlers 23 | 24 | Parameters: 25 | filters (:class:`~pytdbot.filters.Filter`, *optional*): 26 | An update filter 27 | 28 | position (``int``, *optional*): 29 | The function position in initializers list. Default is ``None`` (append) 30 | 31 | inner_object (``bool``, *optional*): 32 | Wether to pass an inner object of update or not; for example ``UpdateNewMessage.message``. Default is ``False`` 33 | 34 | Raises: 35 | :py:class:`TypeError` 36 | """ 37 | 38 | def decorator(func: Callable) -> Callable: 39 | if hasattr(func, "_handler"): 40 | return func 41 | elif isinstance(self, pytdbot.Client): 42 | if iscoroutinefunction(func): 43 | self.add_handler( 44 | "initializer", func, filters, position, inner_object 45 | ) 46 | else: 47 | raise TypeError("Handler must be async") 48 | elif isinstance(self, pytdbot.filters.Filter): 49 | func._handler = Handler( 50 | func, "initializer", self, position, inner_object 51 | ) 52 | else: 53 | func._handler = Handler( 54 | func, "initializer", filters, position, inner_object 55 | ) 56 | 57 | return func 58 | 59 | return decorator 60 | 61 | def finalizer( 62 | self: "pytdbot.Client" = None, 63 | filters: "pytdbot.filters.Filter" = None, 64 | position: int = None, 65 | inner_object: bool = False, 66 | ) -> None: 67 | r"""A decorator to finalize an event object after running all handlers 68 | 69 | Parameters: 70 | filters (:class:`~pytdbot.filters.Filter`, *optional*): 71 | An update filter 72 | 73 | position (``int``, *optional*): 74 | The function position in finalizers list. Default is ``None`` (append) 75 | 76 | inner_object (``bool``, *optional*): 77 | Wether to pass an inner object of update or not; for example ``UpdateNewMessage.message``. Default is ``False`` 78 | 79 | Raises: 80 | :py:class:`TypeError` 81 | """ 82 | 83 | def decorator(func: Callable) -> Callable: 84 | if hasattr(func, "_handler"): 85 | return func 86 | elif isinstance(self, pytdbot.Client): 87 | if iscoroutinefunction(func): 88 | self.add_handler("finalizer", func, filters, position, inner_object) 89 | else: 90 | raise TypeError("Handler must be async") 91 | elif isinstance(self, pytdbot.filters.Filter): 92 | func._handler = Handler(func, "finalizer", self, position, inner_object) 93 | else: 94 | func._handler = Handler( 95 | func, "finalizer", filters, position, inner_object 96 | ) 97 | return func 98 | 99 | return decorator 100 | 101 | def on_message( 102 | self: "pytdbot.Client" = None, 103 | filters: "pytdbot.filters.Filter" = None, 104 | position: int = None, 105 | ) -> None: 106 | r"""A decorator to handle ``updateNewMessage`` but with ``Message`` object. 107 | 108 | Parameters: 109 | filters (:class:`~pytdbot.filters.Filter`, *optional*): 110 | An update filter 111 | 112 | position (``int``, *optional*): 113 | The function position in handlers list. Default is ``None`` (append) 114 | 115 | Raises: 116 | :py:class:`TypeError` 117 | """ 118 | 119 | def decorator(func: Callable) -> Callable: 120 | if hasattr(func, "_handler"): 121 | return func 122 | elif isinstance(self, pytdbot.Client): 123 | if iscoroutinefunction(func): 124 | self.add_handler("updateNewMessage", func, filters, position, True) 125 | else: 126 | raise TypeError("Handler must be async") 127 | elif isinstance(self, pytdbot.filters.Filter): 128 | func._handler = Handler(func, "updateNewMessage", self, position, True) 129 | else: 130 | func._handler = Handler( 131 | func, "updateNewMessage", filters, position, True 132 | ) 133 | 134 | return func 135 | 136 | return decorator 137 | -------------------------------------------------------------------------------- /pytdbot/handlers/handler.py: -------------------------------------------------------------------------------- 1 | from typing import Callable 2 | 3 | import pytdbot 4 | 5 | from ..filters import Filter 6 | 7 | 8 | class Handler: 9 | r"""A handler class.""" 10 | 11 | def __init__( 12 | self, 13 | func: Callable, 14 | update_type: str, 15 | filter: Filter = None, 16 | position: int = None, 17 | inner_object: bool = False, 18 | ) -> None: 19 | self.func = func 20 | self.update_type = update_type 21 | self.filter = filter 22 | self.position = position 23 | self.inner_object = inner_object 24 | 25 | def __call__(self, client: "pytdbot.Client", update: "pytdbot.types.Update"): 26 | return self.func(client, update) 27 | 28 | def __str__(self) -> str: 29 | return f"Handler(func={self.func}, update_type={self.update_type}, filter={self.filter}, position={self.position}, inner_object={self.inner_object})" 30 | 31 | def __repr__(self) -> str: 32 | return str(self) 33 | -------------------------------------------------------------------------------- /pytdbot/methods/__init__.py: -------------------------------------------------------------------------------- 1 | __all__ = ("Methods",) 2 | 3 | from .methods import Methods 4 | -------------------------------------------------------------------------------- /pytdbot/tdjson/__init__.py: -------------------------------------------------------------------------------- 1 | __all__ = ("TdJson",) 2 | 3 | from .tdjson import TdJson 4 | -------------------------------------------------------------------------------- /pytdbot/tdjson/tdjson.py: -------------------------------------------------------------------------------- 1 | try: 2 | import tdjson 3 | except ImportError: 4 | tdjson = None 5 | 6 | import sys 7 | from ctypes import CDLL, c_char_p, c_double, c_int 8 | from logging import getLogger 9 | from typing import Union 10 | 11 | from ..utils import JSON_ENCODER, json_dumps, json_loads 12 | 13 | logger = getLogger(__name__) 14 | 15 | 16 | class TdJson: 17 | def __init__(self, lib_path: str = None, verbosity: int = 2) -> None: 18 | """TdJson client 19 | 20 | Parameters: 21 | lib_path (``str``, optional): 22 | Path to shared library; if ``None`` then [`tdjson`](https://github.com/AYMENJD/tdjson) binding will be used. Default is ``None`` 23 | 24 | verbosity (``int``, optional): 25 | TDLib verbosity level. Default is ``2`` 26 | 27 | Raises: 28 | :py:class:``ValueError``: If library not found 29 | """ 30 | 31 | self._build_client(lib_path, verbosity) 32 | 33 | def __enter__(self): 34 | return self 35 | 36 | def __exit__(self, exc_type, exc_value, traceback): 37 | pass 38 | 39 | def _build_client(self, lib_path: str, verbosity: int) -> None: 40 | """Build TdJson client 41 | 42 | Parameters: 43 | lib_path (``str``): 44 | Path to shared library 45 | 46 | verbosity (``int``): 47 | TDLib verbosity level 48 | """ 49 | 50 | self.using_binding = False 51 | 52 | if lib_path is None: 53 | if not tdjson: 54 | raise ValueError( 55 | f"tdjson binding not found. Try install using: `{sys.executable} -m pip install --upgrade tdjson`" 56 | ) 57 | 58 | # Use tdjson binding that already include TDLib 59 | self._td_create_client_id = tdjson.td_create_client_id 60 | self._td_send = tdjson.td_send 61 | self._td_receive = tdjson.td_receive 62 | self._td_execute = tdjson.td_execute 63 | 64 | self.using_binding = True 65 | 66 | logger.info(f"Using tdjson binding {tdjson.__version__}") 67 | else: 68 | if not lib_path: 69 | raise ValueError( 70 | "Could not find TDLib, provide full path to libtdjson.so in lib_path" 71 | ) 72 | 73 | logger.info(f"Initializing TdJson client with library: {lib_path}") 74 | 75 | self._tdjson = CDLL(lib_path) 76 | 77 | # load TDLib functions from shared library 78 | self._td_create_client_id = self._tdjson.td_create_client_id 79 | self._td_create_client_id.restype = c_int 80 | self._td_create_client_id.argtypes = [] 81 | 82 | self._td_receive = self._tdjson.td_receive 83 | self._td_receive.restype = c_char_p 84 | self._td_receive.argtypes = [c_double] 85 | 86 | self._td_send = self._tdjson.td_send 87 | self._td_send.restype = None 88 | self._td_send.argtypes = [c_int, c_char_p] 89 | 90 | self._td_execute = self._tdjson.td_execute 91 | self._td_execute.restype = c_char_p 92 | self._td_execute.argtypes = [c_char_p] 93 | 94 | td_version, td_commit_hash = ( 95 | self.execute({"@type": "getOption", "name": "version"}), 96 | self.execute({"@type": "getOption", "name": "commit_hash"}), 97 | ) 98 | 99 | logger.info( 100 | f"Using TDLib {td_version['value']} ({td_commit_hash['value'][:9]}) with {JSON_ENCODER} encoder" 101 | ) 102 | 103 | if isinstance(verbosity, int): 104 | res = self.execute( 105 | {"@type": "setLogVerbosityLevel", "new_verbosity_level": verbosity} 106 | ) 107 | 108 | if res["@type"] == "error": 109 | logger.error("Can't set log level: {}".format(res["message"])) 110 | 111 | def create_client_id(self) -> int: 112 | """Returns an opaque identifier of a new TDLib instance""" 113 | return self._td_create_client_id() 114 | 115 | def receive(self, timeout: float = 2.0) -> Union[None, dict]: 116 | """Receives incoming updates and results from TDLib 117 | 118 | Parameters: 119 | timeout (``float``, *optional*): 120 | The maximum number of seconds allowed to wait for new data. Default is ``2.0`` 121 | 122 | Returns: 123 | :py:class:``dict``: An incoming update or result to a request. If no data is received, ``None`` is returned 124 | """ 125 | 126 | if res := self._td_receive( 127 | timeout if self.using_binding else c_double(timeout) 128 | ): 129 | return json_loads(res) 130 | 131 | def send(self, client_id: int, data: dict) -> None: 132 | """Sends a request to TDLib 133 | 134 | Parameters: 135 | client_id (``int``): 136 | TDLib Client identifier 137 | 138 | data (``dict``): 139 | Request to be sent 140 | """ 141 | 142 | if client_id is None: 143 | raise ValueError("client_id is required") 144 | 145 | self._td_send(client_id, json_dumps(data, encode=not self.using_binding)) 146 | 147 | def execute(self, data: dict) -> Union[None, dict]: 148 | """Executes a TDLib request 149 | 150 | Parameters: 151 | data (``dict``): The request to be executed 152 | 153 | Returns: 154 | :py:class:``dict``: The result of the request 155 | """ 156 | 157 | if res := self._td_execute(json_dumps(data, encode=not self.using_binding)): 158 | return json_loads(res) 159 | -------------------------------------------------------------------------------- /pytdbot/types/plugins/__init__.py: -------------------------------------------------------------------------------- 1 | __all__ = ("Plugins",) 2 | 3 | 4 | class Plugins: 5 | """Load and filter plugins from a folder""" 6 | 7 | def __init__(self, folder: str, include: list = None, exclude: list = None) -> None: 8 | """ 9 | Parameters: 10 | folder (``str``): 11 | The folder to load plugins from 12 | 13 | include (``list``, *optional*): 14 | Only load plugins with names in this list 15 | 16 | exclude (``list``, *optional*): 17 | Exclude plugins with names in this list 18 | 19 | Example: 20 | To load only the plugins with path "plugins/rules.py" and "plugins/subfolder1/commands.py", 21 | you should create the ``Plugins`` object like this: 22 | 23 | >>> plugins = Plugins( 24 | folder="plugins/", 25 | include=[ 26 | "rules" # will be translated to "plugins.rules" 27 | "subfolder1.commands" # -> plugins.subfolder1.commands 28 | ] 29 | ) 30 | Raises: 31 | TypeError 32 | """ 33 | 34 | if not isinstance(folder, str): 35 | raise TypeError("folder must be str") 36 | elif include is not None and not isinstance(include, list): 37 | raise TypeError("include must be list or None") 38 | elif exclude is not None and not isinstance(exclude, list): 39 | raise TypeError("include must be list or None") 40 | 41 | self.folder = folder 42 | self.include = include 43 | self.exclude = exclude 44 | -------------------------------------------------------------------------------- /pytdbot/types/td_types/__init__.py: -------------------------------------------------------------------------------- 1 | from .types import * 2 | -------------------------------------------------------------------------------- /pytdbot/types/td_types/bound_methods/__init__.py: -------------------------------------------------------------------------------- 1 | __all__ = ["MessageBoundMethods", "FileBoundMethods", "CallbackQueryBoundMethods"] 2 | from .message import MessageBoundMethods 3 | from .file import FileBoundMethods 4 | from .callback_query import CallbackQueryBoundMethods 5 | -------------------------------------------------------------------------------- /pytdbot/types/td_types/bound_methods/callback_query.py: -------------------------------------------------------------------------------- 1 | from typing import List, Union 2 | from functools import lru_cache 3 | 4 | import pytdbot 5 | 6 | 7 | class CallbackQueryBoundMethods: 8 | def __init__(self): 9 | self._client: pytdbot.Client 10 | 11 | @property 12 | @lru_cache(1) 13 | def text(self) -> str: 14 | r"""Callback data decoded as str""" 15 | 16 | if isinstance(self.payload, pytdbot.types.CallbackQueryPayloadData): 17 | return self.payload.data.decode("utf-8") 18 | 19 | return "" 20 | 21 | async def getMessage(self) -> Union["pytdbot.types.Error", "pytdbot.types.Message"]: 22 | r"""Get callback query message""" 23 | 24 | if self.message_id: 25 | return await self._client.getMessage( 26 | chat_id=self.chat_id, message_id=self.message_id 27 | ) 28 | 29 | async def answer( 30 | self, 31 | text: str, 32 | show_alert: bool = None, 33 | url: str = None, 34 | cache_time: int = None, 35 | ) -> Union["pytdbot.types.Error", "pytdbot.types.Ok"]: 36 | r"""Answer to callback query. Shortcut for :meth:`~pytdbot.Client.answerCallbackQuery`""" 37 | 38 | return await self._client.answerCallbackQuery( 39 | self.id, text=text, show_alert=show_alert, url=url, cache_time=cache_time 40 | ) 41 | 42 | async def edit_message_text( 43 | self, 44 | text: str, 45 | parse_mode: str = None, 46 | entities: list = None, 47 | disable_web_page_preview: bool = False, 48 | url: str = None, 49 | force_small_media: bool = None, 50 | force_large_media: bool = None, 51 | show_above_text: bool = None, 52 | reply_markup: "pytdbot.types.ReplyMarkup" = None, 53 | ) -> Union["pytdbot.types.Error", "pytdbot.types.Message"]: 54 | r"""Edit callback query message text. Shortcut for :meth:`~pytdbot.Client.editTextMessage`""" 55 | 56 | return await self._client.editTextMessage( 57 | chat_id=self.chat_id, 58 | message_id=self.message_id, 59 | text=text, 60 | parse_mode=parse_mode, 61 | entities=entities, 62 | disable_web_page_preview=disable_web_page_preview, 63 | url=url, 64 | force_small_media=force_small_media, 65 | force_large_media=force_large_media, 66 | show_above_text=show_above_text, 67 | reply_markup=reply_markup, 68 | ) 69 | 70 | async def edit_message_caption( 71 | self, 72 | caption: str, 73 | caption_entities: List["pytdbot.types.TextEntity"] = None, 74 | parse_mode: str = None, 75 | show_caption_above_media: bool = None, 76 | reply_markup: "pytdbot.types.ReplyMarkup" = None, 77 | ) -> Union["pytdbot.types.Error", "pytdbot.types.Message"]: 78 | r"""Edit message caption""" 79 | 80 | parse_mode = parse_mode or self._client.default_parse_mode 81 | if isinstance(caption_entities, list): 82 | caption = pytdbot.types.FormattedText( 83 | text=caption, entities=caption_entities 84 | ) 85 | elif parse_mode and isinstance(parse_mode, str): 86 | parse = await self._client.parseText(caption, parse_mode=parse_mode) 87 | if isinstance(parse, pytdbot.types.Error): 88 | return parse 89 | caption = parse 90 | else: 91 | caption = pytdbot.types.FormattedText(caption) 92 | 93 | return await self._client.editMessageCaption( 94 | chat_id=self.chat_id, 95 | message_id=self.message_id, 96 | caption=caption, 97 | show_caption_above_media=show_caption_above_media, 98 | reply_markup=reply_markup, 99 | ) 100 | 101 | # TODO: edit_message_media? 102 | 103 | async def edit_message_reply_markup( 104 | self, reply_markup: "pytdbot.types.ReplyMarkup" 105 | ) -> Union["pytdbot.types.Error", "pytdbot.types.Message"]: 106 | r"""Edit message reply markup. Shortcut for :meth:`~pytdbot.Client.editMessageReplyMarkup`""" 107 | 108 | return await self._client.editMessageReplyMarkup( 109 | chat_id=self.chat_id, message_id=self.message_id, reply_markup=reply_markup 110 | ) 111 | -------------------------------------------------------------------------------- /pytdbot/types/td_types/bound_methods/chatActions.py: -------------------------------------------------------------------------------- 1 | from asyncio import sleep 2 | from typing import Literal 3 | 4 | import pytdbot 5 | 6 | 7 | class ChatActions: 8 | def __init__( 9 | self, 10 | client: "pytdbot.Client", 11 | chat_id: int, 12 | action: Literal[ 13 | "typing", 14 | "upload_photo", 15 | "record_video", 16 | "upload_video", 17 | "record_voice", 18 | "upload_voice", 19 | "upload_document", 20 | "choose_sticker", 21 | "find_location", 22 | "record_video_note", 23 | "upload_video_note", 24 | "cancel", 25 | ], 26 | message_thread_id: int = None, 27 | ) -> None: 28 | self.client = client 29 | self.chat_id = chat_id 30 | self.action = None 31 | self.__task = None 32 | self.message_thread_id = message_thread_id or 0 33 | 34 | assert isinstance(self.message_thread_id, int), "message_thread_id must be int" 35 | self.setAction(action) 36 | 37 | def __await__(self): 38 | return self.sendAction().__await__() 39 | 40 | async def __aenter__(self): 41 | await self.sendAction() 42 | self.__task = self.client.loop.create_task(self.__loop_action()) 43 | return self 44 | 45 | async def __aexit__(self, exc_type, exc, traceback): 46 | await self.stop() 47 | 48 | async def sendAction(self): 49 | return await self.client.sendChatAction( 50 | chat_id=self.chat_id, 51 | message_thread_id=self.message_thread_id, 52 | action=self.action, 53 | ) 54 | 55 | def setAction( 56 | self, 57 | action: Literal[ 58 | "typing", 59 | "upload_photo", 60 | "record_video", 61 | "upload_video", 62 | "record_voice", 63 | "upload_voice", 64 | "upload_document", 65 | "choose_sticker", 66 | "find_location", 67 | "record_video_note", 68 | "upload_video_note", 69 | "cancel", 70 | ], 71 | ): 72 | if action == "typing" or action == "chatActionTyping": 73 | self.action = pytdbot.types.ChatActionTyping() 74 | elif action == "upload_photo" or action == "chatActionUploadingPhoto": 75 | self.action = pytdbot.types.ChatActionUploadingPhoto() 76 | elif action == "record_video" or action == "chatActionRecordingVideo": 77 | self.action = pytdbot.types.ChatActionRecordingVideo() 78 | elif action == "upload_video" or action == "chatActionUploadingVideo": 79 | self.action = pytdbot.types.ChatActionUploadingVideo() 80 | elif action == "record_voice" or action == "chatActionRecordingVoiceNote": 81 | self.action = pytdbot.types.ChatActionRecordingVoiceNote() 82 | elif action == "upload_voice" or action == "chatActionUploadingVoiceNote": 83 | self.action = pytdbot.types.ChatActionUploadingVoiceNote() 84 | elif action == "upload_document" or action == "chatActionUploadingDocument": 85 | self.action = pytdbot.types.ChatActionUploadingDocument() 86 | elif action == "choose_sticker" or action == "chatActionChoosingSticker": 87 | self.action = pytdbot.types.ChatActionChoosingSticker() 88 | elif action == "find_location" or action == "chatActionChoosingLocation": 89 | self.action = pytdbot.types.ChatActionChoosingLocation() 90 | elif action == "record_video_note" or action == "chatActionRecordingVideoNote": 91 | self.action = pytdbot.types.ChatActionRecordingVideoNote() 92 | elif action == "upload_video_note" or action == "chatActionUploadingVideoNote": 93 | self.action = pytdbot.types.ChatActionUploadingVideoNote() 94 | elif action == "cancel" or action == "chatActionCancel": 95 | self.action = pytdbot.types.ChatActionCancel() 96 | else: 97 | raise ValueError(f"Unknown action type {action}") 98 | 99 | async def __loop_action(self): 100 | while True: 101 | await sleep(4) 102 | await self.sendAction() 103 | 104 | async def stop(self): 105 | self.setAction("cancel") 106 | self.__task.cancel() 107 | await self.sendAction() 108 | -------------------------------------------------------------------------------- /pytdbot/types/td_types/bound_methods/file.py: -------------------------------------------------------------------------------- 1 | from typing import Union 2 | 3 | import pytdbot 4 | 5 | 6 | class FileBoundMethods: 7 | def __init__(self): 8 | self._client: pytdbot.Client 9 | 10 | async def download( 11 | self, 12 | priority: int = 1, 13 | offset: int = 0, 14 | limit: int = 0, 15 | synchronous: bool = True, 16 | ) -> Union["pytdbot.types.Error", "pytdbot.types.File"]: 17 | r"""Downloads a file. Shortcut for :meth:`~pytdbot.Client.downloadFile`""" 18 | 19 | file_id = None 20 | if isinstance(self, pytdbot.types.RemoteFile): 21 | file_info = await self._client.getRemoteFile(self.id) 22 | if not file_info: 23 | return file_info 24 | 25 | file_id = file_info.id 26 | elif isinstance(self, pytdbot.types.File): 27 | file_id = self.id 28 | 29 | if file_id: 30 | return await self._client.downloadFile( 31 | file_id=file_id, 32 | priority=priority, 33 | offset=offset, 34 | limit=limit, 35 | synchronous=synchronous, 36 | ) 37 | -------------------------------------------------------------------------------- /pytdbot/types/td_types/bound_methods/message.py: -------------------------------------------------------------------------------- 1 | from functools import lru_cache 2 | from typing import List, Literal, Union 3 | 4 | import pytdbot 5 | 6 | from .chatActions import ChatActions 7 | 8 | 9 | class MessageBoundMethods: 10 | def __init__(self): 11 | self._client: pytdbot.Client 12 | 13 | @property 14 | @lru_cache(1) 15 | def from_id(self) -> Union[int, None]: 16 | r"""Message Sender ID""" 17 | 18 | if isinstance(self.sender_id, pytdbot.types.MessageSenderChat): 19 | return self.sender_id.chat_id 20 | elif isinstance(self.sender_id, pytdbot.types.MessageSenderUser): 21 | return self.sender_id.user_id 22 | 23 | @property 24 | @lru_cache(1) 25 | def reply_to_message_id(self) -> int: 26 | r"""Replied message ID""" 27 | 28 | if isinstance(self.reply_to, pytdbot.types.MessageReplyToMessage): 29 | return self.reply_to.message_id 30 | 31 | @property 32 | @lru_cache(1) 33 | def text(self) -> str: 34 | r"""Text of the message""" 35 | 36 | if isinstance(self.content, pytdbot.types.MessageText): 37 | return self.content.text.text 38 | 39 | return "" 40 | 41 | @property 42 | @lru_cache(1) 43 | def entities(self) -> Union[List["pytdbot.types.TextEntity"], None]: 44 | r"""Entities of the message""" 45 | 46 | if isinstance(self.content, pytdbot.types.MessageText): 47 | return self.content.text.entities 48 | 49 | @property 50 | @lru_cache(1) 51 | def caption(self) -> Union[str, None]: 52 | r"""Caption of the received media""" 53 | 54 | if isinstance( 55 | self.content, 56 | ( 57 | pytdbot.types.MessagePhoto, 58 | pytdbot.types.MessageVideo, 59 | pytdbot.types.MessageAnimation, 60 | pytdbot.types.MessageAudio, 61 | pytdbot.types.MessageDocument, 62 | pytdbot.types.MessageVoiceNote, 63 | ), 64 | ): 65 | return self.content.caption.text 66 | 67 | @property 68 | @lru_cache(1) 69 | def caption_entities(self) -> Union[List["pytdbot.types.TextEntity"], None]: 70 | r"""Caption entities of the received media""" 71 | 72 | if isinstance( 73 | self.content, 74 | ( 75 | pytdbot.types.MessagePhoto, 76 | pytdbot.types.MessageVideo, 77 | pytdbot.types.MessageAnimation, 78 | pytdbot.types.MessageAudio, 79 | pytdbot.types.MessageDocument, 80 | pytdbot.types.MessageVoiceNote, 81 | ), 82 | ): 83 | return self.content.caption.entities 84 | 85 | @property 86 | @lru_cache(1) 87 | def remote_file_id(self) -> Union[str, None]: 88 | r"""Remote file id""" 89 | 90 | file_id = None 91 | if isinstance(self.content, pytdbot.types.MessagePhoto): 92 | file_id = self.content.photo.sizes[-1].photo.remote.id 93 | elif isinstance(self.content, pytdbot.types.MessageVideo): 94 | file_id = self.content.video.video.remote.id 95 | elif isinstance(self.content, pytdbot.types.MessageSticker): 96 | file_id = self.content.sticker.sticker.remote.id 97 | elif isinstance(self.content, pytdbot.types.MessageAnimation): 98 | file_id = self.content.animation.animation.remote.id 99 | elif isinstance(self.content, pytdbot.types.MessageAudio): 100 | file_id = self.content.audio.audio.remote.id 101 | elif isinstance(self.content, pytdbot.types.MessageDocument): 102 | file_id = self.content.document.document.remote.id 103 | elif isinstance(self.content, pytdbot.types.MessageVoiceNote): 104 | file_id = self.content.voice_note.voice.remote.id 105 | elif isinstance(self.content, pytdbot.types.MessageVideoNote): 106 | file_id = self.content.video_note.video.remote.id 107 | 108 | return file_id 109 | 110 | @property 111 | @lru_cache(1) 112 | def remote_unique_file_id(self) -> Union[str, None]: 113 | r"""Remote unique file id""" 114 | 115 | unique_file_id = None 116 | if isinstance(self.content, pytdbot.types.MessagePhoto): 117 | unique_file_id = self.content.photo.sizes[-1].photo.remote.unique_id 118 | elif isinstance(self.content, pytdbot.types.MessageVideo): 119 | unique_file_id = self.content.video.video.remote.unique_id 120 | elif isinstance(self.content, pytdbot.types.MessageSticker): 121 | unique_file_id = self.content.sticker.sticker.remote.unique_id 122 | elif isinstance(self.content, pytdbot.types.MessageAnimation): 123 | unique_file_id = self.content.animation.animation.remote.unique_id 124 | elif isinstance(self.content, pytdbot.types.MessageAudio): 125 | unique_file_id = self.content.audio.audio.remote.unique_id 126 | elif isinstance(self.content, pytdbot.types.MessageDocument): 127 | unique_file_id = self.content.document.document.remote.unique_id 128 | elif isinstance(self.content, pytdbot.types.MessageVoiceNote): 129 | unique_file_id = self.content.voice_note.voice.remote.unique_id 130 | elif isinstance(self.content, pytdbot.types.MessageVideoNote): 131 | unique_file_id = self.content.video_note.video.remote.unique_id 132 | 133 | return unique_file_id 134 | 135 | async def mention(self, parse_mode: str = "html") -> Union[str, None]: 136 | r"""Get the text_mention of the message sender 137 | 138 | Parameters: 139 | parse_mode (``str``, *optional*): 140 | The parse mode of the mention. Default is ``html`` 141 | """ 142 | 143 | chat = await self._client.getChat(self.from_id) 144 | if chat: 145 | return pytdbot.utils.mention( 146 | chat.title, 147 | self.from_id, 148 | html=True if parse_mode.lower() == "html" else False, 149 | ) 150 | 151 | async def getMessageProperties( 152 | self, 153 | ) -> Union["pytdbot.types.Error", "pytdbot.types.MessageProperties"]: 154 | r"""Get the message properties""" 155 | 156 | return await self._client.getMessageProperties( 157 | chat_id=self.chat_id, 158 | message_id=self.id, 159 | ) 160 | 161 | async def getMessageLink( 162 | self, 163 | media_timestamp: int = 0, 164 | for_album: bool = False, 165 | in_message_thread: bool = False, 166 | ) -> Union["pytdbot.types.Error", "pytdbot.types.MessageLink"]: 167 | r"""Get message link 168 | 169 | Parameters: 170 | media_timestamp (:class:`int`): 171 | If not 0, timestamp from which the video/audio/video note/voice note/story playing must start, in seconds\. The media can be in the message content or in its link preview 172 | 173 | for_album (:class:`bool`): 174 | Pass true to create a link for the whole media album 175 | 176 | in_message_thread (:class:`bool`): 177 | Pass true to create a link to the message as a channel post comment, in a message thread, or a forum topic 178 | """ 179 | 180 | return await self._client.getMessageLink( 181 | chat_id=self.chat_id, 182 | message_id=self.id, 183 | media_timestamp=media_timestamp, 184 | for_album=for_album, 185 | in_message_thread=in_message_thread, 186 | ) 187 | 188 | async def getRepliedMessage( 189 | self, 190 | ) -> Union["pytdbot.types.Error", "pytdbot.types.Message"]: 191 | r"""Get the replied message""" 192 | 193 | return await self._client.getRepliedMessage( 194 | chat_id=self.chat_id, 195 | message_id=self.id, 196 | ) 197 | 198 | async def getChat( 199 | self, 200 | ) -> Union["pytdbot.types.Error", "pytdbot.types.Chat"]: 201 | r"""Get chat info""" 202 | 203 | return await self._client.getChat(self.chat_id) 204 | 205 | async def getChatMember( 206 | self, 207 | ) -> Union["pytdbot.types.Error", "pytdbot.types.ChatMember"]: 208 | r"""Get member info in the current chat""" 209 | 210 | return await self._client.getChatMember( 211 | chat_id=self.chat_id, member_id=self.sender_id 212 | ) 213 | 214 | async def getUser( 215 | self, 216 | ) -> Union["pytdbot.types.Error", "pytdbot.types.User"]: 217 | r"""Get user info""" 218 | 219 | return await self._client.getUser(self.from_id) 220 | 221 | async def setChatMemberStatus( 222 | self, 223 | status: "pytdbot.types.ChatMemberStatus", 224 | ) -> Union["pytdbot.types.Error", "pytdbot.types.Ok"]: 225 | r"""Set chat member status""" 226 | 227 | return await self._client.setChatMemberStatus( 228 | chat_id=self.chat_id, member_id=self.sender_id, status=status 229 | ) 230 | 231 | async def leaveChat(self) -> Union["pytdbot.types.Error", "pytdbot.types.Ok"]: 232 | r"""Leave the current chat""" 233 | 234 | return await self._client.leaveChat(self.chat_id) 235 | 236 | async def ban( 237 | self, banned_until_date: int = None 238 | ) -> Union["pytdbot.types.Error", "pytdbot.types.Ok"]: 239 | r"""Ban the message sender 240 | 241 | Parameters: 242 | 243 | banned_until_date (``int``): 244 | Point in time (Unix timestamp) when the user will be unbanned; 0 if never. If the user is banned for more than 366 days or for less than 30 seconds from the current time, the user is considered to be banned forever. Always 0 in basic groups 245 | """ 246 | 247 | return await self.setChatMemberStatus( 248 | status=pytdbot.types.ChatMemberStatusBanned( 249 | banned_until_date=banned_until_date 250 | ) 251 | ) 252 | 253 | async def delete( 254 | self, 255 | revoke: bool = True, 256 | ) -> Union["pytdbot.types.Error", "pytdbot.types.Ok"]: 257 | r"""Delete the received message 258 | 259 | Parameters: 260 | revoke (``bool``, *optional*): 261 | Pass true to delete messages for all chat members. Always true for supergroups, channels and secret chats 262 | """ 263 | 264 | return await self._client.deleteMessages( 265 | chat_id=self.chat_id, message_ids=[self.id], revoke=revoke 266 | ) 267 | 268 | async def react( 269 | self, emoji: str = "👍", is_big: bool = False 270 | ) -> Union["pytdbot.types.Error", "pytdbot.types.Ok"]: 271 | r"""React to the current message 272 | 273 | Parameters: 274 | emoji (``str``, *optional*): 275 | Text representation of the reaction; pass ``None`` to remove the current reaction. Default is ``👍`` 276 | 277 | is_big (``bool``, *optional*): 278 | Pass true if the reactions are added with a big animation. Default is ``False`` 279 | """ 280 | 281 | return await self._client.setMessageReactions( 282 | chat_id=self.chat_id, 283 | message_id=self.id, 284 | reaction_types=None 285 | if not emoji 286 | else [pytdbot.types.ReactionTypeEmoji(emoji=emoji)], 287 | is_big=is_big, 288 | ) 289 | 290 | async def pin( 291 | self, 292 | disable_notification: bool = False, 293 | only_for_self: bool = False, 294 | ) -> Union["pytdbot.types.Error", "pytdbot.types.Ok"]: 295 | r"""Pin the message 296 | 297 | Parameters: 298 | disable_notification (``bool``, *optional*): 299 | If True, disable notification for the message 300 | 301 | only_for_self (``bool``, *optional*): 302 | True, if the message needs to be pinned for one side only; private chats only 303 | """ 304 | 305 | return await self._client.pinChatMessage( 306 | chat_id=self.chat_id, 307 | message_id=self.id, 308 | disable_notification=disable_notification, 309 | only_for_self=only_for_self, 310 | ) 311 | 312 | async def unpin(self) -> Union["pytdbot.types.Error", "pytdbot.types.Ok"]: 313 | r"""Unpin the message""" 314 | 315 | return await self._client.unpinChatMessage( 316 | chat_id=self.chat_id, message_id=self.id 317 | ) 318 | 319 | async def download( 320 | self, 321 | priority: int = 1, 322 | offset: int = 0, 323 | limit: int = 0, 324 | synchronous: bool = True, 325 | ) -> Union["pytdbot.types.Error", "pytdbot.types.LocalFile"]: 326 | r"""Download the media file and returns ``LocalFile`` object. Shortcut for :meth:`~pytdbot.Client.downloadFile`.""" 327 | 328 | res = None 329 | if isinstance(self.content, pytdbot.types.MessagePhoto): 330 | res = await self.content.photo.sizes[-1].photo.download( 331 | priority=priority, offset=offset, limit=limit, synchronous=synchronous 332 | ) 333 | elif isinstance(self.content, pytdbot.types.MessageVideo): 334 | res = await self.content.video.video.download( 335 | priority=priority, offset=offset, limit=limit, synchronous=synchronous 336 | ) 337 | elif isinstance(self.content, pytdbot.types.MessageSticker): 338 | res = await self.content.sticker.sticker.download( 339 | priority=priority, offset=offset, limit=limit, synchronous=synchronous 340 | ) 341 | elif isinstance(self.content, pytdbot.types.MessageAnimation): 342 | res = await self.content.animation.animation.download( 343 | priority=priority, offset=offset, limit=limit, synchronous=synchronous 344 | ) 345 | elif isinstance(self.content, pytdbot.types.MessageAudio): 346 | res = await self.content.audio.audio.download( 347 | priority=priority, offset=offset, limit=limit, synchronous=synchronous 348 | ) 349 | elif isinstance(self.content, pytdbot.types.MessageDocument): 350 | res = await self.content.document.document.download( 351 | priority=priority, offset=offset, limit=limit, synchronous=synchronous 352 | ) 353 | elif isinstance(self.content, pytdbot.types.MessageVoiceNote): 354 | res = await self.content.voice_note.voice.download( 355 | priority=priority, offset=offset, limit=limit, synchronous=synchronous 356 | ) 357 | elif isinstance(self.content, pytdbot.types.MessageVideoNote): 358 | res = await self.content.video_note.video.download( 359 | priority=priority, offset=offset, limit=limit, synchronous=synchronous 360 | ) 361 | 362 | if isinstance(res, pytdbot.types.Error): 363 | return res 364 | elif isinstance(res, pytdbot.types.File): 365 | return res.local 366 | 367 | def action( 368 | self, 369 | action: Literal[ 370 | "typing", 371 | "upload_photo", 372 | "record_video", 373 | "upload_video", 374 | "record_voice", 375 | "upload_voice", 376 | "upload_document", 377 | "choose_sticker", 378 | "find_location", 379 | "record_video_note", 380 | "upload_video_note", 381 | "cancel", 382 | ], 383 | message_thread_id: int = None, 384 | ) -> ChatActions: 385 | r"""Sends a chat action to a specific chat. Supporting context manager (``with`` statement) 386 | 387 | \Example: 388 | 389 | 390 | .. code-block:: python 391 | 392 | async with update.action("record_video") as action: 393 | ## Any blocking operation 394 | await asyncio.sleep(10) 395 | action.setAction("upload_video") # change the action to uploading a video 396 | 397 | Or 398 | 399 | 400 | .. code-block:: python 401 | 402 | await update.action("typing") 403 | ## Any blocking operation 404 | await asyncio.sleep(2) 405 | await update.reply_text("Hello?") 406 | 407 | \Parameters: 408 | action (``str``): 409 | Type of action to broadcast. Choose one, depending on what the user is about to receive: ``typing`` for text messages, ``upload_photo`` for photos, ``record_video`` or ``upload_video`` for videos, ``record_voice`` or ``upload_voice`` for voice notes, ``upload_document`` for general files, ``choose_sticker`` for stickers, ``find_location` for location data, ``record_video_note`` or ``upload_video_note`` for video notes 410 | 411 | message_thread_id (``int``, *optional*): 412 | If not 0, a message thread identifier in which the action was performed. Default is ``None`` 413 | """ 414 | 415 | return ChatActions( 416 | client=self._client, 417 | chat_id=self.chat_id, 418 | action=action, 419 | message_thread_id=message_thread_id, 420 | ) 421 | 422 | async def reply_text( 423 | self, 424 | text: str, 425 | entities: List["pytdbot.types.TextEntity"] = None, 426 | parse_mode: str = None, 427 | disable_web_page_preview: bool = False, 428 | url: str = None, 429 | force_small_media: bool = None, 430 | force_large_media: bool = None, 431 | show_above_text: bool = None, 432 | clear_draft: bool = False, 433 | disable_notification: bool = False, 434 | protect_content: bool = False, 435 | allow_paid_broadcast: bool = False, 436 | message_thread_id: int = 0, 437 | quote: "pytdbot.types.InputTextQuote" = None, 438 | reply_markup: Union[ 439 | "pytdbot.types.ReplyMarkupInlineKeyboard", 440 | "pytdbot.types.ReplyMarkupShowKeyboard", 441 | "pytdbot.types.ReplyMarkupForceReply", 442 | "pytdbot.types.ReplyMarkupRemoveKeyboard", 443 | ] = None, 444 | ) -> Union["pytdbot.types.Error", "pytdbot.types.Message"]: 445 | r"""Reply to the message with text. Shortcut for :meth:`~pytdbot.Client.sendTextMessage`.""" 446 | 447 | return await self._client.sendTextMessage( 448 | chat_id=self.chat_id, 449 | text=text, 450 | entities=entities, 451 | parse_mode=parse_mode, 452 | disable_web_page_preview=disable_web_page_preview, 453 | url=url, 454 | force_small_media=force_small_media, 455 | force_large_media=force_large_media, 456 | show_above_text=show_above_text, 457 | clear_draft=clear_draft, 458 | disable_notification=disable_notification, 459 | protect_content=protect_content, 460 | allow_paid_broadcast=allow_paid_broadcast, 461 | message_thread_id=message_thread_id, 462 | quote=quote, 463 | reply_to_message_id=self.id, 464 | reply_markup=reply_markup, 465 | ) 466 | 467 | async def reply_animation( 468 | self, 469 | animation: Union["pytdbot.types.InputFile", str], 470 | thumbnail: "pytdbot.types.InputThumbnail" = None, 471 | caption: str = None, 472 | caption_entities: list = None, 473 | parse_mode: str = None, 474 | added_sticker_file_ids: list = None, 475 | duration: int = 0, 476 | width: int = 0, 477 | height: int = 0, 478 | disable_notification: bool = False, 479 | protect_content: bool = False, 480 | allow_paid_broadcast: bool = False, 481 | has_spoiler: bool = False, 482 | message_thread_id: int = 0, 483 | quote: "pytdbot.types.InputTextQuote" = None, 484 | reply_markup: Union[ 485 | "pytdbot.types.ReplyMarkupInlineKeyboard", 486 | "pytdbot.types.ReplyMarkupShowKeyboard", 487 | "pytdbot.types.ReplyMarkupForceReply", 488 | "pytdbot.types.ReplyMarkupRemoveKeyboard", 489 | ] = None, 490 | ) -> Union["pytdbot.types.Error", "pytdbot.types.Message"]: 491 | r"""Reply to the message with animation. Shortcut for :meth:`~pytdbot.Client.sendAnimation`.""" 492 | 493 | return await self._client.sendAnimation( 494 | chat_id=self.chat_id, 495 | animation=animation, 496 | thumbnail=thumbnail, 497 | caption=caption, 498 | caption_entities=caption_entities, 499 | parse_mode=parse_mode, 500 | added_sticker_file_ids=added_sticker_file_ids, 501 | duration=duration, 502 | width=width, 503 | height=height, 504 | disable_notification=disable_notification, 505 | protect_content=protect_content, 506 | allow_paid_broadcast=allow_paid_broadcast, 507 | has_spoiler=has_spoiler, 508 | message_thread_id=message_thread_id, 509 | quote=quote, 510 | reply_to_message_id=self.id, 511 | reply_markup=reply_markup, 512 | ) 513 | 514 | async def reply_audio( 515 | self, 516 | audio: Union["pytdbot.types.InputFile", str], 517 | album_cover_thumbnail: "pytdbot.types.InputThumbnail" = None, 518 | caption: str = None, 519 | caption_entities: list = None, 520 | parse_mode: str = None, 521 | title: str = None, 522 | performer: str = None, 523 | duration: int = 0, 524 | disable_notification: bool = False, 525 | protect_content: bool = False, 526 | allow_paid_broadcast: bool = False, 527 | message_thread_id: int = 0, 528 | quote: "pytdbot.types.InputTextQuote" = None, 529 | reply_markup: Union[ 530 | "pytdbot.types.ReplyMarkupInlineKeyboard", 531 | "pytdbot.types.ReplyMarkupShowKeyboard", 532 | "pytdbot.types.ReplyMarkupForceReply", 533 | "pytdbot.types.ReplyMarkupRemoveKeyboard", 534 | ] = None, 535 | ) -> Union["pytdbot.types.Error", "pytdbot.types.Message"]: 536 | r"""Reply to the message with audio. Shortcut for :meth:`~pytdbot.Client.sendAudio`.""" 537 | 538 | return await self._client.sendAudio( 539 | chat_id=self.chat_id, 540 | audio=audio, 541 | album_cover_thumbnail=album_cover_thumbnail, 542 | caption=caption, 543 | caption_entities=caption_entities, 544 | parse_mode=parse_mode, 545 | title=title, 546 | performer=performer, 547 | duration=duration, 548 | disable_notification=disable_notification, 549 | protect_content=protect_content, 550 | allow_paid_broadcast=allow_paid_broadcast, 551 | message_thread_id=message_thread_id, 552 | quote=quote, 553 | reply_to_message_id=self.id, 554 | reply_markup=reply_markup, 555 | ) 556 | 557 | async def reply_document( 558 | self, 559 | document: Union["pytdbot.types.InputFile", str], 560 | thumbnail: "pytdbot.types.InputThumbnail" = None, 561 | caption: str = None, 562 | caption_entities: list = None, 563 | parse_mode: str = None, 564 | disable_content_type_detection: bool = True, 565 | disable_notification: bool = False, 566 | protect_content: bool = False, 567 | allow_paid_broadcast: bool = False, 568 | message_thread_id: int = 0, 569 | quote: "pytdbot.types.InputTextQuote" = None, 570 | reply_markup: Union[ 571 | "pytdbot.types.ReplyMarkupInlineKeyboard", 572 | "pytdbot.types.ReplyMarkupShowKeyboard", 573 | "pytdbot.types.ReplyMarkupForceReply", 574 | "pytdbot.types.ReplyMarkupRemoveKeyboard", 575 | ] = None, 576 | ) -> Union["pytdbot.types.Error", "pytdbot.types.Message"]: 577 | r"""Reply to the message with a document. Shortcut for :meth:`~pytdbot.Client.sendDocument`.""" 578 | 579 | return await self._client.sendDocument( 580 | chat_id=self.chat_id, 581 | document=document, 582 | thumbnail=thumbnail, 583 | caption=caption, 584 | caption_entities=caption_entities, 585 | parse_mode=parse_mode, 586 | disable_content_type_detection=disable_content_type_detection, 587 | disable_notification=disable_notification, 588 | protect_content=protect_content, 589 | allow_paid_broadcast=allow_paid_broadcast, 590 | message_thread_id=message_thread_id, 591 | quote=quote, 592 | reply_to_message_id=self.id, 593 | reply_markup=reply_markup, 594 | ) 595 | 596 | async def reply_photo( 597 | self, 598 | photo: Union["pytdbot.types.InputFile", str], 599 | thumbnail: "pytdbot.types.InputThumbnail" = None, 600 | caption: str = None, 601 | caption_entities: list = None, 602 | parse_mode: str = None, 603 | added_sticker_file_ids: list = None, 604 | width: int = 0, 605 | height: int = 0, 606 | self_destruct_type: "pytdbot.types.MessageSelfDestructType" = None, 607 | disable_notification: bool = False, 608 | protect_content: bool = False, 609 | allow_paid_broadcast: bool = False, 610 | has_spoiler: bool = False, 611 | message_thread_id: int = 0, 612 | quote: "pytdbot.types.InputTextQuote" = None, 613 | reply_markup: Union[ 614 | "pytdbot.types.ReplyMarkupInlineKeyboard", 615 | "pytdbot.types.ReplyMarkupShowKeyboard", 616 | "pytdbot.types.ReplyMarkupForceReply", 617 | "pytdbot.types.ReplyMarkupRemoveKeyboard", 618 | ] = None, 619 | ) -> Union["pytdbot.types.Error", "pytdbot.types.Message"]: 620 | r"""Reply to the message with a photo. Shortcut for :meth:`~pytdbot.Client.sendPhoto`.""" 621 | 622 | return await self._client.sendPhoto( 623 | chat_id=self.chat_id, 624 | photo=photo, 625 | thumbnail=thumbnail, 626 | caption=caption, 627 | caption_entities=caption_entities, 628 | parse_mode=parse_mode, 629 | added_sticker_file_ids=added_sticker_file_ids, 630 | width=width, 631 | height=height, 632 | self_destruct_type=self_destruct_type, 633 | disable_notification=disable_notification, 634 | protect_content=protect_content, 635 | allow_paid_broadcast=allow_paid_broadcast, 636 | has_spoiler=has_spoiler, 637 | message_thread_id=message_thread_id, 638 | quote=quote, 639 | reply_to_message_id=self.id, 640 | reply_markup=reply_markup, 641 | ) 642 | 643 | async def reply_video( 644 | self, 645 | video: Union["pytdbot.types.InputFile", str], 646 | thumbnail: "pytdbot.types.InputThumbnail" = None, 647 | caption: str = None, 648 | caption_entities: list = None, 649 | parse_mode: str = None, 650 | added_sticker_file_ids: list = None, 651 | supports_streaming: bool = None, 652 | duration: int = 0, 653 | width: int = 0, 654 | height: int = 0, 655 | self_destruct_type: "pytdbot.types.MessageSelfDestructType" = None, 656 | disable_notification: bool = False, 657 | protect_content: bool = False, 658 | allow_paid_broadcast: bool = False, 659 | has_spoiler: bool = False, 660 | message_thread_id: int = 0, 661 | quote: "pytdbot.types.InputTextQuote" = None, 662 | reply_markup: Union[ 663 | "pytdbot.types.ReplyMarkupInlineKeyboard", 664 | "pytdbot.types.ReplyMarkupShowKeyboard", 665 | "pytdbot.types.ReplyMarkupForceReply", 666 | "pytdbot.types.ReplyMarkupRemoveKeyboard", 667 | ] = None, 668 | ) -> Union["pytdbot.types.Error", "pytdbot.types.Message"]: 669 | r"""Reply to the message with a video. Shortcut for :meth:`~pytdbot.Client.sendVideo`.""" 670 | 671 | return await self._client.sendVideo( 672 | chat_id=self.chat_id, 673 | video=video, 674 | thumbnail=thumbnail, 675 | caption=caption, 676 | caption_entities=caption_entities, 677 | parse_mode=parse_mode, 678 | added_sticker_file_ids=added_sticker_file_ids, 679 | supports_streaming=supports_streaming, 680 | duration=duration, 681 | width=width, 682 | height=height, 683 | self_destruct_type=self_destruct_type, 684 | disable_notification=disable_notification, 685 | protect_content=protect_content, 686 | allow_paid_broadcast=allow_paid_broadcast, 687 | has_spoiler=has_spoiler, 688 | message_thread_id=message_thread_id, 689 | quote=quote, 690 | reply_to_message_id=self.id, 691 | reply_markup=reply_markup, 692 | ) 693 | 694 | async def reply_video_note( 695 | self, 696 | video_note: Union["pytdbot.types.InputFile", str], 697 | thumbnail: "pytdbot.types.InputThumbnail" = None, 698 | duration: int = 0, 699 | length: int = 0, 700 | disable_notification: bool = False, 701 | protect_content: bool = False, 702 | allow_paid_broadcast: bool = False, 703 | message_thread_id: int = 0, 704 | quote: "pytdbot.types.InputTextQuote" = None, 705 | reply_markup: Union[ 706 | "pytdbot.types.ReplyMarkupInlineKeyboard", 707 | "pytdbot.types.ReplyMarkupShowKeyboard", 708 | "pytdbot.types.ReplyMarkupForceReply", 709 | "pytdbot.types.ReplyMarkupRemoveKeyboard", 710 | ] = None, 711 | ) -> Union["pytdbot.types.Error", "pytdbot.types.Message"]: 712 | r"""Reply to the message with a video note. Shortcut for :meth:`~pytdbot.Client.sendVideoNote`.""" 713 | 714 | return await self._client.sendVideoNote( 715 | chat_id=self.chat_id, 716 | video_note=video_note, 717 | thumbnail=thumbnail, 718 | duration=duration, 719 | length=length, 720 | disable_notification=disable_notification, 721 | protect_content=protect_content, 722 | allow_paid_broadcast=allow_paid_broadcast, 723 | message_thread_id=message_thread_id, 724 | quote=quote, 725 | reply_to_message_id=self.id, 726 | reply_markup=reply_markup, 727 | ) 728 | 729 | async def reply_voice( 730 | self, 731 | voice: Union["pytdbot.types.InputFile", str], 732 | caption: str = None, 733 | caption_entities: list = None, 734 | parse_mode: str = None, 735 | duration: int = 0, 736 | waveform: bytes = None, 737 | disable_notification: bool = False, 738 | protect_content: bool = False, 739 | allow_paid_broadcast: bool = False, 740 | message_thread_id: int = 0, 741 | quote: "pytdbot.types.InputTextQuote" = None, 742 | reply_markup: Union[ 743 | "pytdbot.types.ReplyMarkupInlineKeyboard", 744 | "pytdbot.types.ReplyMarkupShowKeyboard", 745 | "pytdbot.types.ReplyMarkupForceReply", 746 | "pytdbot.types.ReplyMarkupRemoveKeyboard", 747 | ] = None, 748 | ) -> Union["pytdbot.types.Error", "pytdbot.types.Message"]: 749 | r"""Reply to the message with a voice note. Shortcut for :meth:`~pytdbot.Client.sendVoice`.""" 750 | 751 | return await self._client.sendVoice( 752 | chat_id=self.chat_id, 753 | voice=voice, 754 | caption=caption, 755 | caption_entities=caption_entities, 756 | parse_mode=parse_mode, 757 | duration=duration, 758 | waveform=waveform, 759 | disable_notification=disable_notification, 760 | protect_content=protect_content, 761 | allow_paid_broadcast=allow_paid_broadcast, 762 | message_thread_id=message_thread_id, 763 | quote=quote, 764 | reply_to_message_id=self.id, 765 | reply_markup=reply_markup, 766 | ) 767 | 768 | async def reply_sticker( 769 | self, 770 | sticker: Union["pytdbot.types.InputFile", str], 771 | emoji: str = None, 772 | thumbnail: "pytdbot.types.InputThumbnail" = None, 773 | width: int = 0, 774 | height: int = 0, 775 | disable_notification: bool = False, 776 | protect_content: bool = False, 777 | allow_paid_broadcast: bool = False, 778 | message_thread_id: int = 0, 779 | quote: "pytdbot.types.InputTextQuote" = None, 780 | reply_markup: Union[ 781 | "pytdbot.types.ReplyMarkupInlineKeyboard", 782 | "pytdbot.types.ReplyMarkupShowKeyboard", 783 | "pytdbot.types.ReplyMarkupForceReply", 784 | "pytdbot.types.ReplyMarkupRemoveKeyboard", 785 | ] = None, 786 | ) -> Union["pytdbot.types.Error", "pytdbot.types.Message"]: 787 | r"""Reply to the message with a sticker. Shortcut for :meth:`~pytdbot.Client.sendSticker`.""" 788 | 789 | return await self._client.sendSticker( 790 | chat_id=self.chat_id, 791 | sticker=sticker, 792 | emoji=emoji, 793 | thumbnail=thumbnail, 794 | width=width, 795 | height=height, 796 | disable_notification=disable_notification, 797 | protect_content=protect_content, 798 | allow_paid_broadcast=allow_paid_broadcast, 799 | message_thread_id=message_thread_id, 800 | quote=quote, 801 | reply_to_message_id=self.id, 802 | reply_markup=reply_markup, 803 | ) 804 | 805 | async def copy( 806 | self, 807 | chat_id: int, 808 | in_game_share: bool = None, 809 | replace_caption: bool = None, 810 | new_caption: str = None, 811 | new_caption_entities: list = None, 812 | parse_mode: str = None, 813 | disable_notification: bool = False, 814 | protect_content: bool = False, 815 | allow_paid_broadcast: bool = False, 816 | message_thread_id: int = 0, 817 | quote: "pytdbot.types.InputTextQuote" = None, 818 | reply_to_message_id: int = 0, 819 | ) -> Union["pytdbot.types.Error", "pytdbot.types.Message"]: 820 | r"""Copy message to chat. Shortcut for :meth:`~pytdbot.Client.sendCopy`.""" 821 | 822 | return await self._client.sendCopy( 823 | chat_id=chat_id, 824 | from_chat_id=self.chat_id, 825 | message_id=self.id, 826 | in_game_share=in_game_share, 827 | replace_caption=replace_caption, 828 | new_caption=new_caption, 829 | new_caption_entities=new_caption_entities, 830 | parse_mode=parse_mode, 831 | disable_notification=disable_notification, 832 | protect_content=protect_content, 833 | allow_paid_broadcast=allow_paid_broadcast, 834 | message_thread_id=message_thread_id, 835 | quote=quote, 836 | reply_to_message_id=reply_to_message_id, 837 | ) 838 | 839 | async def forward( 840 | self, 841 | chat_id: int, 842 | in_game_share: bool = False, 843 | disable_notification: bool = False, 844 | ) -> Union["pytdbot.types.Error", "pytdbot.types.Message"]: 845 | r"""Forward message to chat. Shortcut for :meth:`~pytdbot.Client.forwardMessage`.""" 846 | 847 | return await self._client.forwardMessage( 848 | chat_id=chat_id, 849 | from_chat_id=self.chat_id, 850 | message_id=self.id, 851 | in_game_share=in_game_share, 852 | disable_notification=disable_notification, 853 | ) 854 | 855 | async def edit_text( 856 | self, 857 | text: str, 858 | parse_mode: str = None, 859 | entities: list = None, 860 | disable_web_page_preview: bool = False, 861 | url: str = None, 862 | force_small_media: bool = None, 863 | force_large_media: bool = None, 864 | show_above_text: bool = None, 865 | reply_markup: "pytdbot.types.ReplyMarkup" = None, 866 | ) -> Union["pytdbot.types.Error", "pytdbot.types.Message"]: 867 | r"""Edit text message. Shortcut for :meth:`~pytdbot.Client.editTextMessage`.""" 868 | 869 | return await self._client.editTextMessage( 870 | chat_id=self.chat_id, 871 | message_id=self.id, 872 | text=text, 873 | parse_mode=parse_mode, 874 | entities=entities, 875 | disable_web_page_preview=disable_web_page_preview, 876 | url=url, 877 | force_small_media=force_small_media, 878 | force_large_media=force_large_media, 879 | show_above_text=show_above_text, 880 | reply_markup=reply_markup, 881 | ) 882 | -------------------------------------------------------------------------------- /pytdbot/utils/__init__.py: -------------------------------------------------------------------------------- 1 | __all__ = [ 2 | "RETRY_AFTER_PREFEX", 3 | "get_running_loop", 4 | "escape_markdown", 5 | "escape_html", 6 | "JSON_ENCODER", 7 | "json_dumps", 8 | "json_loads", 9 | "obj_to_json", 10 | "obj_to_dict", 11 | "dict_to_obj", 12 | "create_webapp_secret_key", 13 | "parse_webapp_data", 14 | "to_camel_case", 15 | "create_extra_id", 16 | "get_bot_id_from_token", 17 | "get_retry_after_time", 18 | "bold", 19 | "italic", 20 | "underline", 21 | "strikethrough", 22 | "spoiler", 23 | "hyperlink", 24 | "mention", 25 | "code", 26 | "pre", 27 | "pre_code", 28 | "quote", 29 | ] 30 | 31 | from .asyncio_utils import get_running_loop 32 | from .escape import escape_markdown, escape_html 33 | from .json_utils import JSON_ENCODER, json_dumps, json_loads 34 | from .obj_encoder import obj_to_json, obj_to_dict, dict_to_obj 35 | from .webapps import create_webapp_secret_key, parse_webapp_data 36 | from .strings import ( 37 | RETRY_AFTER_PREFEX, 38 | to_camel_case, 39 | create_extra_id, 40 | get_bot_id_from_token, 41 | get_retry_after_time, 42 | ) 43 | from .text_format import ( 44 | bold, 45 | italic, 46 | underline, 47 | strikethrough, 48 | spoiler, 49 | hyperlink, 50 | mention, 51 | code, 52 | pre, 53 | pre_code, 54 | quote, 55 | ) 56 | -------------------------------------------------------------------------------- /pytdbot/utils/asyncio_utils.py: -------------------------------------------------------------------------------- 1 | import asyncio 2 | 3 | 4 | def get_running_loop(): 5 | """Get the current running event loop, or create a new one if none is running""" 6 | 7 | try: 8 | return asyncio.get_running_loop() 9 | except RuntimeError: 10 | return asyncio.new_event_loop() 11 | -------------------------------------------------------------------------------- /pytdbot/utils/escape.py: -------------------------------------------------------------------------------- 1 | from html import escape as _html_escape 2 | 3 | 4 | def escape_html(text: str, quote: bool = True) -> str: 5 | r"""Escape HTML characters in the given text 6 | 7 | Parameters: 8 | text (``str``): 9 | The text to escape 10 | 11 | quote (``bool``, *optional*): 12 | Whether to escape double quotes. Default is ``True`` 13 | 14 | Returns: 15 | :py:class:`str`: The escaped text 16 | """ 17 | 18 | return _html_escape(text, quote=quote) 19 | 20 | 21 | special_chars_v1 = r"_\*`\[" 22 | special_chars_v2 = r"\_*[]()~`>#+-=|{}.!" 23 | 24 | 25 | def escape_markdown(text: str, version: int = 2) -> str: 26 | r"""Escape Markdown characters in the given text 27 | 28 | Parameters: 29 | text (``str``): 30 | The text to escape 31 | 32 | version (``int``, *optional*): 33 | The Markdown version to escape. Default is ``2`` 34 | 35 | Returns: 36 | :py:class:`str`: The escaped text 37 | 38 | Raises: 39 | :py:class:`ValueError`: If the given markdown version is not supported 40 | """ 41 | 42 | if version == 1: 43 | chars = special_chars_v1 44 | elif version == 2: 45 | chars = special_chars_v2 46 | else: 47 | raise ValueError("Invalid version. Must be 1 or 2.") 48 | 49 | return "".join("\\" + c if c in chars else c for c in text) 50 | -------------------------------------------------------------------------------- /pytdbot/utils/json_utils.py: -------------------------------------------------------------------------------- 1 | try: 2 | import orjson as json 3 | except ImportError: 4 | try: 5 | import ujson as json 6 | except ImportError: 7 | import json 8 | 9 | from typing import Union 10 | 11 | JSON_ENCODER = json.__name__ 12 | 13 | if JSON_ENCODER == "orjson": 14 | 15 | def json_dumps(obj, encode: bool = False) -> Union[str, bytes]: 16 | # Null-terminated string is needed for orjson with c_char_p in tdjson 17 | d = json.dumps(obj) + b"\0" 18 | return d if encode else d.decode("utf-8") 19 | else: 20 | 21 | def json_dumps(obj, encode: bool = False) -> Union[str, bytes]: 22 | d = json.dumps(obj) 23 | return d if not encode else d.encode("utf-8") 24 | 25 | 26 | def json_loads(obj): 27 | return json.loads(obj) 28 | -------------------------------------------------------------------------------- /pytdbot/utils/obj_encoder.py: -------------------------------------------------------------------------------- 1 | import json 2 | from base64 import b64encode 3 | 4 | from .. import types, utils 5 | 6 | 7 | def obj_to_json(obj, **kwargs): 8 | return json.dumps(obj_to_dict(obj), **kwargs) 9 | 10 | 11 | def obj_to_dict(obj): 12 | if hasattr(obj, "to_dict"): 13 | return obj_to_dict(obj.to_dict()) 14 | elif isinstance(obj, list): 15 | return [obj_to_dict(item) for item in obj] 16 | elif isinstance(obj, dict): 17 | return {key: obj_to_dict(value) for key, value in obj.items()} 18 | elif isinstance(obj, bytes): 19 | return b64encode(obj).decode("utf-8") 20 | else: 21 | return obj 22 | 23 | 24 | def dict_to_obj(dict_obj, client=None): 25 | if isinstance(dict_obj, dict): 26 | if "@type" in dict_obj: 27 | obj = getattr(types, utils.to_camel_case(dict_obj["@type"])).from_dict( 28 | {key: dict_to_obj(value, client) for key, value in dict_obj.items()} 29 | ) 30 | if client: 31 | obj._client = client 32 | return obj 33 | else: 34 | return {key: dict_to_obj(value, client) for key, value in dict_obj.items()} 35 | elif isinstance(dict_obj, list): 36 | return [dict_to_obj(item, client) for item in dict_obj] 37 | else: 38 | return dict_obj 39 | -------------------------------------------------------------------------------- /pytdbot/utils/strings.py: -------------------------------------------------------------------------------- 1 | import binascii 2 | import os 3 | 4 | RETRY_AFTER_PREFEX = "Too Many Requests: retry after " 5 | 6 | 7 | def to_camel_case(input_str: str, delimiter: str = ".", is_class: bool = True) -> str: 8 | if not input_str: 9 | return "" 10 | 11 | parts = input_str.split(delimiter) 12 | camel_case_str = "" 13 | 14 | for i, part in enumerate(parts): 15 | if i > 0: 16 | camel_case_str += part[0].upper() + part[1:] 17 | else: 18 | camel_case_str += part 19 | 20 | if camel_case_str: 21 | camel_case_str = ( 22 | camel_case_str[0].upper() if is_class else camel_case_str[0].lower() 23 | ) + camel_case_str[1:] 24 | 25 | return camel_case_str 26 | 27 | 28 | def create_extra_id(bytes_size: int = 9): 29 | return binascii.hexlify(os.urandom(bytes_size)).decode() 30 | 31 | 32 | def get_bot_id_from_token(token: str) -> str: 33 | if len(token) > 80: 34 | return "" 35 | return token.split(":")[0] if ":" in token else "" 36 | 37 | 38 | def get_retry_after_time(error_message: str) -> int: 39 | r"""Get the retry after time from flood wait error message 40 | 41 | Parameters: 42 | error_message (``str``): 43 | The returned error message from TDLib 44 | 45 | Returns: 46 | py:class:`int` 47 | """ 48 | 49 | try: 50 | return int(error_message.removeprefix(RETRY_AFTER_PREFEX)) 51 | except Exception: 52 | return 0 53 | -------------------------------------------------------------------------------- /pytdbot/utils/text_format.py: -------------------------------------------------------------------------------- 1 | from . import escape_html, escape_markdown 2 | 3 | 4 | def bold(text: str, html: bool = True, escape: bool = True) -> str: 5 | r"""Convert the given text to bold format 6 | 7 | Parameters: 8 | text (``str``): 9 | The text to convert 10 | 11 | html (``bool``, *optional*): 12 | If ``True``, returns HTML format, if ``False`` returns MarkdownV2. Default is ``True`` 13 | 14 | escape (``bool``, *optional*): 15 | Whether escape special characters to the given text or not. Default is ``True`` 16 | 17 | Returns: 18 | py:class:`str`: The formated text 19 | """ 20 | 21 | if html: 22 | return f"{text if escape is False else escape_html(str(text))}" 23 | 24 | return f"*{text if escape is False else escape_markdown(str(text))}*" 25 | 26 | 27 | def italic(text: str, html: bool = True, escape: bool = True) -> str: 28 | r"""Convert the given text to italic format 29 | 30 | Parameters: 31 | text (``str``): 32 | The text to convert 33 | 34 | html (``bool``, *optional*): 35 | If ``True``, returns HTML format, if ``False`` returns MarkdownV2. Default is ``True`` 36 | 37 | escape (``bool``, *optional*): 38 | Whether escape special characters to the given text or not. Default is ``True`` 39 | 40 | Returns: 41 | py:class:`str`: The formated text 42 | """ 43 | 44 | if html: 45 | return f"{text if escape is False else escape_html(str(text))}" 46 | 47 | return f"_{text if escape is False else escape_markdown(str(text))}_" 48 | 49 | 50 | def underline(text: str, html: bool = True, escape: bool = True) -> str: 51 | r"""Convert the given text to underline format 52 | 53 | Parameters: 54 | text (``str``): 55 | The text to convert 56 | 57 | html (``bool``, *optional*): 58 | If ``True``, returns HTML format, if ``False`` returns MarkdownV2. Default is ``True`` 59 | 60 | escape (``bool``, *optional*): 61 | Whether escape special characters to the given text or not. Default is ``True`` 62 | 63 | Returns: 64 | py:class:`str`: The formated text 65 | """ 66 | 67 | if html: 68 | return f"{text if escape is False else escape_html(str(text))}" 69 | 70 | return f"__{text if escape is False else escape_markdown(str(text))}__" 71 | 72 | 73 | def strikethrough(text: str, html: bool = True, escape: bool = True) -> str: 74 | r"""Convert the given text to strikethrough format 75 | 76 | Parameters: 77 | text (``str``): 78 | The text to convert 79 | 80 | html (``bool``, *optional*): 81 | If ``True``, returns HTML format, if ``False`` returns MarkdownV2. Default is ``True`` 82 | 83 | escape (``bool``, *optional*): 84 | Whether escape special characters to the given text or not. Default is ``True`` 85 | 86 | Returns: 87 | py:class:`str`: The formated text 88 | """ 89 | 90 | if html: 91 | return f"{text if escape is False else escape_html(str(text))}" 92 | 93 | return f"~{text if escape is False else escape_markdown(str(text))}~" 94 | 95 | 96 | def spoiler(text: str, html: bool = True, escape: bool = True) -> str: 97 | r"""Convert the given text to spoiler format 98 | 99 | Parameters: 100 | text (``str``): 101 | The text to convert 102 | 103 | html (``bool``, *optional*): 104 | If ``True``, returns HTML format, if ``False`` returns MarkdownV2. Default is ``True`` 105 | 106 | escape (``bool``, *optional*): 107 | Whether escape special characters to the given text or not. Default is ``True`` 108 | 109 | Returns: 110 | py:class:`str`: The formated text 111 | """ 112 | 113 | if html: 114 | return f'{text if escape is False else escape_html(f"{text}")}' 115 | 116 | return f"||{text if escape is False else escape_markdown(str(text))}||" 117 | 118 | 119 | def hyperlink(text: str, url: str, html: bool = True, escape: bool = True) -> str: 120 | r"""Convert the given text to hyperlink format 121 | 122 | Parameters: 123 | text (``str``): 124 | The hyperlink text 125 | 126 | url (``str``): 127 | The hyperlink url 128 | 129 | html (``bool``, *optional*): 130 | If ``True``, returns HTML format, if ``False`` returns MarkdownV2. Default is ``True`` 131 | 132 | escape (``bool``, *optional*): 133 | Whether escape special characters to the given text or not. Default is ``True`` 134 | 135 | Returns: 136 | py:class:`str`: The formated text 137 | """ 138 | 139 | assert isinstance(url, str), "url must be str" 140 | 141 | if html: 142 | return ( 143 | f'{text if escape is False else escape_html(f"{text}")}' 144 | ) 145 | 146 | return f"[{text if escape is False else escape_markdown(str(text))}]({url})" 147 | 148 | 149 | def mention(text: str, user_id: str, html: bool = True, escape: bool = True) -> str: 150 | r"""Convert the given text to inline mention format 151 | 152 | Parameters: 153 | text (``str``): 154 | The text of inline mention 155 | 156 | user_id (``str``): 157 | The inline user id to mention 158 | 159 | html (``bool``, *optional*): 160 | If ``True``, returns HTML format, if ``False`` returns MarkdownV2. Default is ``True`` 161 | 162 | escape (``bool``, *optional*): 163 | Whether escape special characters to the given text or not. Default is ``True`` 164 | 165 | Returns: 166 | py:class:`str`: The formated text 167 | """ 168 | 169 | if html: 170 | return f'{text if escape is False else escape_html(f"{text}")}' 171 | 172 | return f"[{text if escape is False else escape_markdown(str(text))}](tg://user?id={user_id})" 173 | 174 | 175 | def custom_emoji(emoji: str, custom_emoji_id: int, html: bool = True) -> str: 176 | r"""Convert the given emoji to custom emoji format 177 | 178 | Parameters: 179 | emoji (``str``): 180 | The emoji of the custom emoji 181 | 182 | custom_emoji_id (``str``): 183 | Identifier of the custom emoji 184 | 185 | html (``bool``, *optional*): 186 | If ``True``, returns HTML format, if ``False`` returns MarkdownV2. Default is ``True`` 187 | 188 | Returns: 189 | py:class:`str`: The formated text 190 | """ 191 | 192 | if html: 193 | return f'{emoji}' 194 | 195 | return f"![{emoji}](tg://emoji?id={custom_emoji_id})" 196 | 197 | 198 | def code(text: str, html: bool = True, escape: bool = True) -> str: 199 | r"""Convert the given text to code format 200 | 201 | Parameters: 202 | text (``str``): 203 | The text to convert 204 | 205 | html (``bool``, *optional*): 206 | If ``True``, returns HTML format, if ``False`` returns MarkdownV2. Default is ``True`` 207 | 208 | escape (``bool``, *optional*): 209 | Whether escape special characters to the given text or not. Default is ``True`` 210 | 211 | Returns: 212 | py:class:`str`: The formated text 213 | """ 214 | 215 | if html: 216 | return f"{text if escape is False else escape_html(str(text))}" 217 | 218 | return f"`{text if escape is False else escape_markdown(str(text))}`" 219 | 220 | 221 | def pre(text: str, html: bool = True, escape: bool = True) -> str: 222 | r"""Convert the given text to pre format 223 | 224 | Parameters: 225 | text (``str``): 226 | The text to convert 227 | 228 | html (``bool``, *optional*): 229 | If ``True``, returns HTML format, if ``False`` returns MarkdownV2. Default is ``True`` 230 | 231 | escape (``bool``, *optional*): 232 | Whether escape special characters to the given text or not. Default is ``True`` 233 | 234 | Returns: 235 | py:class:`str`: The formated text 236 | """ 237 | 238 | if html: 239 | return f"
{text if escape is False else escape_html(str(text))}
" 240 | 241 | return f"```\n{text if escape is False else escape_markdown(str(text))}\n```" 242 | 243 | 244 | def pre_code(text: str, language: str, html: bool = True, escape: bool = True) -> str: 245 | r"""Convert the given text to pre-formatted fixed-width code block 246 | 247 | Parameters: 248 | text (``str``): 249 | The text to convert 250 | 251 | language (``str``): 252 | The name of the programming language written in the given code block 253 | 254 | html (``bool``, *optional*): 255 | If ``True``, returns HTML format, if ``False`` returns MarkdownV2. Default is ``True`` 256 | 257 | escape (``bool``, *optional*): 258 | Whether escape special characters to the given text or not. Default is ``True`` 259 | 260 | Returns: 261 | py:class:`str`: The formated text 262 | """ 263 | 264 | assert isinstance(language, str), "text must be str" 265 | 266 | if html: 267 | return f'
{text if escape is False else escape_html(f"{text}")}
' 268 | 269 | return ( 270 | f"```{language}\n{text if escape is False else escape_markdown(str(text))}\n```" 271 | ) 272 | 273 | 274 | def quote(text: str, expandable: bool = False, html: bool = True, escape: bool = True): 275 | r"""Convert the given text to quote block 276 | 277 | Parameters: 278 | text (``str``): 279 | The text to convert 280 | 281 | expandable (``bool``, *optional*): 282 | Wether the quote is expandable or not. Default is ``False`` 283 | 284 | html (``bool``, *optional*): 285 | If ``True``, returns HTML format, if ``False`` returns MarkdownV2. Default is ``True`` 286 | 287 | escape (``bool``, *optional*): 288 | Whether escape special characters to the given text or not. Default is ``True`` 289 | 290 | Returns: 291 | py:class:`str`: The formated text 292 | """ 293 | 294 | if html: 295 | return f"{text if escape is False else escape_html(str(text))}" 296 | 297 | return f"{'**' if expandable else ''}>{text if escape is False else escape_markdown(str(text))}" 298 | -------------------------------------------------------------------------------- /pytdbot/utils/webapps.py: -------------------------------------------------------------------------------- 1 | import hashlib 2 | import hmac 3 | import time 4 | 5 | from urllib.parse import parse_qs 6 | 7 | from ..exception import WebAppDataInvalid, WebAppDataOutdated, WebAppDataMismatch 8 | 9 | _webapp_secret_key = b"WebAppData" 10 | 11 | 12 | def create_webapp_secret_key(bot_token: str) -> bytes: 13 | r"""Create a secret key for Web App data validation 14 | 15 | \Parameters: 16 | bot_token (``str``): 17 | Bot token 18 | 19 | """ 20 | 21 | return hmac.new( 22 | key=_webapp_secret_key, 23 | msg=bot_token.encode("utf-8"), 24 | digestmod=hashlib.sha256, 25 | ).digest() 26 | 27 | 28 | def parse_webapp_data( 29 | secret_key: bytes, init_data: str, max_data_age: int = 60 30 | ) -> dict: 31 | r"""Parse and validate init data received from Web App 32 | 33 | \Parameters: 34 | secret_key (``bytes``): 35 | Secret key for Web App data validation; can be created using :func:`pytdbot.utils.create_webapp_secret_key` 36 | 37 | init_data (``str``): 38 | Init data received from Web App 39 | 40 | max_data_age (``int``, *optional*): 41 | Maximum age of init data in seconds. Default is ``60`` seconds 42 | 43 | Returns: 44 | ``dict``: Parsed data 45 | 46 | Raises: 47 | :class:`pytdbot.exception.WebAppDataInvalid` 48 | :class:`pytdbot.exception.WebAppDataOutdated` 49 | :class:`pytdbot.exception.WebAppDataMismatch` 50 | """ 51 | 52 | assert isinstance(secret_key, bytes), "secret_key must be bytes" 53 | assert isinstance(init_data, str), "init_data must be a string" 54 | assert isinstance(max_data_age, int), "max_data_age must be an int" 55 | 56 | # In Python 3.8.7 or earlier, parse_qs treats ';' as query separator in addition to '&' 57 | # Which may cause issues with the hash validation 58 | data = parse_qs(init_data) 59 | 60 | data = {k: v[0] for k, v in data.items()} 61 | 62 | if "hash" not in data or "auth_date" not in data: 63 | raise WebAppDataInvalid("Missing hash or auth_date") 64 | 65 | if int(data["auth_date"]) < int(time.time() - max_data_age): 66 | raise WebAppDataOutdated 67 | 68 | received_hash = data.pop("hash") 69 | 70 | sorted_keys = sorted(data.keys()) 71 | 72 | data_check_string = "\n".join([f"{key}={data[key]}" for key in sorted_keys]) 73 | 74 | expected_hash = hmac.new( 75 | key=secret_key, msg=data_check_string.encode("utf-8"), digestmod=hashlib.sha256 76 | ).hexdigest() 77 | 78 | if not hmac.compare_digest(expected_hash, received_hash): 79 | raise WebAppDataMismatch("Hash mismatch") 80 | 81 | return data 82 | -------------------------------------------------------------------------------- /requirements.txt: -------------------------------------------------------------------------------- 1 | deepdiff 2 | aio-pika 3 | -------------------------------------------------------------------------------- /setup.py: -------------------------------------------------------------------------------- 1 | from re import findall 2 | 3 | from setuptools import find_packages, setup 4 | 5 | with open("pytdbot/__init__.py", "r") as f: 6 | version = findall(r"__version__ = \"(.+)\"", f.read())[0] 7 | 8 | with open("README.md", "r") as f: 9 | readme = f.read() 10 | 11 | with open("requirements.txt", "r") as f: 12 | requirements = [x.strip() for x in f.readlines()] 13 | 14 | 15 | setup( 16 | name="Pytdbot", 17 | version=version, 18 | description="Easy-to-use asynchronous TDLib wrapper for Python.", 19 | long_description=readme, 20 | long_description_content_type="text/markdown", 21 | author="AYMEN Mohammed", 22 | author_email="let.me.code.safe@gmail.com", 23 | url="https://github.com/pytdbot/client", 24 | license="MIT", 25 | python_requires=">=3.9", 26 | install_requires=requirements, 27 | extras_require={ 28 | "tdjson": ["tdjson"], 29 | }, 30 | project_urls={ 31 | "Source": "https://github.com/pytdbot/client", 32 | "Tracker": "https://github.com/pytdbot/client/issues", 33 | }, 34 | packages=find_packages(exclude=["examples"]), 35 | package_data={ 36 | "pytdbot": ["td_api.*"], 37 | }, 38 | keywords=[ 39 | "telegram", 40 | "tdlib", 41 | "bot", 42 | "telegram-client", 43 | "telegram-bot", 44 | "bot-api", 45 | "telegram-bot", 46 | "tdlib-python", 47 | "tdlib-bot", 48 | ], 49 | ) 50 | --------------------------------------------------------------------------------