├── .DS_Store ├── .github └── FUNDING.yml ├── .gitignore ├── .vscode └── settings.json ├── LICENSE ├── README.md ├── __init__.py ├── deploy.md ├── pyproject.toml ├── requirements.txt └── src ├── .DS_Store ├── __init__.py ├── examples ├── conversation_bot_1.py ├── conversation_bot_2.py ├── echo_bot.py ├── reply_interactive_message_bot.py ├── reply_message_bot.py ├── send_interactive_message.py ├── send_simple_message.py └── send_template_message.py └── python_whatsapp_bot ├── .DS_Store ├── __init__.py ├── dispatcher.py ├── error_handlers.py ├── handler_classes.py ├── markup.py ├── message.py ├── py.typed ├── user_context.py └── whatsapp.py /.DS_Store: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/radi-dev/python-whatsapp-bot/3afca6338e55041e318a26fb8aadcb0d0546460d/.DS_Store -------------------------------------------------------------------------------- /.github/FUNDING.yml: -------------------------------------------------------------------------------- 1 | # These are supported funding model platforms 2 | 3 | github: # Replace with up to 4 GitHub Sponsors-enabled usernames e.g., [user1, user2] 4 | patreon: 5 | open_collective: # Replace with a single Open Collective username 6 | ko_fi: # Replace with a single Ko-fi username 7 | tidelift: # Replace with a single Tidelift platform-name/package-name e.g., npm/babel 8 | community_bridge: # Replace with a single Community Bridge project-name e.g., cloud-foundry 9 | liberapay: # Replace with a single Liberapay username 10 | issuehunt: # Replace with a single IssueHunt username 11 | otechie: # Replace with a single Otechie username 12 | lfx_crowdfunding: # Replace with a single LFX Crowdfunding project-name e.g., cloud-foundry 13 | custom: ['buymeacoffee.com/radibytes'] # Replace with up to 4 custom sponsorship URLs e.g., ['link1', 'link2'] 14 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Byte-compiled / optimized / DLL files 2 | __pycache__/ 3 | *.py[cod] 4 | *$py.class 5 | 6 | # Installer logs 7 | pip-log.txt 8 | pip-delete-this-directory.txt 9 | 10 | # Environments 11 | env/ 12 | venv/ 13 | ENV/ 14 | 15 | data.py 16 | 17 | #dists 18 | dist/ 19 | src/python_whatsapp_bot.egg-info/ 20 | -------------------------------------------------------------------------------- /.vscode/settings.json: -------------------------------------------------------------------------------- 1 | { 2 | "[python]": { 3 | "editor.defaultFormatter": "ms-python.black-formatter" 4 | }, 5 | "python.formatting.provider": "none" 6 | } 7 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2022 Radi 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 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # python-whatsapp-bot 2 | 3 | A whatsapp client library for python utilizing the [WhatsApp Business Cloud API](https://developers.facebook.com/docs/whatsapp/cloud-api). 4 | 5 | [![Made in Nigeria](https://img.shields.io/badge/made%20in-nigeria-008751.svg?style=flat-square)](https://github.com/acekyd/made-in-nigeria) 6 | [![Downloads](https://pepy.tech/badge/python-whatsapp-bot)](https://pepy.tech/project/python-whatsapp-bot) 7 | [![Downloads](https://pepy.tech/badge/python-whatsapp-bot/month)](https://pepy.tech/project/python-whatsapp-bot) 8 | [![Downloads](https://pepy.tech/badge/python-whatsapp-bot/week)](https://pepy.tech/project/python-whatsapp-bot) 9 | 10 | ## Features supported 11 | 12 | - [python-whatsapp-bot](#python-whatsapp-bot) 13 | - [Features supported](#features-supported) 14 | - [Getting started](#getting-started) 15 | - [Setting up](#setting-up) 16 | - [Initialization](#initialization) 17 | - [Sending Messages](#sending-messages) 18 | - [Example](#example) 19 | - [Sending Interactive Messages](#sending-interactive-messages) 20 | - [For buttons](#for-buttons) 21 | - [For lists](#for-lists) 22 | - [Sending Template Messages](#sending-template-messages) 23 | - [Handling Incoming Messages](#handling-incoming-messages) 24 | - [A short note about **Webhooks**](#a-short-note-about-webhooks) 25 | - [Issues](#issues) 26 | - [Contributing](#contributing) 27 | - [References](#references) 28 | - [All the credit](#all-the-credit) 29 | 30 | ## Getting started 31 | 32 | To start, install with pip: 33 | 34 | ```bash 35 | pip3 install --upgrade python-whatsapp-bot 36 | 37 | ``` 38 | 39 | ## Setting up 40 | 41 | To get started using this library, you have to obtain a **TOKEN** and **PHONE NUMBER ID** from [Facebook Developer Portal](https://developers.facebook.com/). You get these after setting up a developer account and setting up an app. 42 | 43 | [Here is a tutorial on the platform on how to go about the process](https://developers.facebook.com/docs/whatsapp/cloud-api/get-started) 44 | 45 | If you followed the tutorial, you should now have a **TOKEN** and **TEST WHATSAPP NUMBER** and its phone_number_id.activeYou might have even already sent your first message on the platform using the provided curl request. 46 | 47 | Now you have all you need to start using this library. 48 | **Note:** The given token is temporary. [This tutorial](https://developers.facebook.com/docs/whatsapp/business-management-api/get-started#1--acquire-an-access-token-using-a-system-user-or-facebook-login) on the platform guides you to create a permanent token. [This guide](https://developers.facebook.com/docs/whatsapp/cloud-api/get-started#phone-number) shows how to register an authentic phone number. 49 | 50 | ## Initialization 51 | 52 | To initialize the app instance, you need to specify the `TOKEN` and `phone_number_id` you obtained from the steps above. Note that phone number id is not phone number. 53 | 54 | ```python 55 | >>> from python_whatsapp_bot import Whatsapp 56 | >>> wa_bot = Whatsapp(number_id='305xxxxxx', token=TOKEN) 57 | ``` 58 | 59 | Once initialized, you can start using some of the bot's features right away. 60 | 61 | ## Sending Messages 62 | 63 | To send a text message 64 | 65 | ```python 66 | >>> wa_bot.send_message('mobile eg: 2348145xxxxx3', 'Your message here') 67 | ``` 68 | 69 | ### Example 70 | 71 | Here is an example 72 | 73 | ```python 74 | >>> wa_bot.send_message('2348945434343', 'Your message here') 75 | ``` 76 | 77 | ## Sending Interactive Messages 78 | 79 | For buttons and lists, use the same send_message endpoint but with a reply_markup parameter. e.g 80 | 81 | ### For buttons 82 | 83 | ```python 84 | >>> from from python_whatsapp_bot import Inline_keyboard # Import inline_keyboard for interactive buttons 85 | >>> wa_bot.send_message('2348945434343', 'This is a message with two buttons',reply_markup=Inline_keyboard(['First button', 'Second button'])) 86 | ``` 87 | 88 | ### For lists 89 | 90 | ```python 91 | >>> from python_whatsapp_bot import Inline_list, List_item # Import inline_list and List_item for interactive list 92 | >>> wa_bot.send_message('2348945434343', 'This is a message with lists',reply_markup=Inline_list("Show list",list_items=[[List_item("one list item")]]) 93 | ``` 94 | 95 | ## Sending Template Messages 96 | 97 | To send a pre-approved template message: 98 | 99 | ```python 100 | >>> wa_bot.send_template_message("255757xxxxxx","hello_world") 101 | ``` 102 | 103 | ## Handling Incoming Messages 104 | 105 | ### A short note about **Webhooks** 106 | 107 | For every message sent to your bot business account, whatsapp sends an object containing the message as a post request to a url which you have to provide beforehand. The url you provide should be able to process simple get and post requests. This url is the webhook url, and the object whatsapp sends to your url is the webhook. 108 | 109 | Now, you can write a small server with the Python Flask library to handle the webhook requests, but another problem arises if you're developing on a local server; whatsapp will not be able to send requests to your localhost url, so a quick fix would be to deploy your project to an online server each time you make a change to be able to test it. 110 | Once deployed, you can proceed to register the url of your deployed app using [this tutorial](https://developers.facebook.com/docs/whatsapp/business-management-api/guides/set-up-webhooks) from the platform. 111 | 112 | If you're like me however, you wouldn't want to always deploy before you test, you want to run everything on local first. In this case, you might decide to use Ngrok to tunnel a live url to your local server, but another issue arises; Since Ngrok generates a new url each time it is restarted, you'd have to constantly log in to facebook servers to register the newly generated url. I presume you don't want that hassle either. In this situation, a webhook forwarder can be deployed to a virtual server like Heroku, and it doesn't get modified. You register the deployed forwarder's url on Whatsapp servers, it receives all the webhook requests and forwards them to your local machine using ngrok. 113 | 114 | To continue with this fowarding process, open this repository and follow the readme instructions to deploy it and setup a client for it on your device, then register the url following [this guide](https://github.com/Radi-dev/webhook-forwarder). 115 | 116 | ## Issues 117 | 118 | Please open an issue to draw my attention to mistake or suggestion 119 | 120 | ## Contributing 121 | 122 | This is an opensource project under `MIT License` so anyone is welcome to contribute from typo, to source code to documentation, `JUST FORK IT`. 123 | 124 | ## References 125 | 126 | 1. [WhatsApp Cloud API official documentation](https://developers.facebook.com/docs/whatsapp/cloud-api/) 127 | 128 | ## All the credit 129 | 130 | 1. All contributors 131 | -------------------------------------------------------------------------------- /__init__.py: -------------------------------------------------------------------------------- 1 | from .src.python_whatsapp_bot import * 2 | -------------------------------------------------------------------------------- /deploy.md: -------------------------------------------------------------------------------- 1 | # Deployment checklist 2 | 3 | *optional run >> python3 -m pip install --upgrade build 4 | *optional run >> python3 -m pip install --upgrade twine 5 | 6 | ✅change version of pyproject.toml 7 | 8 | ✅delete src/python_whatsapp_bot.egg-info/ if exists 9 | ✅delete dist/ if exists 10 | 11 | ✅run >> python3 -m build 12 | ✅run >> python3 -m twine upload dist/* 13 | 💡use __token__ as username 14 | 💡enter password with the pypi prefix 15 | -------------------------------------------------------------------------------- /pyproject.toml: -------------------------------------------------------------------------------- 1 | [build-system] 2 | requires = ["setuptools>=61.0"] 3 | build-backend = "setuptools.build_meta" 4 | 5 | [project] 6 | name = "python-whatsapp-bot" 7 | version = "2.0.3" 8 | authors = [{name="Radi", email="evaradi18@gmail.com"},] 9 | description = "A whatsapp client library for python using the new WhatsApp cloud API" 10 | readme = "README.md" 11 | license = { file="LICENSE" } 12 | requires-python = ">=3.7" 13 | classifiers = [ 14 | "Programming Language :: Python :: 3", 15 | "License :: OSI Approved :: MIT License", 16 | "Operating System :: OS Independent", 17 | ] 18 | dependencies = [ 19 | "requests>=2", 20 | ] 21 | 22 | [options.packages.find] 23 | where = "src" 24 | 25 | [project.urls] 26 | "Homepage" = "https://github.com/Radi-dev/python-whatsapp-cloud-bot" 27 | "Bug Tracker" = "https://github.com/Radi-dev/python-whatsapp-cloud-bot/issues" -------------------------------------------------------------------------------- /requirements.txt: -------------------------------------------------------------------------------- 1 | requests==2.27.1 2 | -------------------------------------------------------------------------------- /src/.DS_Store: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/radi-dev/python-whatsapp-bot/3afca6338e55041e318a26fb8aadcb0d0546460d/src/.DS_Store -------------------------------------------------------------------------------- /src/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/radi-dev/python-whatsapp-bot/3afca6338e55041e318a26fb8aadcb0d0546460d/src/__init__.py -------------------------------------------------------------------------------- /src/examples/conversation_bot_1.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/radi-dev/python-whatsapp-bot/3afca6338e55041e318a26fb8aadcb0d0546460d/src/examples/conversation_bot_1.py -------------------------------------------------------------------------------- /src/examples/conversation_bot_2.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/radi-dev/python-whatsapp-bot/3afca6338e55041e318a26fb8aadcb0d0546460d/src/examples/conversation_bot_2.py -------------------------------------------------------------------------------- /src/examples/echo_bot.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/radi-dev/python-whatsapp-bot/3afca6338e55041e318a26fb8aadcb0d0546460d/src/examples/echo_bot.py -------------------------------------------------------------------------------- /src/examples/reply_interactive_message_bot.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/radi-dev/python-whatsapp-bot/3afca6338e55041e318a26fb8aadcb0d0546460d/src/examples/reply_interactive_message_bot.py -------------------------------------------------------------------------------- /src/examples/reply_message_bot.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/radi-dev/python-whatsapp-bot/3afca6338e55041e318a26fb8aadcb0d0546460d/src/examples/reply_message_bot.py -------------------------------------------------------------------------------- /src/examples/send_interactive_message.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/radi-dev/python-whatsapp-bot/3afca6338e55041e318a26fb8aadcb0d0546460d/src/examples/send_interactive_message.py -------------------------------------------------------------------------------- /src/examples/send_simple_message.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/radi-dev/python-whatsapp-bot/3afca6338e55041e318a26fb8aadcb0d0546460d/src/examples/send_simple_message.py -------------------------------------------------------------------------------- /src/examples/send_template_message.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/radi-dev/python-whatsapp-bot/3afca6338e55041e318a26fb8aadcb0d0546460d/src/examples/send_template_message.py -------------------------------------------------------------------------------- /src/python_whatsapp_bot/.DS_Store: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/radi-dev/python-whatsapp-bot/3afca6338e55041e318a26fb8aadcb0d0546460d/src/python_whatsapp_bot/.DS_Store -------------------------------------------------------------------------------- /src/python_whatsapp_bot/__init__.py: -------------------------------------------------------------------------------- 1 | from .whatsapp import Whatsapp 2 | from .markup import ( 3 | Inline_button, 4 | Inline_keyboard, 5 | Inline_list, 6 | List_item, 7 | InlineLocationRequest, 8 | ) 9 | from .user_context import User_context 10 | from .dispatcher import ( 11 | Update, 12 | MessageHandler, 13 | InteractiveQueryHandler, 14 | ImageHandler, 15 | StickerHandler, 16 | AudioHandler, 17 | VideoHandler, 18 | LocationHandler, 19 | UnknownHandler, 20 | UnsupportedHandler, 21 | ) 22 | from .error_handlers import keys_exists 23 | 24 | Message_handler, Interactive_query_handler = MessageHandler, InteractiveQueryHandler 25 | -------------------------------------------------------------------------------- /src/python_whatsapp_bot/dispatcher.py: -------------------------------------------------------------------------------- 1 | from typing import Callable 2 | 3 | # from queue import Queue 4 | from threading import Thread 5 | 6 | from .error_handlers import keys_exists 7 | from .user_context import User_context 8 | from .handler_classes import ( 9 | Update, 10 | UpdateHandler, 11 | MessageHandler, 12 | InteractiveQueryHandler, 13 | ImageHandler, 14 | LocationHandler, 15 | StickerHandler, 16 | AudioHandler, 17 | VideoHandler, 18 | UnknownHandler, 19 | UnsupportedHandler, 20 | ) 21 | 22 | Message_handler, Interactive_query_handler, Update_handler = ( 23 | MessageHandler, 24 | InteractiveQueryHandler, 25 | UpdateHandler, 26 | ) 27 | 28 | 29 | class Dispatcher: 30 | def __init__(self, bot, mark_as_read: bool = True) -> None: 31 | self.bot = bot 32 | self.queue = bot.queue 33 | self.registered_handlers = [] # list of handler instances 34 | self.mark_as_read = mark_as_read 35 | self.next_step_handler = {} 36 | self.fallback_function = None 37 | 38 | def process_update(self, update) -> None: 39 | self.queue.put(update) 40 | while True: 41 | _update = self.queue.get() 42 | if not self.bot.threaded: 43 | self._process_queue(_update) 44 | else: 45 | Thread(target=self._process_queue(_update)).start() 46 | if self.queue.empty(): 47 | break 48 | 49 | async def aprocess_update(self, update) -> None: 50 | self.queue.put(update) 51 | while True: 52 | _update = self.queue.get() 53 | if not self.bot.threaded: 54 | await self._aprocess_queue(_update) 55 | else: 56 | Thread(target=await self._aprocess_queue(_update)).start() 57 | if self.queue.empty(): 58 | break 59 | 60 | def _process_queue(self, update) -> None: 61 | if not keys_exists(update, "entry", 0, "changes", 0, "value"): 62 | return 63 | value = update["entry"][0]["changes"][0]["value"] 64 | # self.value = value 65 | if not keys_exists(value, "metadata", "phone_number_id"): 66 | return 67 | if str(value["metadata"]["phone_number_id"]) == str(self.bot.id): 68 | if not keys_exists(value, "messages"): 69 | return 70 | _message = value["messages"][0] 71 | if self.mark_as_read: 72 | self.bot.mark_as_read(_message) 73 | update = Update(self.bot, value) 74 | 75 | # check if a next step handler has been registered 76 | persistent_handlers = [i for i in self.registered_handlers if i.persistent] 77 | try: 78 | users_next_step = self.next_step_handler[update.user_phone_number] 79 | users_next_step_handler = users_next_step["next_step_handler"] 80 | matched_handlers = [] 81 | try: 82 | users_next_step_fallback = users_next_step["fallback_function"] 83 | matched_handlers.append(users_next_step_fallback) 84 | except KeyError: 85 | pass 86 | matched_handlers.append(users_next_step_handler) 87 | # get registered handlers if no next step handler 88 | except KeyError: 89 | matched_handlers = list(self.registered_handlers) 90 | matched_handlers = persistent_handlers + matched_handlers 91 | 92 | for handler in matched_handlers: 93 | # if ( 94 | # isinstance(handler, UpdateHandler) 95 | # and handler.name == _message["type"] 96 | # ) or isinstance(handler, UpdateHandler): 97 | if ( 98 | isinstance(handler, UpdateHandler) 99 | and handler.name == _message["type"] 100 | ): 101 | 102 | # Get message text 103 | message_txt = handler.extract_data(_message).message_txt 104 | 105 | res = self._check_and_run_handler(handler, value, message_txt) 106 | if res: 107 | try: 108 | if ( 109 | self.next_step_handler[update.user_phone_number][ 110 | "next_step_handler" 111 | ] 112 | == handler 113 | or self.next_step_handler[update.user_phone_number][ 114 | "fallback_function" 115 | ] 116 | == handler 117 | ): 118 | del self.next_step_handler[update.user_phone_number] 119 | return 120 | except KeyError: 121 | return 122 | else: 123 | continue 124 | 125 | async def _aprocess_queue(self, update) -> None: 126 | if not keys_exists(update, "entry", 0, "changes", 0, "value"): 127 | return 128 | value = update["entry"][0]["changes"][0]["value"] 129 | # self.value = value 130 | if not keys_exists(value, "metadata", "phone_number_id"): 131 | return 132 | if str(value["metadata"]["phone_number_id"]) == str(self.bot.id): 133 | if not keys_exists(value, "messages"): 134 | return 135 | _message = value["messages"][0] 136 | if self.mark_as_read: 137 | self.bot.mark_as_read(_message) 138 | update = Update(self.bot, value) 139 | 140 | # check if a next step handler has been registered 141 | persistent_handlers = [i for i in self.registered_handlers if i.persistent] 142 | try: 143 | users_next_step = self.next_step_handler[update.user_phone_number] 144 | users_next_step_handler = users_next_step["next_step_handler"] 145 | matched_handlers = [] 146 | try: 147 | users_next_step_fallback = users_next_step["fallback_function"] 148 | matched_handlers.append(users_next_step_fallback) 149 | except KeyError: 150 | pass 151 | matched_handlers.append(users_next_step_handler) 152 | # get registered handlers if no next step handler 153 | except KeyError: 154 | matched_handlers = list(self.registered_handlers) 155 | matched_handlers = persistent_handlers + matched_handlers 156 | 157 | for handler in matched_handlers: 158 | # if ( 159 | # isinstance(handler, UpdateHandler) 160 | # and handler.name == _message["type"] 161 | # ) or isinstance(handler, UpdateHandler): 162 | if ( 163 | isinstance(handler, UpdateHandler) 164 | and handler.name == _message["type"] 165 | ): 166 | 167 | # Get message text 168 | message_txt = handler.extract_data(_message).message_txt 169 | 170 | res = await self._acheck_and_run_handler( 171 | handler, value, message_txt 172 | ) 173 | if res: 174 | try: 175 | if ( 176 | self.next_step_handler[update.user_phone_number][ 177 | "next_step_handler" 178 | ] 179 | == handler 180 | or self.next_step_handler[update.user_phone_number][ 181 | "fallback_function" 182 | ] 183 | == handler 184 | ): 185 | del self.next_step_handler[update.user_phone_number] 186 | return 187 | except KeyError: 188 | return 189 | else: 190 | continue 191 | 192 | def _check_and_run_handler(self, handler: UpdateHandler, value, message): 193 | _message = value.get("messages", [{}])[0] 194 | if hasattr(handler, "filter_check"): 195 | if not handler.filter_check(message): 196 | return False 197 | if handler.context: 198 | update = Update(self.bot, value) 199 | extracted_data = handler.extract_data(_message) 200 | 201 | update.message_text = handler.extract_data(_message).message_txt 202 | for key, val in (extracted_data.__dict__).items(): 203 | setattr(update, key, val) 204 | 205 | handler.run(update, context=User_context(update.user_phone_number)) 206 | else: 207 | handler.run(update) 208 | return True 209 | return False 210 | 211 | async def _acheck_and_run_handler(self, handler: UpdateHandler, value, message): 212 | _message = value.get("messages", [{}])[0] 213 | if hasattr(handler, "filter_check"): 214 | if not handler.filter_check(message): 215 | return False 216 | if handler.context: 217 | update = Update(self.bot, value) 218 | extracted_data = handler.extract_data(_message) 219 | 220 | update.message_text = handler.extract_data(_message).message_txt 221 | for key, val in (extracted_data.__dict__).items(): 222 | setattr(update, key, val) 223 | 224 | await handler.arun( 225 | update, context=User_context(update.user_phone_number) 226 | ) 227 | else: 228 | await handler.run(update) 229 | return True 230 | return False 231 | 232 | def _register_handler(self, handler_instance): 233 | self.registered_handlers.append(handler_instance) 234 | handler_index = len(self.registered_handlers) - 1 235 | return handler_index 236 | 237 | def set_next_handler( 238 | self, 239 | update: Update, 240 | function: Callable, 241 | handler_type: UpdateHandler = UpdateHandler, 242 | regex: str = None, 243 | func: Callable = None, 244 | end_conversation_action: Callable = lambda x: x, 245 | end_conversation_keyword_regex: str = r"(?i)^(end|stop|cancel)$", 246 | ): 247 | """Sets a function for handling of next update. 248 | The set_next_handler overrides other handlers till it handles an update itself 249 | """ 250 | if not issubclass(handler_type, UpdateHandler): 251 | return "type should be an UpdateHandler class" 252 | self.next_step_handler[update.user_phone_number] = {} 253 | users_next = self.next_step_handler[update.user_phone_number] 254 | users_next["fallback_function"] = MessageHandler( 255 | regex=end_conversation_keyword_regex, action=end_conversation_action 256 | ) 257 | if handler_type == MessageHandler: 258 | users_next["next_step_handler"] = MessageHandler( 259 | regex, func, action=function 260 | ) 261 | elif handler_type == InteractiveQueryHandler: 262 | users_next["next_step_handler"] = InteractiveQueryHandler( 263 | regex, func, action=function 264 | ) 265 | else: 266 | try: 267 | _type = update.value["messages"][0]["type"] 268 | new_handler = UpdateHandler() 269 | # new_handler.name = _type 270 | new_handler.regex = regex 271 | new_handler.func = func 272 | new_handler.action = function 273 | users_next["next_step_handler"] = new_handler 274 | except KeyError: 275 | return 276 | 277 | def add_message_handler( 278 | self, 279 | regex: str = None, 280 | func: Callable = None, 281 | context: bool = True, 282 | persistent: bool = False, 283 | ): 284 | def inner(function): 285 | _handler = MessageHandler( 286 | regex=regex, 287 | func=func, 288 | action=function, 289 | context=context, 290 | persistent=persistent, 291 | ) 292 | self._register_handler(_handler) 293 | return function 294 | 295 | return inner 296 | 297 | def add_interactive_handler( 298 | self, 299 | regex: str = None, 300 | func: Callable = None, 301 | handle_button: bool = True, 302 | handle_list: bool = True, 303 | context: bool = True, 304 | persistent: bool = False, 305 | ): 306 | def inner(function): 307 | _handler = InteractiveQueryHandler( 308 | regex=regex, 309 | func=func, 310 | action=function, 311 | handle_button=handle_button, 312 | handle_list=handle_list, 313 | context=context, 314 | persistent=persistent, 315 | ) 316 | self._register_handler(_handler) 317 | return function 318 | 319 | return inner 320 | 321 | def add_image_handler( 322 | self, 323 | regex: str = None, 324 | func: Callable = None, 325 | context: bool = True, 326 | persistent: bool = False, 327 | ): 328 | def inner(function): 329 | _handler = ImageHandler( 330 | regex=regex, 331 | func=func, 332 | action=function, 333 | context=context, 334 | persistent=persistent, 335 | ) 336 | self._register_handler(_handler) 337 | return function 338 | 339 | return inner 340 | 341 | def add_audio_handler( 342 | self, 343 | regex: str = None, 344 | func: Callable = None, 345 | context: bool = True, 346 | persistent: bool = False, 347 | ): 348 | def inner(function): 349 | _handler = AudioHandler( 350 | regex=regex, 351 | func=func, 352 | action=function, 353 | context=context, 354 | persistent=persistent, 355 | ) 356 | self._register_handler(_handler) 357 | return function 358 | 359 | return inner 360 | 361 | def add_video_handler( 362 | self, 363 | regex: str = None, 364 | func: Callable = None, 365 | context: bool = True, 366 | persistent: bool = False, 367 | ): 368 | def inner(function): 369 | _handler = VideoHandler( 370 | regex=regex, 371 | func=func, 372 | action=function, 373 | context=context, 374 | persistent=persistent, 375 | ) 376 | self._register_handler(_handler) 377 | return function 378 | 379 | return inner 380 | 381 | def add_sticker_handler( 382 | self, 383 | context: bool = True, 384 | persistent: bool = False, 385 | ): 386 | def inner(function): 387 | _handler = StickerHandler( 388 | action=function, 389 | context=context, 390 | persistent=persistent, 391 | ) 392 | self._register_handler(_handler) 393 | return function 394 | 395 | return inner 396 | 397 | def add_location_handler( 398 | self, 399 | context: bool = True, 400 | persistent: bool = False, 401 | ): 402 | def inner(function): 403 | _handler = LocationHandler( 404 | action=function, 405 | context=context, 406 | persistent=persistent, 407 | ) 408 | self._register_handler(_handler) 409 | return function 410 | 411 | return inner 412 | 413 | # def add_location_handler( 414 | # self, 415 | # regex: str = None, 416 | # func: Callable = None, 417 | # context: bool = True, 418 | # persistent: bool = False, 419 | # ): 420 | # def inner(function): 421 | # _handler = LocationHandler( 422 | # regex=regex, 423 | # func=func, 424 | # action=function, 425 | # context=context, 426 | # persistent=persistent, 427 | # ) 428 | # self._register_handler(_handler) 429 | # return function 430 | 431 | # return inner 432 | -------------------------------------------------------------------------------- /src/python_whatsapp_bot/error_handlers.py: -------------------------------------------------------------------------------- 1 | def keys_exists(element, *keys): 2 | ''' 3 | Check if *keys (nested) exists in `element` (dict). e.g: 4 | data = { 5 | "spam": { 6 | "egg": { 7 | "bacon": "Well..", 8 | "sausages": "Spam egg sausages and spam", 9 | "spam": "does not have much spam in it" 10 | } 11 | } 12 | } 13 | keys_exists(data, "spam") -> True 14 | keys_exists(data, "spam", "bacon") -> False 15 | keys_exists(data, "spam", "egg") ->True 16 | Keys_exists(data, "spam", "egg", "bacon") ->True 17 | ''' 18 | if not isinstance(element, dict): 19 | raise AttributeError('keys_exists() expects dict as first argument.') 20 | if len(keys) == 0: 21 | raise AttributeError( 22 | 'keys_exists() expects at least two arguments, one given.') 23 | 24 | _element = element 25 | for key in keys: 26 | try: 27 | _element = _element[key] 28 | except KeyError: 29 | return False 30 | return True 31 | -------------------------------------------------------------------------------- /src/python_whatsapp_bot/handler_classes.py: -------------------------------------------------------------------------------- 1 | import re 2 | import inspect 3 | from typing import Callable, Dict 4 | 5 | from .markup import Reply_markup 6 | from .error_handlers import keys_exists 7 | 8 | 9 | class Update: 10 | 11 | def __init__(self, bot, update) -> None: 12 | self.bot = bot 13 | self.value = update 14 | self.message = self.value.get("messages", [{}])[0] 15 | self.user = self.value.get("contacts", [{}])[0] 16 | self.user_display_name: str = self.user.get("profile", {}).get("name", "") 17 | self.user_phone_number = self.user.get("wa_id", "") 18 | self.message_id: str = self.message.get("id") 19 | self.message_text: str = None 20 | self.interactive_text: str = None 21 | self.media_url: str = None 22 | self.media_mime_type: str = None 23 | self.media_file_id: str = None 24 | self.media_hash: str = None 25 | self.media_voice: bool = False 26 | self.loc_address: str = None 27 | self.loc_name: str = None 28 | self.loc_latitude: str = None 29 | self.loc_longitude: str = None 30 | 31 | # self._initialize_message_text() 32 | 33 | # def _initialize_message_text(self): 34 | # if keys_exists(self.message, "text", "body"): 35 | # self.message_text = self.message["text"]["body"] 36 | # if keys_exists(self.message, "interactive", "list_reply"): 37 | # self.interactive_text = self.message["interactive"]["list_reply"] 38 | # self.message_text = self.message["interactive"]["list_reply"]["id"] 39 | # if keys_exists(self.message, "interactive", "button_reply"): 40 | # self.interactive_text = self.message["interactive"]["button_reply"] 41 | # self.message_text = self.message["interactive"]["button_reply"]["id"] 42 | 43 | def set_message_text(self, text: str): 44 | self.message_text = text 45 | 46 | def reply_message( 47 | self, 48 | text: str, 49 | reply_markup: Reply_markup = None, 50 | header: str = None, 51 | header_type: str = "text", 52 | footer: str = None, 53 | web_page_preview: bool = True, 54 | tag_message: bool = True, 55 | *args, 56 | **kwargs, 57 | ): 58 | return self.bot.reply_message( 59 | self.user_phone_number, 60 | text, 61 | msg_id=self.message_id, 62 | reply_markup=reply_markup, 63 | header=header, 64 | header_type=header_type, 65 | footer=footer, 66 | web_page_preview=web_page_preview, 67 | tag_message=tag_message, 68 | *args, 69 | **kwargs, 70 | ) 71 | 72 | def reply_media( 73 | self, 74 | media_path, 75 | caption: str = None, 76 | media_provider_token: str = None, 77 | *args, 78 | **kwargs, 79 | ): 80 | return self.bot.reply_media( 81 | self.user_phone_number, 82 | media_path, 83 | caption, 84 | media_provider_token, 85 | *args, 86 | **kwargs, 87 | ) 88 | 89 | 90 | class UpdateData: 91 | def __init__(self) -> None: 92 | self.message_txt = "" 93 | self.list_reply = None 94 | 95 | # Media 96 | self.media_mime_type: str = None 97 | self.media_file_id: str = None 98 | self.media_hash: str = None 99 | self.media_voice: bool = False 100 | 101 | # Location 102 | self.loc_address: str = None 103 | self.loc_name: str = None 104 | self.loc_latitude: str = None 105 | self.loc_longitude: str = None 106 | 107 | 108 | class UpdateHandler: 109 | def __init__(self, context: bool = True, *args, **kwargs) -> None: 110 | self.name: str = None 111 | self.regex: str = None 112 | self.func: Callable 113 | self.action: Callable 114 | self.context = context 115 | self.list = None 116 | self.button = None 117 | self.persistent = False 118 | 119 | def extract_data(self, msg: Dict[str, dict]) -> UpdateData: 120 | data = UpdateData() 121 | data.message_txt = "" 122 | return data 123 | 124 | def filter_check(self, msg) -> bool: 125 | if self.regex: 126 | return bool(re.match(self.regex, msg)) 127 | if self.func: 128 | return bool(self.func(msg)) 129 | return True 130 | 131 | def run(self, *args, **kwargs): 132 | new_kwargs = { 133 | key: val 134 | for key, val in kwargs.items() 135 | if key in inspect.getfullargspec(self.action).args 136 | } 137 | return self.action(*args, **new_kwargs) 138 | 139 | async def arun(self, *args, **kwargs): 140 | new_kwargs = { 141 | key: val 142 | for key, val in kwargs.items() 143 | if key in inspect.getfullargspec(self.action).args 144 | } 145 | if not inspect.iscoroutinefunction(self.action): 146 | raise TypeError( 147 | f"function {self.action.__name__} must be an async function (coroutine), but it is not." 148 | ) 149 | return await self.action(*args, **new_kwargs) 150 | 151 | 152 | class MessageHandler(UpdateHandler): 153 | def __init__( 154 | self, 155 | regex: str = None, 156 | func: Callable = None, 157 | action: Callable = None, 158 | context: bool = True, 159 | persistent: bool = False, 160 | ) -> None: 161 | super().__init__(context) 162 | self.name = "text" 163 | self.regex = regex 164 | self.func = func 165 | self.action = action 166 | self.persistent = persistent 167 | 168 | def extract_data(self, msg) -> UpdateData: 169 | data = UpdateData() 170 | data.message_txt = msg.get("text", {}).get("body", "") 171 | return data 172 | 173 | 174 | class InteractiveQueryHandler(UpdateHandler): 175 | """For button_reply and list_reply""" 176 | 177 | def __init__( 178 | self, 179 | regex: str = None, 180 | func: Callable = None, 181 | handle_button: bool = True, 182 | handle_list: bool = True, 183 | action: Callable = None, 184 | context: bool = True, 185 | persistent: bool = False, 186 | ) -> None: 187 | super().__init__(context) 188 | self.name = "interactive" 189 | self.regex = regex 190 | self.func = func 191 | self.action = action 192 | self.list = handle_list 193 | self.button = handle_button 194 | self.persistent = persistent 195 | 196 | def extract_data(self, msg) -> UpdateData: 197 | message_txt = "" 198 | if msg["interactive"]["type"] == "button_reply" and self.button: 199 | message_txt = msg.get("interactive", {}).get("button_reply", {}).get("id") 200 | elif msg["interactive"]["type"] == "list_reply" and self.list: 201 | message_txt = msg.get("interactive", {}).get("list_reply", {}).get("id") 202 | data = UpdateData() 203 | data.message_txt = message_txt 204 | return data 205 | 206 | 207 | class MediaHandler(UpdateHandler): 208 | def __init__( 209 | self, 210 | regex: str = None, 211 | func: Callable = None, 212 | action: Callable = None, 213 | context: bool = True, 214 | persistent: bool = False, 215 | ) -> None: 216 | super().__init__(context) 217 | self.regex = regex 218 | self.func = func 219 | self.action = action 220 | self.persistent = persistent 221 | 222 | 223 | class ImageHandler(MediaHandler): 224 | def __init__(self, *args, **kwargs) -> None: 225 | super().__init__(*args, **kwargs) 226 | self.name = "image" 227 | 228 | def extract_data(self, msg) -> UpdateData: 229 | data = UpdateData() 230 | img_data = msg.get("image", {}) 231 | data.message_txt = img_data.get("caption", "") 232 | data.media_mime_type = img_data.get("mime_type", "") 233 | data.media_file_id = img_data.get("id", "") 234 | data.media_hash = img_data.get("sha256", "") 235 | return data 236 | 237 | 238 | class AudioHandler(MediaHandler): 239 | def __init__(self, *args, **kwargs) -> None: 240 | super().__init__(*args, **kwargs) 241 | self.name = "audio" 242 | 243 | def extract_data(self, msg) -> UpdateData: 244 | data = UpdateData() 245 | audio_data = msg.get("audio", {}) 246 | data.media_mime_type = audio_data.get("mime_type", "") 247 | data.media_file_id = audio_data.get("id", "") 248 | data.media_hash = audio_data.get("sha256", "") 249 | data.media_voice = audio_data.get("voice", "") 250 | return data 251 | 252 | 253 | class VideoHandler(MediaHandler): 254 | def __init__(self, *args, **kwargs) -> None: 255 | super().__init__(*args, **kwargs) 256 | self.name = "video" 257 | 258 | def extract_data(self, msg) -> UpdateData: 259 | data = UpdateData() 260 | vid_data = msg.get("video", {}) 261 | data.message_txt = vid_data.get("caption", "") 262 | data.media_mime_type = vid_data.get("mime_type", "") 263 | data.media_file_id = vid_data.get("id", "") 264 | data.media_hash = vid_data.get("sha256", "") 265 | return data 266 | 267 | 268 | class StickerHandler(MediaHandler): 269 | def __init__(self, *args, **kwargs) -> None: 270 | super().__init__(*args, **kwargs) 271 | self.name = "sticker" 272 | 273 | def extract_data(self, msg) -> UpdateData: 274 | data = UpdateData() 275 | stckr_data = msg.get("sticker", {}) 276 | data.media_mime_type = stckr_data.get("mime_type", "") 277 | data.media_file_id = stckr_data.get("id", "") 278 | data.media_hash = stckr_data.get("sha256", "") 279 | return data 280 | 281 | 282 | class LocationHandler(UpdateHandler): 283 | def __init__( 284 | self, 285 | regex: str = None, 286 | func: Callable = None, 287 | action: Callable = None, 288 | context: bool = True, 289 | persistent: bool = False, 290 | ) -> None: 291 | super().__init__(context) 292 | self.name = "location" 293 | self.regex = regex 294 | self.func = func 295 | self.action = action 296 | self.persistent = persistent 297 | 298 | def extract_data(self, msg) -> UpdateData: 299 | data = UpdateData() 300 | loc_data = msg.get("location", {}) 301 | loc_name = loc_data.get("name", "") 302 | data.loc_address = loc_data.get("address", "") 303 | data.loc_name = loc_data.get("name", "") 304 | data.loc_latitude = loc_data.get("latitude", "") 305 | data.loc_longitude = loc_data.get("longitude", "") 306 | data.message_txt = ( 307 | loc_name + "\n" + data.loc_address 308 | if data.loc_address 309 | else f"long - _{data.loc_longitude}_\nlat - _{data.loc_longitude}_" 310 | ) 311 | 312 | return data 313 | 314 | 315 | class UnknownHandler(UpdateHandler): 316 | def __init__( 317 | self, 318 | regex: str = None, 319 | func: Callable = None, 320 | action: Callable = None, 321 | context: bool = True, 322 | persistent: bool = False, 323 | ) -> None: 324 | super().__init__(context) 325 | self.name = "unknown" 326 | self.regex = regex 327 | self.func = func 328 | self.action = action 329 | self.persistent = persistent 330 | 331 | 332 | class UnsupportedHandler(UpdateHandler): 333 | def __init__( 334 | self, 335 | regex: str = None, 336 | func: Callable = None, 337 | action: Callable = None, 338 | context: bool = True, 339 | persistent: bool = False, 340 | ) -> None: 341 | super().__init__(context) 342 | self.name = "unsupported" 343 | self.regex = regex 344 | self.func = func 345 | self.action = action 346 | self.persistent = persistent 347 | -------------------------------------------------------------------------------- /src/python_whatsapp_bot/markup.py: -------------------------------------------------------------------------------- 1 | from typing import Any, Dict, Union 2 | 3 | 4 | class Reply_markup: 5 | type: str 6 | 7 | def __init__(self, markup: Dict[str, Any]) -> None: 8 | self.markup = markup 9 | 10 | # if item is for keyboard, initialize with button settings 11 | # if item is for list actions, initialize with list settings 12 | 13 | 14 | class InlineLocationRequest(Reply_markup): 15 | """This is used to request a location from the user. 16 | It is a button that when clicked, opens the user's location. 17 | Args: 18 | text: (str),required - Specifies the text of the button 19 | button_id: (str),optional - Specifies the id of the button. If not provided, it will be set to the text of the button 20 | """ 21 | 22 | type: str = "location_request_message" 23 | 24 | def __init__(self, text: str): 25 | self.action = self.get_action() 26 | super().__init__(self.action) 27 | 28 | def get_action(self): 29 | return {"name": "send_location"} 30 | 31 | 32 | class Inline_button: 33 | def __init__(self, text: str, button_id: str = None): 34 | 35 | self.button = { 36 | "type": "reply", 37 | "reply": { 38 | "id": button_id if button_id else text, 39 | "title": text 40 | } 41 | } 42 | __slots__ = ('button') 43 | 44 | 45 | class Inline_keyboard(Reply_markup): 46 | """Accepts only three(3) text (or buttons) in a flat list. 47 | Minimum of one(1)""" 48 | type: str = "button" 49 | 50 | def __init__(self, inline_buttons: Union[list[str], list[Inline_button]]): 51 | self.inline_buttons = self.set_buttons(inline_buttons) 52 | self.error_check() 53 | self.keyboard = self.set_keys() 54 | super().__init__(self.keyboard) 55 | 56 | def set_buttons(self, _buttons: Union[list[str], list[Inline_button]]): 57 | if not isinstance(_buttons, list): 58 | raise ValueError("List argument expected") 59 | res = [] 60 | for i in _buttons: 61 | if isinstance(i, str): 62 | res.append(Inline_button(i)) 63 | elif isinstance(i, Inline_button): 64 | res.append(i) 65 | else: 66 | raise ValueError( 67 | "str or Inline_button expected as button elements") 68 | return res 69 | 70 | def error_check(self): 71 | if len(self.inline_buttons) > 3 or len(self.inline_buttons) < 1: 72 | raise ValueError( 73 | f"Inline_keyboard can only accept minimum of 1 Inline_button item and maximum of 3, you added {len(self.inline_buttons)}" 74 | ) 75 | button_id_check = [] 76 | button_text_check = [] 77 | for i, button in enumerate(self.inline_buttons): 78 | if not isinstance(button, Inline_button): 79 | raise ValueError( 80 | f"Item at position {i} of list argument expected to be string or an instance of Inline_button") 81 | butt = button.button['reply']['id'] 82 | buttt = button.button['reply']['title'] 83 | if butt in button_id_check or buttt in button_text_check: 84 | raise ValueError("Use unique id and text for the buttons") 85 | button_id_check.append(butt) 86 | button_text_check.append(buttt) 87 | 88 | def set_keys(self): 89 | action = {"buttons": [i.button for i in self.inline_buttons]} 90 | return action 91 | 92 | 93 | class List_item(): 94 | def __init__(self, title: str, _id: str = None, description: str = None) -> None: 95 | self.title = title 96 | self._id = _id if _id else self.title 97 | self.item = { 98 | "id": self._id, 99 | "title": self.title 100 | } 101 | if description: 102 | self.item["description"] = description 103 | __slots__ = ('title', 'item', '_id') 104 | 105 | 106 | class List_section(): 107 | def __init__(self, title: str, items_list: Union[list[str], list[List_item]]) -> None: 108 | self.title = title 109 | self.items_list = self.set_list(items_list) 110 | self.error_check() 111 | self.section = self.set_section() 112 | 113 | def set_list(self, _items_list: Union[list[str], list[List_item]]): 114 | if not isinstance(_items_list, list): 115 | raise ValueError("List argument expected") 116 | res = [] 117 | for i in _items_list: 118 | if isinstance(i, str): 119 | res.append(List_item(i)) 120 | elif isinstance(i, List_item): 121 | res.append(i) 122 | else: 123 | raise ValueError( 124 | "str or List_item object expected as items_list elements") 125 | return res 126 | 127 | def error_check(self): 128 | for i, item in enumerate(self.items_list): 129 | if not isinstance(item, List_item): 130 | raise ValueError( 131 | f"Item at position {i} of list argument expected to be an instance of Inline_button") 132 | 133 | def set_section(self): 134 | sections = {"title": self.title, 135 | "rows": [i.item for i in self.items_list]} 136 | return sections 137 | 138 | 139 | class Inline_list(Reply_markup): 140 | """Accepts up to ten(10) list items. Minimum of one(1) 141 | Accepts one level of nesting, e.g [[],[],[]]. 142 | Args: 143 | button_text: (str),required - Specifies the button that displays the list items when clicked 144 | list_items:(list) required - Specifies the list items to be listed. Maximum of ten items. 145 | These Items are defined with the List_item class. 146 | To use sections, pass a list of List_section instances instead""" 147 | type: str = "list" 148 | 149 | def __init__(self, button_text: str, list_items: Union[list[List_item], list[List_section]]): 150 | self.button_text = button_text 151 | self.list_items = list_items 152 | self.error_check() 153 | self.inline_list = self.set_list() 154 | super().__init__(self.inline_list) 155 | 156 | def error_check(self): 157 | if not isinstance(self.list_items, list): 158 | raise ValueError( 159 | "The argument for list_items should be of type 'list'") 160 | 161 | for i, item in enumerate(self.list_items): 162 | if not (isinstance(item, List_item) or isinstance(item, List_section)): 163 | # Check that non-nested list is List_item 164 | raise ValueError( 165 | f"Item at position {i} of list argument expected to be an instance of List_item or list of List_section") 166 | 167 | def set_list(self): 168 | action = { 169 | "button": self.button_text, 170 | "sections": [i.section for i in self.list_items] if isinstance(self.list_items, List_section) else [{"rows": [i.item for i in self.list_items]}]} 171 | return action 172 | -------------------------------------------------------------------------------- /src/python_whatsapp_bot/message.py: -------------------------------------------------------------------------------- 1 | import mimetypes 2 | import os 3 | from pathlib import Path 4 | import re 5 | from typing import Optional, Union 6 | import json 7 | import requests 8 | from .markup import ( 9 | Reply_markup, 10 | Inline_button, 11 | Inline_keyboard, 12 | Inline_list, 13 | List_item, 14 | List_section, 15 | InlineLocationRequest, 16 | ) 17 | 18 | TIMEOUT: int = 30 19 | KNOWN_EXTENSIONS = { 20 | "text/plain": ".txt", 21 | "image/jpeg": ".jpg", 22 | "image/png": ".png", 23 | "image/gif": ".gif", 24 | "video/mp4": ".mp4", 25 | "audio/mp3": ".mp3", 26 | "audio/mpeg": ".mp3", 27 | "audio/wav": ".wav", 28 | "audio/aac": ".aac", 29 | "audio/ogg": ".opus", 30 | "audio/webm": ".webm", 31 | "application/pdf": ".pdf", 32 | "application/msword": ".doc", 33 | "application/vnd.openxmlformats-officedocument.wordprocessingml.document": ".docx", 34 | "application/vnd.ms-powerpoint": ".ppt", 35 | "application/vnd.openxmlformats-officedocument.presentationml.presentation": ".pptx", 36 | "application/vnd.ms-excel": ".xls", 37 | "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet": ".xlsx", 38 | } 39 | 40 | 41 | def headers(WA_TOKEN): 42 | return {"Content-Type": "application/json", "Authorization": f"Bearer {WA_TOKEN}"} 43 | 44 | 45 | def mark_as_read(update, url: str, token: str): 46 | payload = json.dumps( 47 | { 48 | "messaging_product": "whatsapp", 49 | "status": "read", 50 | "message_id": update["id"], 51 | "typing_indicator": {"type": "text"}, 52 | } 53 | ) 54 | response = requests.post(url, headers=headers(token), data=payload, timeout=TIMEOUT) 55 | return response 56 | 57 | 58 | def message_text( 59 | url: str, 60 | token: str, 61 | phone_num: str, 62 | text: str, 63 | msg_id: str = "", 64 | web_page_preview=True, 65 | tag_message: bool = True, 66 | ): 67 | message_frame = { 68 | "messaging_product": "whatsapp", 69 | "to": str(phone_num), 70 | "recipient_type": "individual", 71 | "type": "text", 72 | "text": {"body": text, "preview_url": web_page_preview}, 73 | } 74 | if msg_id and tag_message: 75 | message_frame["context"] = {"message_id": msg_id} 76 | payload = json.dumps(message_frame) 77 | response = requests.post(url, headers=headers(token), data=payload, timeout=TIMEOUT) 78 | return response 79 | 80 | 81 | def message_interactive( 82 | url: str, 83 | token: str, 84 | phone_num: str, 85 | text: str, 86 | reply_markup: Reply_markup, 87 | msg_id: str = "", 88 | header: str = None, 89 | header_type: str = "text", 90 | footer: str = None, 91 | web_page_preview=True, 92 | ): 93 | if not isinstance(reply_markup, Reply_markup): 94 | raise ValueError("Reply markup must be a Reply_markup object") 95 | message_frame = { 96 | "messaging_product": "whatsapp", 97 | "to": str(phone_num), 98 | "recipient_type": "individual", 99 | "type": "interactive", 100 | "interactive": { 101 | "type": reply_markup.type, # [button, list, location_request_message] 102 | "body": {"text": text}, 103 | "action": reply_markup.markup, 104 | }, 105 | } 106 | if msg_id: 107 | message_frame["context"] = {"message_id": msg_id} 108 | if header: 109 | if header_type == "text": 110 | message_frame["interactive"]["header"] = { 111 | "type": "text", # [text,video,image,document] 112 | "text": header, 113 | } 114 | elif header_type in ["image", "video", "document"]: 115 | if re.match(r"^((http[s]?://)|(www.))", header): 116 | header_type_object = {"link": header} 117 | else: 118 | header_type_object = {"id": header} 119 | message_frame["interactive"]["header"] = { 120 | "type": header_type, # [text,video,image,document] 121 | header_type: header_type_object, 122 | } 123 | 124 | if footer: 125 | message_frame["interactive"]["footer"] = {"text": footer} 126 | payload = json.dumps(message_frame) 127 | response = requests.post(url, headers=headers(token), data=payload, timeout=TIMEOUT) 128 | return response 129 | 130 | 131 | def message_template( 132 | url: str, 133 | token: str, 134 | phone_num: str, 135 | template_name: str, 136 | components: list = None, 137 | language_code: str = "en_US", 138 | ): 139 | payload = json.dumps( 140 | { 141 | "messaging_product": "whatsapp", 142 | "to": str(phone_num), 143 | "recipient_type": "individual", 144 | "type": "template", 145 | "template": { 146 | "name": template_name, 147 | "language": {"code": language_code}, 148 | "components": list(components) if components is not None else [], 149 | }, 150 | } 151 | ) 152 | response = requests.post(url, headers=headers(token), data=payload, timeout=TIMEOUT) 153 | return response 154 | 155 | 156 | def upload_media(url: str, token: str): 157 | payload = json.dumps( 158 | { 159 | "messaging_product": "whatsapp", 160 | } 161 | ) 162 | response = requests.post(url, headers=headers(token), data=payload, timeout=TIMEOUT) 163 | return response 164 | 165 | 166 | def get_media_url(base_url: str, media_id: str, token: str): 167 | url = f"{base_url}/{media_id}" 168 | response = requests.get(url, headers=headers(token), timeout=TIMEOUT) 169 | return response 170 | 171 | 172 | def download_media( 173 | base_url: str, media_id: str, token: str, relative_file_path: str = "/media" 174 | ): 175 | if not media_id: 176 | return None 177 | # Generate the absolute file path from the relative path 178 | file_path = Path("tmp/" + relative_file_path).resolve() / media_id 179 | 180 | # Ensure the file has the correct extension 181 | media_data = get_media_url(base_url, media_id, token).json() 182 | media_url = media_data["url"] 183 | mime_type = media_data["mime_type"] 184 | extension = ( 185 | KNOWN_EXTENSIONS.get(mime_type) 186 | or mimetypes.guess_extension(mime_type, strict=True) 187 | or ".bin" 188 | ) 189 | 190 | file_path = file_path.with_suffix(extension) 191 | 192 | # Create the directory if it does not exist 193 | file_path.parent.mkdir(parents=True, exist_ok=True) 194 | 195 | # Download the media file 196 | with requests.get( 197 | media_url, headers=headers(token), stream=True, timeout=TIMEOUT 198 | ) as response, open(file_path, "wb") as file: 199 | for chunk in response.iter_content(chunk_size=8192): 200 | if chunk: 201 | file.write(chunk) 202 | 203 | return file_path 204 | 205 | 206 | def download_media_data( 207 | base_url: str, media_id: str, token: str, relative_file_path: str = "/media" 208 | ): 209 | if not media_id: 210 | return None 211 | media_data = get_media_url(base_url, media_id, token).json() 212 | media_url = media_data["url"] 213 | 214 | # Download the media file 215 | return requests.get(media_url, headers=headers(token), stream=True, timeout=TIMEOUT) 216 | 217 | 218 | def message_media( 219 | url: str, 220 | token: str, 221 | phone_num: str, 222 | image_path: str, 223 | caption: str = None, 224 | media_provider_token: str = None, 225 | ): 226 | payload = json.dumps( 227 | { 228 | "messaging_product": "whatsapp", 229 | "to": str(phone_num), 230 | "recipient_type": "individual", 231 | "type": "image", 232 | "image": { 233 | # "id" : "MEDIA-OBJECT-ID" 234 | "link": image_path, 235 | "caption": caption, 236 | }, 237 | } 238 | ) 239 | response = requests.post(url, headers=headers(token), data=payload, timeout=TIMEOUT) 240 | return response 241 | 242 | 243 | def message_location( 244 | url: str, 245 | token: str, 246 | phone_num: str, 247 | location_latitude: str, 248 | location_longitude: str, 249 | location_name: Optional[str] = None, 250 | location_address: Optional[str] = None, 251 | ): 252 | location_data: dict = { 253 | "latitude": location_latitude, 254 | "longitude": location_longitude, 255 | } 256 | if location_name: 257 | location_data["name"] = location_name 258 | if location_address: 259 | location_data["address"] = location_address 260 | payload = json.dumps( 261 | { 262 | "messaging_product": "whatsapp", 263 | "recipient_type": "individual", 264 | "to": str(phone_num), 265 | "type": "location", 266 | "location": location_data, 267 | } 268 | ) 269 | response = requests.post(url, headers=headers(token), data=payload, timeout=TIMEOUT) 270 | return response 271 | 272 | 273 | def message_location_request( 274 | url: str, 275 | token: str, 276 | phone_num: str, 277 | location_latitude: str, 278 | location_longitude: str, 279 | location_name: Optional[str] = None, 280 | location_address: Optional[str] = None, 281 | ): 282 | 283 | payload = json.dumps( 284 | { 285 | "messaging_product": "whatsapp", 286 | "recipient_type": "individual", 287 | "to": str(phone_num), 288 | "type": "interactive", 289 | "interactive": { 290 | "type": "location_request_message", 291 | "body": {"text": ""}, 292 | "action": {"name": "send_location"}, 293 | }, 294 | } 295 | ) 296 | response = requests.post(url, headers=headers(token), data=payload, timeout=TIMEOUT) 297 | return response 298 | -------------------------------------------------------------------------------- /src/python_whatsapp_bot/py.typed: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/radi-dev/python-whatsapp-bot/3afca6338e55041e318a26fb8aadcb0d0546460d/src/python_whatsapp_bot/py.typed -------------------------------------------------------------------------------- /src/python_whatsapp_bot/user_context.py: -------------------------------------------------------------------------------- 1 | class _Context: 2 | """Object to store all users data in a conversation""" 3 | 4 | def __init__(self) -> None: 5 | self.users_data = {} 6 | 7 | def _add_user(self, phone_num): 8 | self.users_data[str(phone_num)] = {} 9 | 10 | def _user_exists(self, phone_num): 11 | if len(self.users_data): 12 | if phone_num in self.users_data.keys(): 13 | return True 14 | 15 | 16 | _context = _Context() 17 | 18 | 19 | class User_context(): 20 | """Object that manages a specific user's data in a conversation. 21 | The user's phone number id is used as the id""" 22 | 23 | def __init__(self, phone_num: str) -> None: 24 | if not _context._user_exists(phone_num): 25 | _context._add_user(phone_num) 26 | self.user_data = _context.users_data[str(phone_num)] 27 | 28 | # def user_data(self): 29 | # pass 30 | -------------------------------------------------------------------------------- /src/python_whatsapp_bot/whatsapp.py: -------------------------------------------------------------------------------- 1 | from .dispatcher import Dispatcher, Update 2 | from .message import ( 3 | download_media, 4 | download_media_data, 5 | get_media_url, 6 | message_interactive, 7 | mark_as_read, 8 | message_text, 9 | message_template, 10 | upload_media, 11 | message_media, 12 | message_location, 13 | ) 14 | from .markup import Reply_markup 15 | from typing import Union 16 | from queue import Queue 17 | 18 | 19 | class Whatsapp: 20 | 21 | def __init__(self, number_id: int, token: str, mark_as_read: bool = True) -> None: 22 | """This is the main Whatsapp class. Use it to initialize your bot 23 | Args: 24 | id: Your phone number id provided by WhatsApp cloud 25 | token : Your token provided by WhatsApp cloud 26 | mark_as_read:(bool), Use to set whether incoming messages should be marked as read. Default is True 27 | """ 28 | self.version_number: int = 21 29 | self.queue = Queue() 30 | self.threaded = True 31 | self.id = number_id 32 | self.token = token 33 | self.base_url = f"https://graph.facebook.com/v{str(float(self.version_number))}" 34 | self.msg_url = self.base_url + f"/{str(self.id)}/messages" 35 | self.media_url = self.base_url + f"/{str(self.id)}/media" 36 | self.dispatcher = Dispatcher(self, mark_as_read) 37 | self.on_message = self.dispatcher.add_message_handler 38 | self.on_interactive_message = self.dispatcher.add_interactive_handler 39 | self.on_image_message = self.dispatcher.add_image_handler 40 | self.on_audio_message = self.dispatcher.add_audio_handler 41 | self.on_video_message = self.dispatcher.add_video_handler 42 | self.on_sticker_message = self.dispatcher.add_sticker_handler 43 | self.on_location_message = self.dispatcher.add_location_handler 44 | self.set_next_step = self.dispatcher.set_next_handler 45 | 46 | def _set_base_url(self): 47 | self.base_url = f"https://graph.facebook.com/v{str(float(self.version_number))}" 48 | 49 | def set_version(self, version_number: int): 50 | self.version_number = version_number 51 | self._set_base_url() 52 | 53 | def process_update(self, update): 54 | return self.dispatcher.process_update(update) 55 | 56 | def mark_as_read(self, update): 57 | """Mark any message as read""" 58 | return mark_as_read(update, self.msg_url, self.token) 59 | 60 | def reply_message( 61 | self, 62 | phone_num: str, 63 | text: str, 64 | msg_id: str = "", 65 | reply_markup: Reply_markup = None, 66 | header: str = None, 67 | header_type: str = "text", 68 | footer: str = None, 69 | web_page_preview=True, 70 | tag_message: bool = True, 71 | ): 72 | return self.send_message( 73 | phone_num, 74 | text, 75 | msg_id, 76 | reply_markup, 77 | header, 78 | header_type, 79 | footer, 80 | web_page_preview=web_page_preview, 81 | tag_message=tag_message, 82 | ) 83 | 84 | def reply_template(self, update: Update, template_name: str): 85 | return self.send_template_message(update.user_phone_number, template_name) 86 | 87 | def reply_media( 88 | self, 89 | update: Update, 90 | image_path: str, 91 | caption: str = None, 92 | media_provider_token: str = None, 93 | ): 94 | return self.send_media_message( 95 | update.user_phone_number, image_path, caption, media_provider_token 96 | ) 97 | 98 | def send_message( 99 | self, 100 | phone_num: str, 101 | text: str, 102 | msg_id: str = "", 103 | reply_markup: Reply_markup = None, 104 | header: str = None, 105 | header_type: str = "text", 106 | footer: str = None, 107 | web_page_preview=True, 108 | tag_message: bool = True, 109 | ): 110 | """Sends text message 111 | Args: 112 | phone_num:(int) Recipeint's phone number 113 | text:(str) The text to be sent 114 | web_page_preview:(bool),optional. Turn web page preview of links on/off 115 | """ 116 | if reply_markup: 117 | return message_interactive( 118 | self.msg_url, 119 | self.token, 120 | phone_num, 121 | text, 122 | reply_markup, 123 | msg_id=msg_id, 124 | header=header, 125 | header_type=header_type, 126 | footer=footer, 127 | web_page_preview=web_page_preview, 128 | ) 129 | else: 130 | return message_text( 131 | self.msg_url, 132 | self.token, 133 | phone_num, 134 | text, 135 | msg_id=msg_id, 136 | web_page_preview=web_page_preview, 137 | tag_message=tag_message, 138 | ) 139 | 140 | def send_template_message( 141 | self, 142 | phone_num: str, 143 | template_name: str, 144 | components: list = None, 145 | language_code: str = None, 146 | ): 147 | """Sends preregistered template message""" 148 | return message_template( 149 | self.msg_url, 150 | self.token, 151 | phone_num, 152 | template_name, 153 | components, 154 | language_code, 155 | ) 156 | 157 | def upload_media( 158 | self, 159 | ): 160 | return upload_media(self.media_url, self.token) 161 | 162 | def get_media_url(self, media_id: str): 163 | return get_media_url(self.base_url, media_id, self.token).json() 164 | 165 | def download_media(self, media_id: str, file_path: str): 166 | return download_media(self.base_url, media_id, self.token, file_path) 167 | 168 | def download_media_data(self, media_id: str, file_path: str): 169 | return download_media_data(self.base_url, media_id, self.token, file_path) 170 | 171 | def send_media_message( 172 | self, 173 | phone_num: str, 174 | image_path: str, 175 | caption: str = None, 176 | media_provider_token: str = None, 177 | ): 178 | """Sends media message which may include audio, document, image, sticker, or video 179 | Using media link or by uploading media from file. 180 | paths starting with http(s):// or www. will be treated as link, others will be treated as local files 181 | """ 182 | return message_media( 183 | self.msg_url, 184 | self.token, 185 | phone_num, 186 | image_path, 187 | caption, 188 | media_provider_token, 189 | ) 190 | --------------------------------------------------------------------------------