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/README.md b/README.md index d90855c..699bc09 100644 --- a/README.md +++ b/README.md @@ -461,6 +461,39 @@ def call_support_operator_handler(notification: Notification) -> None: bot.run_forever() ``` +### Webhook-mode notifications receiving + +By default bot read notifications using long polling method. Receiving notifications is also possible via 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/) 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/requirements.txt b/requirements.txt index 9613970..480f3ba 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1 +1,2 @@ whatsapp-api-client-python==0.0.45 +whatsapp-api-webhook-server-python-v2==0.1.0 diff --git a/setup.py b/setup.py index 46a6eb8..2d92a3d 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", @@ -27,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", @@ -37,12 +35,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.8", ) 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 bed9724..2a053a4 100644 --- a/whatsapp_chatbot_python/bot.py +++ b/whatsapp_chatbot_python/bot.py @@ -8,159 +8,277 @@ 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, ): + """ + Init args: + + - `id_instance: str` - (required) Instance ID + + - `api_token_instance: str` - (required) Api Token + + - `debug_mode: bool` - (default: `False`) Debug mode (extended logging) + for API wrapper + + - `raise_errors: bool` - (default: `False`) Raise errors when it handled + (for long polling mode only), otherwise - skip it + + - `host: str | None` - (default: `None`) API host url + ("https://api.green-api.com" if `None` provided) + + - `media: str | None` - (default: `None`) API host url + ("https://media.green-api.com" if `None` provided) + + - `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 + + - `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. + + - `webhook_host: str` - (default: `"0.0.0.0"`) Host 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. + Will be ignored if set to `None` + + """ + self.id_instance = id_instance self.api_token_instance = api_token_instance - self.debug_mode = debug_mode - self.raise_errors = raise_errors + 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: + + self.__init_webhook_handler() + self.__init_webhook_server() + + self.logger.info("Bot initialization success") + + def __init_logger(self) -> None: + + logger = logging.getLogger("whatsapp-chatbot-python") + if self.bot_debug_mode: + logger.setLevel(logging.DEBUG) + else: + logger.setLevel(logging.INFO) + + 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") + + def __init_api_wrapper(self) -> None: + + self.logger.debug("GreenAPI wrapper initialization...") 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" + 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", ) - self.bot_debug_mode = bot_debug_mode + self.logger.debug("GreenAPI wrapper OK") - self.logger = logging.getLogger("whatsapp-chatbot-python") - self.__prepare_logger() + def __init_instance_settings(self) -> None: - if not settings: - self._update_settings() - else: - self.logger.log(logging.DEBUG, "Updating instance settings.") + if self.instance_settings: + self.logger.debug("Updating instance settings") + self.api.account.setSettings(self.instance_settings) - self.api.account.setSettings(settings) + 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})" + ) - if bot_debug_mode: - if not delete_notifications_at_startup: - delete_notifications_at_startup = True + 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.logger.log( - logging.DEBUG, "Enabled delete_notifications_at_startup." + self.api.account.setSettings( + { + "incomingWebhook": "yes", + "outgoingMessageWebhook": "yes", + "outgoingAPIMessageWebhook": "yes", + } ) - if delete_notifications_at_startup: - self._delete_notifications_at_startup() + self.logger.debug("Instance settings OK") - self.router = Router(self.api, self.logger) + def __delete_notifications_at_startup(self) -> None: - def run_forever(self) -> Optional[NoReturn]: - self.api.session.headers["Connection"] = "keep-alive" + if self.bot_debug_mode: + self.delete_notifications_at_startup = True + self.logger.debug("Enabled delete_notifications_at_startup") - self.logger.log( - logging.INFO, "Started receiving incoming notifications." - ) + if self.delete_notifications_at_startup: + + self.api.session.headers["Connection"] = "keep-alive" + self.logger.debug("Started deleting old incoming notifications") - while True: - try: + while True: response = self.api.receiving.receiveNotification() if not response.data: - continue - response = response.data - - self.router.route_event(response["body"]) + break - 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) + self.api.receiving.deleteNotification(response.data["receiptId"]) - time.sleep(5.0) + self.api.session.headers["Connection"] = "close" + self.logger.debug("Stopped deleting old incoming notifications") + self.logger.debug("Old notifications was deleted successfull") - continue + else: + self.logger.debug("Deleting notifications at startup is disbaled, skip") - self.api.session.headers["Connection"] = "close" + def __init_router(self) -> None: - self.logger.log( - logging.INFO, "Stopped receiving incoming notifications." - ) + self.logger.debug("Router initialization...") + self.router = Router(self.api, self.logger) + self.logger.debug("Router OK") - def _update_settings(self) -> Optional[NoReturn]: - self.logger.log(logging.DEBUG, "Checking current instance settings.") + def __init_webhook_handler(self) -> None: - settings = self.api.account.getSettings() + self.logger.debug("Webhook handler initialization...") - response = settings.data + def webhook_handler(webhook_type: str, webhook_data: str): + self.router.route_event(webhook_data) - 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, ( - "All message notifications are disabled. " - "Enabling incoming and outgoing notifications. " - "Settings will be applied within 5 minutes." - ) - ) + self._webhook_handler = webhook_handler + self.logger.debug("Webhook handler OK") - self.api.account.setSettings({ - "incomingWebhook": "yes", - "outgoingMessageWebhook": "yes", - "outgoingAPIMessageWebhook": "yes" - }) + def __init_webhook_server(self) -> None: - def _delete_notifications_at_startup(self) -> Optional[NoReturn]: - self.api.session.headers["Connection"] = "keep-alive" + from whatsapp_api_webhook_server_python_v2 import GreenAPIWebhookServer - self.logger.log( - logging.DEBUG, "Started deleting old incoming notifications." + 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]: - while True: - response = self.api.receiving.receiveNotification() + 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 response.data: - break + else: + self.logger.info( + "Long polling mode: starting to poll incoming notifications" + ) - self.api.receiving.deleteNotification(response.data["receiptId"]) + self.api.session.headers["Connection"] = "keep-alive" + while True: + try: + response = self.api.receiving.receiveNotification() - self.api.session.headers["Connection"] = "close" + if not response.data: + continue + response = response.data - self.logger.log( - logging.DEBUG, "Stopped deleting old incoming notifications." - ) + self.router.route_event(response["body"]) - self.logger.log(logging.INFO, "Deleted old incoming notifications.") + 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) - 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" - )) + time.sleep(5.0) - self.logger.addHandler(handler) + continue - if not self.bot_debug_mode: - self.logger.setLevel(logging.INFO) - else: - self.logger.setLevel(logging.DEBUG) + 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 +294,5 @@ class GreenAPIBotError(Exception): "GreenAPI", "GreenAPIBot", "GreenAPIError", - "GreenAPIBotError" + "GreenAPIBotError", ]