From a55f7de357748ef13f507d9cb00b241e8978bec5 Mon Sep 17 00:00:00 2001 From: karmaplush Date: Mon, 3 Jun 2024 13:37:23 +0500 Subject: [PATCH 1/6] Receiving notifications via webhooks implemented (via `whatsapp-api-webhook-server-python-v2`) Minor refactoring of main `Bot` class --- requirements.txt | 1 + whatsapp_chatbot_python/bot.py | 336 ++++++++++++++++++++++----------- 2 files changed, 226 insertions(+), 111 deletions(-) diff --git a/requirements.txt b/requirements.txt index 9613970..d7d0691 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1 +1,2 @@ whatsapp-api-client-python==0.0.45 +whatsapp-api-webhook-server-python-v2==0.0.1 diff --git a/whatsapp_chatbot_python/bot.py b/whatsapp_chatbot_python/bot.py index bed9724..ff43b54 100644 --- a/whatsapp_chatbot_python/bot.py +++ b/whatsapp_chatbot_python/bot.py @@ -8,159 +8,273 @@ class Bot: + def __init__( - self, - id_instance: str, - api_token_instance: str, - debug_mode: bool = False, - raise_errors: bool = False, - host: Optional[str] = None, - media: Optional[str] = None, - bot_debug_mode: bool = False, - settings: Optional[dict] = None, - delete_notifications_at_startup: bool = True + self, + id_instance: str, + api_token_instance: str, + debug_mode: bool = False, + raise_errors: bool = False, + host: Optional[str] = None, + media: Optional[str] = None, + bot_debug_mode: bool = False, + settings: Optional[dict] = None, + delete_notifications_at_startup: bool = True, + webhook_mode: bool = False, + webhook_host: str = "0.0.0.0", + webhook_port: int = 8080, + webhook_auth_header: Optional[str] = None, ): - self.id_instance = id_instance - self.api_token_instance = api_token_instance - self.debug_mode = debug_mode - self.raise_errors = raise_errors + """ + Init args: - self.api = GreenAPI( - id_instance, - api_token_instance, - debug_mode=debug_mode, - raise_errors=raise_errors, - host=host or "https://api.green-api.com", - media=media or "https://media.green-api.com" - ) + - `id_instance: str` - (required) Instance ID - self.bot_debug_mode = bot_debug_mode + - `api_token_instance: str` - (required) Api Token - self.logger = logging.getLogger("whatsapp-chatbot-python") - self.__prepare_logger() + - `debug_mode: bool` - (default: `False`) Debug mode (extended logging) + for API wrapper - if not settings: - self._update_settings() - else: - self.logger.log(logging.DEBUG, "Updating instance settings.") + - `raise_errors: bool` - (default: `False`) Raise errors when it handled + (for long polling mode only), otherwise - skip it - self.api.account.setSettings(settings) + - `host: str | None` - (default: `None`) API host url + ("https://api.green-api.com" if `None` provided) - if bot_debug_mode: - if not delete_notifications_at_startup: - delete_notifications_at_startup = True + - `media: str | None` - (default: `None`) API host url + ("https://media.green-api.com" if `None` provided) - self.logger.log( - logging.DEBUG, "Enabled delete_notifications_at_startup." - ) + - `bot_debug_mode: bool` - (default: `False`) + Debug mode (extended logging) for bot - if delete_notifications_at_startup: - self._delete_notifications_at_startup() + - `delete_notifications_at_startup: bool` - (default: `True`) Remove all + notifications from notification queue on bot startup. If `bot_debug_mode` + is `True` - this arg will be setted as `True` when bot object init - self.router = Router(self.api, self.logger) + - `webhook_mode: bool` - (default: `False`) Launch bot in webhook-server + mode. All notifcations will recieving via webhooks. + Otherwise - bot will running in long polling mode. - def run_forever(self) -> Optional[NoReturn]: - self.api.session.headers["Connection"] = "keep-alive" + - `webhook_host: str` - (default: `"0.0.0.0"`) Host for webhook server. - self.logger.log( - logging.INFO, "Started receiving incoming notifications." - ) + - `webhook_post: int` - (default: `8080`) Port for webhook server. - while True: - try: - response = self.api.receiving.receiveNotification() + - `webhook_auth_header: str | None` - (default: `None`) Check that the + authorization header matches the specified value. + Will be ignored if set to `None` - if not response.data: - continue - response = response.data + """ + + self.id_instance = id_instance + self.api_token_instance = api_token_instance + self.api_debug_mode = debug_mode + self.api_host = host + self.api_media = media + self.api_raise_errors = raise_errors + self.instance_settings = settings + self.bot_debug_mode = bot_debug_mode + self.delete_notifications_at_startup = delete_notifications_at_startup + self.webhook_mode = webhook_mode + self.webhook_host = webhook_host + self.webhook_port = webhook_port + self.__webhook_auth_header = webhook_auth_header + + self.__init_logger() + + self.logger.info("Bot initialization...") + + self.__init_api_wrapper() + self.__init_instance_settings() + self.__delete_notifications_at_startup() + self.__init_router() + + if self.webhook_mode: + from whatsapp_api_webhook_server_python_v2 import GreenAPIWebhookServer + + self.__init_webhook_handler() + self.__init_webhook_server() + + self.logger.info("Bot initialization success") - self.router.route_event(response["body"]) + def __init_logger(self) -> None: - self.api.receiving.deleteNotification(response["receiptId"]) - except KeyboardInterrupt: - break - except Exception as error: - if self.raise_errors: - raise GreenAPIBotError(error) - self.logger.log(logging.ERROR, error) + logger = logging.getLogger("whatsapp-chatbot-python") + if self.bot_debug_mode: + logger.setLevel(logging.DEBUG) + else: + logger.setLevel(logging.INFO) - time.sleep(5.0) + handler = logging.StreamHandler() + handler.setFormatter( + logging.Formatter( + "%(asctime)s [%(levelname)s] %(name)s: %(message)s", + datefmt="%Y-%m-%d %H:%M:%S", + ) + ) + logger.addHandler(handler) + self.logger = logger + self.logger.debug("Logger inited") - continue + def __init_api_wrapper(self) -> None: - self.api.session.headers["Connection"] = "close" + self.logger.debug("GreenAPI wrapper initialization...") - self.logger.log( - logging.INFO, "Stopped receiving incoming notifications." + self.api = GreenAPI( + self.id_instance, + self.api_token_instance, + debug_mode=self.api_debug_mode, + raise_errors=self.api_raise_errors, + host=self.api_host or "https://api.green-api.com", + media=self.api_media or "https://media.green-api.com", ) - def _update_settings(self) -> Optional[NoReturn]: - self.logger.log(logging.DEBUG, "Checking current instance settings.") + self.logger.debug("GreenAPI wrapper OK") + + def __init_instance_settings(self) -> None: - settings = self.api.account.getSettings() + if self.instance_settings: + self.logger.debug("Updating instance settings") + self.api.account.setSettings(self.instance_settings) - response = settings.data + else: + self.logger.debug("Getting instance settings...") + + account_settings_response = self.api.account.getSettings() + account_settings_data = account_settings_response.data + + incoming_webhook = account_settings_data["incomingWebhook"] + outgoing_message_webhook = account_settings_data["outgoingMessageWebhook"] + outgoing_api_message_webhook = account_settings_data[ + "outgoingAPIMessageWebhook" + ] + + self.logger.debug( + f"Instance [{self.id_instance}] settings status (Incoming webhook, " + "Outgoing message webhook, Outgoing API message webhook): " + f"({incoming_webhook}, {outgoing_message_webhook}, " + f"{outgoing_api_message_webhook})" + ) - incoming_webhook = response["incomingWebhook"] - outgoing_message_webhook = response["outgoingMessageWebhook"] - outgoing_api_message_webhook = response["outgoingAPIMessageWebhook"] - if ( - incoming_webhook == "no" - and outgoing_message_webhook == "no" - and outgoing_api_message_webhook == "no" - ): - self.logger.log( - logging.INFO, ( + if all( + webhook == "no" + for webhook in [ + incoming_webhook, + outgoing_message_webhook, + outgoing_api_message_webhook, + ] + ): + self.logger.info( "All message notifications are disabled. " "Enabling incoming and outgoing notifications. " "Settings will be applied within 5 minutes." ) - ) - self.api.account.setSettings({ - "incomingWebhook": "yes", - "outgoingMessageWebhook": "yes", - "outgoingAPIMessageWebhook": "yes" - }) + self.api.account.setSettings( + { + "incomingWebhook": "yes", + "outgoingMessageWebhook": "yes", + "outgoingAPIMessageWebhook": "yes", + } + ) + + self.logger.debug("Instance settings OK") - def _delete_notifications_at_startup(self) -> Optional[NoReturn]: - self.api.session.headers["Connection"] = "keep-alive" + def __delete_notifications_at_startup(self) -> None: - self.logger.log( - logging.DEBUG, "Started deleting old incoming notifications." - ) + if self.bot_debug_mode: + self.delete_notifications_at_startup = True + self.logger.debug("Enabled delete_notifications_at_startup") - while True: - response = self.api.receiving.receiveNotification() + if self.delete_notifications_at_startup: - if not response.data: - break + self.api.session.headers["Connection"] = "keep-alive" + self.logger.debug("Started deleting old incoming notifications") - self.api.receiving.deleteNotification(response.data["receiptId"]) + while True: + response = self.api.receiving.receiveNotification() - self.api.session.headers["Connection"] = "close" + if not response.data: + break - self.logger.log( - logging.DEBUG, "Stopped deleting old incoming notifications." - ) + self.api.receiving.deleteNotification(response.data["receiptId"]) - self.logger.log(logging.INFO, "Deleted old incoming notifications.") + self.api.session.headers["Connection"] = "close" + self.logger.debug("Stopped deleting old incoming notifications") + self.logger.debug("Old notifications was deleted successfull") - def __prepare_logger(self) -> None: - handler = logging.StreamHandler() - handler.setFormatter(logging.Formatter( - ( - "%(asctime)s:%(name)s:" - "%(levelname)s:%(message)s" - ), datefmt="%Y-%m-%d %H:%M:%S" - )) + else: + self.logger.debug("Deleting notifications at startup is disbaled, skip") + + def __init_router(self) -> None: + + self.logger.debug("Router initialization...") + self.router = Router(self.api, self.logger) + self.logger.debug("Router OK") + + def __init_webhook_handler(self) -> None: + + self.logger.debug("Webhook handler initialization...") + + def webhook_handler(webhook_type: str, webhook_data: str): + self.router.route_event(webhook_data) + + self._webhook_handler = webhook_handler + self.logger.debug("Webhook handler OK") - self.logger.addHandler(handler) + def __init_webhook_server(self) -> None: + + self.logger.debug("GreenAPI webhook server initialization...") + self._webhook_server = GreenAPIWebhookServer( + event_handler=self._webhook_handler, + host=self.webhook_host, + port=self.webhook_port, + webhook_auth_header=self.__webhook_auth_header, + return_keys_by_alias=True, + ) + self.logger.debug("GreenAPI webhook server OK") + + def run_forever(self) -> Optional[NoReturn]: + + if self.webhook_mode: + self.logger.info( + "Webhook mode: starting webhook server on " + f"{self.webhook_host}:{self.webhook_port}" + ) + self._webhook_server.start() - if not self.bot_debug_mode: - self.logger.setLevel(logging.INFO) else: - self.logger.setLevel(logging.DEBUG) + self.logger.info( + "Long polling mode: starting to poll incoming notifications" + ) + + self.api.session.headers["Connection"] = "keep-alive" + while True: + try: + response = self.api.receiving.receiveNotification() + + if not response.data: + continue + response = response.data + + self.router.route_event(response["body"]) + + self.api.receiving.deleteNotification(response["receiptId"]) + except KeyboardInterrupt: + break + except Exception as error: + if self.api_raise_errors: + raise GreenAPIBotError(error) + self.logger.error(error) + + time.sleep(5.0) + + continue + + self.api.session.headers["Connection"] = "close" + self.logger.info("Stopped receiving incoming notifications") + self.logger.info( + "Long polling mode: stopping to poll incoming notifications" + ) class GreenAPIBot(Bot): @@ -176,5 +290,5 @@ class GreenAPIBotError(Exception): "GreenAPI", "GreenAPIBot", "GreenAPIError", - "GreenAPIBotError" + "GreenAPIBotError", ] From 88300e08548542feca2a38bfd8997de72f1d95a0 Mon Sep 17 00:00:00 2001 From: karmaplush Date: Mon, 3 Jun 2024 15:28:23 +0500 Subject: [PATCH 2/6] `requirements.txt` webhook server version updated --- requirements.txt | 2 +- whatsapp_chatbot_python/bot.py | 3 ++- 2 files changed, 3 insertions(+), 2 deletions(-) diff --git a/requirements.txt b/requirements.txt index d7d0691..480f3ba 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,2 +1,2 @@ whatsapp-api-client-python==0.0.45 -whatsapp-api-webhook-server-python-v2==0.0.1 +whatsapp-api-webhook-server-python-v2==0.1.0 diff --git a/whatsapp_chatbot_python/bot.py b/whatsapp_chatbot_python/bot.py index ff43b54..520fb9c 100644 --- a/whatsapp_chatbot_python/bot.py +++ b/whatsapp_chatbot_python/bot.py @@ -89,7 +89,6 @@ def __init__( self.__init_router() if self.webhook_mode: - from whatsapp_api_webhook_server_python_v2 import GreenAPIWebhookServer self.__init_webhook_handler() self.__init_webhook_server() @@ -223,6 +222,8 @@ def webhook_handler(webhook_type: str, webhook_data: str): def __init_webhook_server(self) -> None: + from whatsapp_api_webhook_server_python_v2 import GreenAPIWebhookServer + self.logger.debug("GreenAPI webhook server initialization...") self._webhook_server = GreenAPIWebhookServer( event_handler=self._webhook_handler, From 0844df8f51a48ea52c5417aa1f62bc9081cbb608 Mon Sep 17 00:00:00 2001 From: karmaplush Date: Mon, 3 Jun 2024 15:48:10 +0500 Subject: [PATCH 3/6] README updated --- README.md | 33 +++++++++++++++++++++++++++++++++ 1 file changed, 33 insertions(+) diff --git a/README.md b/README.md index d90855c..cb978dd 100644 --- a/README.md +++ b/README.md @@ -461,6 +461,39 @@ def call_support_operator_handler(notification: Notification) -> None: bot.run_forever() ``` +### Whebhook-mode notifications receiving + +By default bot read notifications using long polling method. Receiving notifications is also possible using webhooks: + +```python +from whatsapp_chatbot_python import GreenAPIBot, Notification + +bot = GreenAPIBot( + "1101000001", + "d75b3a66374942c5b3c019c698abc2067e151558acbd412345", + # Set `webhook_mode` to True (default: False) + webhook_mode=True, + # Set your host for webhook server (default: "0.0.0.0") + webhook_host = "0.0.0.0", + # Set your port for webhook server (default: 8080) + webhook_port = 8080, + # Set your auth header value (:str) from API console + # If it is None, auth header will not affect on data receiving + webhook_auth_header = None, +) + + +@bot.router.outgoing_message() +def outgoint_message_handler(notification: Notification) -> None: + print("Outgoint message received") + +if __name__ == "__main__": + bot.run_forever() + +``` +For this mode to work correctly, you must specify the correct Webhook Url in the instance settings. Based on [whatsapp-api-webhook-server-python-v2](https://github.com/green-api/whatsapp-api-webhook-server-python-v2) (`python >= 3.8` required) + + ## Service methods documentation [Service methods documentation](https://green-api.com/en/docs/api/) From 749f8c330d797e5d48b40e73bb5065b2926decea Mon Sep 17 00:00:00 2001 From: karmaplush Date: Mon, 3 Jun 2024 16:00:16 +0500 Subject: [PATCH 4/6] README typo fix --- README.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index cb978dd..699bc09 100644 --- a/README.md +++ b/README.md @@ -461,9 +461,9 @@ def call_support_operator_handler(notification: Notification) -> None: bot.run_forever() ``` -### Whebhook-mode notifications receiving +### Webhook-mode notifications receiving -By default bot read notifications using long polling method. Receiving notifications is also possible using webhooks: +By default bot read notifications using long polling method. Receiving notifications is also possible via webhooks: ```python from whatsapp_chatbot_python import GreenAPIBot, Notification From 556b3f6c2a8c517486f332c11b22dfd5b2a67200 Mon Sep 17 00:00:00 2001 From: karmaplush Date: Mon, 3 Jun 2024 17:16:17 +0500 Subject: [PATCH 5/6] Version updated Tests updated docs/README.md updated Minor typo fixes --- docs/README.md | 33 +++++++++++++++++++++++++++++++++ setup.py | 15 ++++++++------- tests/test_manager.py | 14 +++++++------- whatsapp_chatbot_python/bot.py | 5 ++++- 4 files changed, 52 insertions(+), 15 deletions(-) diff --git a/docs/README.md b/docs/README.md index 0465471..2fae02d 100644 --- a/docs/README.md +++ b/docs/README.md @@ -459,6 +459,39 @@ def call_support_operator_handler(notification: Notification) -> None: bot.run_forever() ``` +### Получение уведомлений при помощи webhook + +По умолчанию бот читает уведомления, используя long polling метод. Получение уведомлений также возможно с помощью webhook-сервера: + +```python +from whatsapp_chatbot_python import GreenAPIBot, Notification + +bot = GreenAPIBot( + "1101000001", + "d75b3a66374942c5b3c019c698abc2067e151558acbd412345", + # Укажите значение `webhook_mode` равное True (False по-умолчанию) + webhook_mode = True, + # Укажите хост вебхук-сервера ("0.0.0.0" по-умолчанию) + webhook_host = "0.0.0.0", + # Укажите порт вебхук-сервера (8080 по-умолчанию) + webhook_port = 8080, + # При необходимости, укажите заголовок авторизации (:str), который + # установлен в консоли инстанса. Если указать None, то + # заголовок авторизации не будет проверяться вебхук-сервером + webhook_auth_header = None, +) + + +@bot.router.outgoing_message() +def outgoint_message_handler(notification: Notification) -> None: + print("Outgoint message received") + +if __name__ == "__main__": + bot.run_forever() + +``` +Для того, чтобы этот режим работал корректно, укажите корректный URL вебхук-севрера в настройках инстанса. В качестве вебхук-сервера используется [whatsapp-api-webhook-server-python-v2](https://github.com/green-api/whatsapp-api-webhook-server-python-v2) (`python >= 3.8`) + ## Документация по методам сервиса [Документация по методам сервиса](https://green-api.com/docs/api/) diff --git a/setup.py b/setup.py index 46a6eb8..0909aee 100644 --- a/setup.py +++ b/setup.py @@ -7,8 +7,7 @@ name="whatsapp-chatbot-python", version="0.9.1", description=( - "This library helps you easily create" - " a Python chatbot with WhatsApp API." + "This library helps you easily create a Python chatbot with WhatsApp API." ), long_description=long_description, long_description_content_type="text/markdown", @@ -37,12 +36,14 @@ "Topic :: Communications :: Chat", "Topic :: Software Development", "Topic :: Software Development :: Libraries", - "Topic :: Software Development :: Libraries :: Application Frameworks" + "Topic :: Software Development :: Libraries :: Application Frameworks", ], license=( - "Creative Commons Attribution-NoDerivatives 4.0 International" - " (CC BY-ND 4.0)" + "Creative Commons Attribution-NoDerivatives 4.0 International" " (CC BY-ND 4.0)" ), - install_requires=["whatsapp-api-client-python==0.0.45"], - python_requires=">=3.7" + install_requires=[ + "whatsapp-api-client-python==0.0.45", + "whatsapp-api-webhook-server-python-v2==0.1.0", + ], + python_requires=">=3.7", ) diff --git a/tests/test_manager.py b/tests/test_manager.py index efec34c..bde1714 100644 --- a/tests/test_manager.py +++ b/tests/test_manager.py @@ -8,9 +8,9 @@ "messageData": { "typeMessage": "textMessage", "textMessageData": { - "textMessage": "Hello" - } - } + "textMessage": "Hello", + }, + }, } @@ -44,12 +44,12 @@ def handler(_): self.assertEqual(len(bot.router.message.handlers), 2) - @patch("whatsapp_chatbot_python.bot.Bot._update_settings") - def create_bot(self, mock__update_settings: MagicMock) -> GreenAPIBot: - mock__update_settings.return_value = None + @patch("whatsapp_chatbot_python.bot.Bot._Bot__init_instance_settings") + def create_bot(self, mock__init_instance_settings: MagicMock) -> GreenAPIBot: + mock__init_instance_settings.return_value = None return GreenAPIBot("", "", delete_notifications_at_startup=False) -if __name__ == '__main__': +if __name__ == "__main__": unittest.main() diff --git a/whatsapp_chatbot_python/bot.py b/whatsapp_chatbot_python/bot.py index 520fb9c..2a053a4 100644 --- a/whatsapp_chatbot_python/bot.py +++ b/whatsapp_chatbot_python/bot.py @@ -47,6 +47,9 @@ def __init__( - `bot_debug_mode: bool` - (default: `False`) Debug mode (extended logging) for bot + - `settings: dict | None` - (default: `None`) + dict for updating instance settings if provided + - `delete_notifications_at_startup: bool` - (default: `True`) Remove all notifications from notification queue on bot startup. If `bot_debug_mode` is `True` - this arg will be setted as `True` when bot object init @@ -57,7 +60,7 @@ def __init__( - `webhook_host: str` - (default: `"0.0.0.0"`) Host for webhook server. - - `webhook_post: int` - (default: `8080`) Port for webhook server. + - `webhook_port: int` - (default: `8080`) Port for webhook server. - `webhook_auth_header: str | None` - (default: `None`) Check that the authorization header matches the specified value. From f9dc30f0ca29922c5793032fa6ef04c7d88d1b9e Mon Sep 17 00:00:00 2001 From: karmaplush Date: Mon, 3 Jun 2024 17:37:37 +0500 Subject: [PATCH 6/6] Python 3.7 removed as unsupported version --- .github/workflows/python-package.yml | 2 +- setup.py | 3 +-- 2 files changed, 2 insertions(+), 3 deletions(-) diff --git a/.github/workflows/python-package.yml b/.github/workflows/python-package.yml index 1ae4dbb..f92c6ea 100644 --- a/.github/workflows/python-package.yml +++ b/.github/workflows/python-package.yml @@ -14,7 +14,7 @@ jobs: runs-on: ubuntu-latest strategy: matrix: - python-version: [ "3.7", "3.8", "3.9", "3.10", "3.11", "3.12" ] + python-version: [ "3.8", "3.9", "3.10", "3.11", "3.12" ] steps: - uses: actions/checkout@v3 diff --git a/setup.py b/setup.py index 0909aee..2d92a3d 100644 --- a/setup.py +++ b/setup.py @@ -26,7 +26,6 @@ "Programming Language :: Python", "Programming Language :: Python :: 3", "Programming Language :: Python :: 3 :: Only", - "Programming Language :: Python :: 3.7", "Programming Language :: Python :: 3.8", "Programming Language :: Python :: 3.9", "Programming Language :: Python :: 3.10", @@ -45,5 +44,5 @@ "whatsapp-api-client-python==0.0.45", "whatsapp-api-webhook-server-python-v2==0.1.0", ], - python_requires=">=3.7", + python_requires=">=3.8", )