diff --git a/README.md b/README.md index 0148834..e662715 100644 --- a/README.md +++ b/README.md @@ -32,7 +32,7 @@ Various components used when sending messages in Facebook Messenger are wrapped **NOTE :** Please be aware that while this package includes commonly used features of the Messenger Platform, not all features have been implemented. If you would like to contribute and add a feature to this package, you are welcome to submit a pull request. I will review it promptly. ## Prerequisite -- **Python 3.7+** installed +- **Python 3.9+** installed - You'll need to setup a [Facebook App](https://developers.facebook.com/apps/), Facebook Page, get the Page Access Token and link the App to the Page. ## How to install ### From GitHub diff --git a/messenger_api_python.egg-info/PKG-INFO b/messenger_api_python.egg-info/PKG-INFO new file mode 100644 index 0000000..4925b0f --- /dev/null +++ b/messenger_api_python.egg-info/PKG-INFO @@ -0,0 +1,131 @@ +Metadata-Version: 2.4 +Name: messenger-api-python +Version: 2026.7.22 +Summary: Python wrapper to the various APIs in Facebook Messenger Platform +Home-page: https://github.com/krishna2206/messenger-api-python +Author: krishna2206 +Author-email: fitiavana.krishna@gmail.com +License: MIT License +Classifier: Programming Language :: Python :: 3 +Classifier: Programming Language :: Python :: 3.9 +Classifier: Programming Language :: Python :: 3.10 +Classifier: Programming Language :: Python :: 3.11 +Classifier: Programming Language :: Python :: 3.12 +Classifier: License :: OSI Approved :: MIT License +Classifier: Operating System :: OS Independent +Requires-Python: >=3.9 +Description-Content-Type: text/markdown +License-File: LICENSE +Requires-Dist: python-magic<1,>=0.4.27 +Requires-Dist: requests<3,>=2.31.0 +Requires-Dist: requests-toolbelt<2,>=1.0.0 +Dynamic: license-file + +# messenger-api-python +[![PyPI](https://img.shields.io/pypi/v/messenger-api-python.svg?maxAge=2592000)](https://pypi.python.org/pypi/messenger-api-python) + +Python Wrapper to various APIs from [Facebook Messenger Platform](https://developers.facebook.com/docs/messenger-platform). + + +## Features + +### Send API (v19.0) + - Send text messages + - Send attachments from a remote file (image, audio, video, file) + - Send attachments from a local file (image, audio, video, file) + - Send templates (generic messages) + - Send quick replies + - Send buttons +### Profile API (v19.0) +- Set welcome screen +- Set persistent menu +### Attachment Upload API (v19.0) +- Upload attachments from a remote file (image, audio, video, file) +- Upload attachments from a local file (image, audio video, file) +### Reusable components +Various components used when sending messages in Facebook Messenger are wrapped into Python objects to make them reusable and easy to use. +- **Elements:** used to contains various Element objects +- **Element:** a card-like component that holds various other components +- **Buttons:** used to contains various Button objects +- **Button:** button used in various other components, can also be used alone +- **QuickReplies:** used to contains various QuickReply objects +- **QuickReply:** used when sending messages accompanied with quick replies +- **PersistentMenu:** used when setting up persistent menu + +**NOTE :** Please be aware that while this package includes commonly used features of the Messenger Platform, not all features have been implemented. If you would like to contribute and add a feature to this package, you are welcome to submit a pull request. I will review it promptly. + +## Prerequisite +- **Python 3.9+** installed +- You'll need to setup a [Facebook App](https://developers.facebook.com/apps/), Facebook Page, get the Page Access Token and link the App to the Page. +## How to install +### From GitHub +```bash +pip install git+https://github.com/krishna2206/messenger-api-python.git#egg=messenger-api-python +``` +### From Pypi +Package from Pypi.org may not be the latest one, if you want the latest version of this package, install it from the GitHub repository (see above) +```bash +pip install messenger-api-python +``` +## Usage +### Send API +```python +from messengerapi import SendApi +send_api = SendApi() +send_api.send_text_message(, ) +``` +**Note**: From Facebook regarding User IDs + +> These ids are page-scoped. These ids differ from those returned from Facebook Login apps which are app-scoped. You must use ids retrieved from a Messenger integration for this page in order to function properly. + +> The Facebook Graph API allows messages to be sent only to User IDs that have recently interacted with the Facebook Page. Attempting to send messages to User IDs that fall outside of this interaction window will result in a failed delivery. (More to read on : https://developers.facebook.com/docs/messenger-platform/overview) + +##### Sending a generic template message: + +> [Generic Template Messages](https://developers.facebook.com/docs/messenger-platform/implementation#receive_message) allows you to add cool elements like images, text all in a single bubble. +```python +from messengerapi import SendApi +from messengerapi.components import Elements, Element, Buttons, Button, POSTBACK + +send_api = SendApi() + +elements = Elements() +buttons = Buttons() + +button = Button(button_type=POSTBACK, title="My button") +buttons.add_button(button.get_content()) +element = Element(title="My element", subtitle="The element's subtitle, image_url=, buttons=buttons) +elements.add_element(element.get_content()) + +send_api.send_generic_message(elements.get_content() , recipient_id , image_aspect_ratio="horizontal") +``` +##### Sending remote (from URL) image/audio/video/file: +```python +from messengerapi import SendApi + +send_api = SendApi(, ) + +# To send an image +send_api.send_image_attachment( , ) +# To send an audio +send_api.send_audio_attachment( , ) +# To send a video +send_api.send_video_attachment( , ) +# To send a file +send_api.send_file_attachment( , ) +``` +##### Sending local image/audio/video/file: +```python +from messengerapi import SendApi + +send_api = SendApi(, ) + +# To send an image +send_api.send_local_image( , ) +# To send an audio +send_api.send_local_audio( , ) +# To send a video +send_api.send_local_video( , ) +# To send a file +send_api.send_local_file( , ) +``` diff --git a/messenger_api_python.egg-info/SOURCES.txt b/messenger_api_python.egg-info/SOURCES.txt new file mode 100644 index 0000000..d71f206 --- /dev/null +++ b/messenger_api_python.egg-info/SOURCES.txt @@ -0,0 +1,16 @@ +LICENSE +README.md +pyproject.toml +setup.cfg +messenger_api_python.egg-info/PKG-INFO +messenger_api_python.egg-info/SOURCES.txt +messenger_api_python.egg-info/dependency_links.txt +messenger_api_python.egg-info/requires.txt +messenger_api_python.egg-info/top_level.txt +messengerapi/__init__.py +messengerapi/_base_api.py +messengerapi/attachment_upload_api.py +messengerapi/components.py +messengerapi/constants.py +messengerapi/messenger_profile_api.py +messengerapi/send_api.py \ No newline at end of file diff --git a/messenger_api_python.egg-info/dependency_links.txt b/messenger_api_python.egg-info/dependency_links.txt new file mode 100644 index 0000000..8b13789 --- /dev/null +++ b/messenger_api_python.egg-info/dependency_links.txt @@ -0,0 +1 @@ + diff --git a/messenger_api_python.egg-info/requires.txt b/messenger_api_python.egg-info/requires.txt new file mode 100644 index 0000000..a2daf48 --- /dev/null +++ b/messenger_api_python.egg-info/requires.txt @@ -0,0 +1,3 @@ +python-magic<1,>=0.4.27 +requests<3,>=2.31.0 +requests-toolbelt<2,>=1.0.0 diff --git a/messenger_api_python.egg-info/top_level.txt b/messenger_api_python.egg-info/top_level.txt new file mode 100644 index 0000000..aa7cfcf --- /dev/null +++ b/messenger_api_python.egg-info/top_level.txt @@ -0,0 +1 @@ +messengerapi diff --git a/messengerapi/_base_api.py b/messengerapi/_base_api.py new file mode 100644 index 0000000..da8ee54 --- /dev/null +++ b/messengerapi/_base_api.py @@ -0,0 +1,49 @@ +"""Shared HTTP client behavior for Messenger API wrappers.""" + +from __future__ import annotations + +from typing import Any, Mapping + +import requests + + +class BaseApiClient: + """Base class with shared request behavior.""" + + def __init__( + self, + page_access_token: str, + *, + timeout: float = 30.0, + session: requests.Session | None = None, + ) -> None: + if not isinstance(page_access_token, str) or not page_access_token.strip(): + raise ValueError("page_access_token must be a non-empty string") + if timeout <= 0: + raise ValueError("timeout must be greater than 0") + + self._page_access_token = page_access_token + self._timeout = timeout + self._session = session or requests.Session() + + def get_access_token(self) -> str: + return self._page_access_token + + def _post_json(self, url: str, body: Mapping[str, Any]) -> dict[str, Any]: + response = self._session.post( + url, + params={"access_token": self.get_access_token()}, + json=body, + timeout=self._timeout, + ) + return response.json() + + def _post_multipart(self, url: str, data: Any, content_type: str) -> dict[str, Any]: + response = self._session.post( + url, + params={"access_token": self.get_access_token()}, + data=data, + headers={"content-type": content_type}, + timeout=self._timeout, + ) + return response.json() diff --git a/messengerapi/attachment_upload_api.py b/messengerapi/attachment_upload_api.py index d511fab..84bf7df 100644 --- a/messengerapi/attachment_upload_api.py +++ b/messengerapi/attachment_upload_api.py @@ -4,23 +4,25 @@ import json import magic -import requests from requests_toolbelt import MultipartEncoder +from ._base_api import BaseApiClient from .constants import API_VERSION -class AttachmentUploadApi: - def __init__(self, page_access_token: str, page_id: str) -> None: +class AttachmentUploadApi(BaseApiClient): + def __init__(self, page_access_token: str, page_id: str, *, timeout: float = 30.0) -> None: + super().__init__(page_access_token, timeout=timeout) + if not isinstance(page_id, str) or not page_id.strip(): + raise ValueError("page_id must be a non-empty string") self.__graph_version = API_VERSION self.__api_url = f"https://graph.facebook.com/v{self.__graph_version}/{page_id}/message_attachments" - self.__page_access_token = page_access_token def get_api_url(self): return self.__api_url def get_access_token(self): - return self.__page_access_token + return super().get_access_token() def get_graph_version(self): return self.__graph_version @@ -63,32 +65,29 @@ def __upload_local_attachment(self, asset_type: str, file_location: str): else: mimetype = magic.Magic(mime=True).from_file(file_location) - print(f"File MIMETYPE : {mimetype}") - - request_body = MultipartEncoder( - fields={ - "message": json.dumps({ - "attachment": { - "type": asset_type, - "payload": { - "is_reusable": "true" + with open(file_location, "rb") as file_data: + request_body = MultipartEncoder( + fields={ + "message": json.dumps({ + "attachment": { + "type": asset_type, + "payload": { + "is_reusable": "true" + } } - } - }), - "filedata": ( - os.path.basename(file_location), - open(file_location, "rb"), - mimetype - ) - } - ) - headers = {"content-type": request_body.content_type} - - return requests.post( - self.get_api_url(), - params={"access_token": self.get_access_token()}, - data=request_body, - headers=headers).json() + }), + "filedata": ( + os.path.basename(file_location), + file_data, + mimetype + ) + } + ) + return self._post_multipart( + self.get_api_url(), + request_body, + request_body.content_type, + ) def __upload_remote_attachement(self, asset_type: str, file_url: str): request_body = { @@ -103,7 +102,5 @@ def __upload_remote_attachement(self, asset_type: str, file_url: str): } } - return requests.post( - self.get_api_url(), - params={"access_token": self.get_access_token()}, - json=request_body).json()["attachment_id"] + response = self._post_json(self.get_api_url(), request_body) + return response["attachment_id"] diff --git a/messengerapi/messenger_profile_api.py b/messengerapi/messenger_profile_api.py index 23ac0ef..5e263f7 100644 --- a/messengerapi/messenger_profile_api.py +++ b/messengerapi/messenger_profile_api.py @@ -1,15 +1,14 @@ """Wrapper for the Profile API""" -import requests - +from ._base_api import BaseApiClient from .constants import API_VERSION -class ProfileApi: - def __init__(self, page_access_token: str): +class ProfileApi(BaseApiClient): + def __init__(self, page_access_token: str, *, timeout: float = 30.0): + super().__init__(page_access_token, timeout=timeout) self.__graph_version = API_VERSION self.__api_url = f"https://graph.facebook.com/v{self.__graph_version}/me" - self.__page_access_token = page_access_token self.__global_level_endpoint = "/messenger_profile" self.__user_level_endpoint = "/custom_user_settings" @@ -17,7 +16,7 @@ def get_api_url(self): return self.__api_url def get_access_token(self): - return self.__page_access_token + return super().get_access_token() def get_graph_version(self): return self.__graph_version @@ -37,9 +36,10 @@ def set_welcome_screen(self, get_started_button_payload: str, greetings: list = [{"locale": "default", "text": "Welcome , {{user_full_name}} !"}] if greetings is None else greetings) - assert isinstance(greetings, list) and isinstance( - greetings[0], dict), "param greetings must be a list of dicts" - assert greetings[0]["locale"] == "default", "first element of param greetings must be the default locale used" + if not isinstance(greetings, list) or not greetings or not isinstance(greetings[0], dict): + raise TypeError("greetings must be a non-empty list of dictionaries") + if greetings[0].get("locale") != "default": + raise ValueError("the first greetings item must use locale='default'") request_body = { "get_started": @@ -49,10 +49,10 @@ def set_welcome_screen(self, get_started_button_payload: str, greetings: list = "greeting": greetings } - return requests.post( + return self._post_json( self.get_api_url() + self.__global_level_endpoint, - params={"access_token": self.get_access_token()}, - json=request_body).json() + request_body, + ) def set_user_persistent_menu(self, user_id: str, persistent_menu: list): """Set the persistent menu for any user of the page. @@ -62,13 +62,13 @@ def set_user_persistent_menu(self, user_id: str, persistent_menu: list): persistent_menu (PersistentMenu object) : The content of the PersistentMenu object , obtained via the PersistentMenu().get_content() method. """ - return requests.post( + return self._post_json( self.get_api_url() + self.__user_level_endpoint, - params={"access_token": self.get_access_token()}, - json={ + { "psid": user_id, "persistent_menu": persistent_menu - }).json() + }, + ) def set_persistent_menu(self, persistent_menu: list): """Set the persistent menu for the page. @@ -77,7 +77,7 @@ def set_persistent_menu(self, persistent_menu: list): persistent_menu (PersistentMenu object) : The content of the PersistentMenu object , obtained via the PersistentMenu().get_content() method. """ - return requests.post( + return self._post_json( self.get_api_url() + self.__global_level_endpoint, - params={"access_token": self.get_access_token()}, - json={"persistent_menu": persistent_menu}).json() + {"persistent_menu": persistent_menu}, + ) diff --git a/messengerapi/send_api.py b/messengerapi/send_api.py index a78c639..786ff32 100644 --- a/messengerapi/send_api.py +++ b/messengerapi/send_api.py @@ -2,24 +2,36 @@ import os import json -import urllib +from urllib.parse import urlencode +from typing import Optional import magic -import requests from requests_toolbelt import MultipartEncoder +from ._base_api import BaseApiClient from .constants import API_VERSION, MessagingType, NotificationType -class SendApi: - def __init__(self, page_access_token: str, page_id: str = None): +def _validate_non_empty_string(value: str, field_name: str) -> None: + if not isinstance(value, str) or not value.strip(): + raise ValueError(f"{field_name} must be a non-empty string") + + +class SendApi(BaseApiClient): + def __init__( + self, + page_access_token: str, + page_id: Optional[str] = None, + *, + timeout: float = 30.0, + ) -> None: + super().__init__(page_access_token, timeout=timeout) self.__graph_version = API_VERSION self.__def_api_url = f"https://graph.facebook.com/v{self.__graph_version}/me" self.__alt_api_url = ( None if page_id is None else f"https://graph.facebook.com/v{self.__graph_version}/{page_id}") self.__page_id = None if page_id is None else page_id self.__default_endpoint = "/messages" - self.__page_access_token = page_access_token def get_def_api_url(self): return self.__def_api_url @@ -34,7 +46,7 @@ def get_def_endpoint(self): return self.__default_endpoint def get_access_token(self): - return self.__page_access_token + return super().get_access_token() def get_graph_version(self): return self.__graph_version @@ -52,8 +64,12 @@ def send_text_message(self, message: str, recipient_id: str, Returns: dict: The response body from Facebook's API's server. """ - assert messaging_type in ("RESPONSE", "UPDATE", "MESSAGE_TAG"), \ - "value of param messagin_type must be \"RESPONSE\",\"UPDATE\" or \"MESSAGE_TAG\"" + _validate_non_empty_string(message, "message") + _validate_non_empty_string(recipient_id, "recipient_id") + if messaging_type not in ("RESPONSE", "UPDATE", "MESSAGE_TAG"): + raise ValueError( + "messaging_type must be one of RESPONSE, UPDATE, or MESSAGE_TAG" + ) request_body = { "messaging_type": messaging_type, @@ -66,16 +82,20 @@ def send_text_message(self, message: str, recipient_id: str, } } if messaging_type == MessagingType.MESSAGE_TAG: - assert kwargs.get("tag") in ( - "ACCOUNT_UPDATE", "CONFIRMED_EVENT_UPDATE", - "CUSTOMER_FEEDBACK", "HUMAN_AGENT", "POST_PURCHASE_UPDATE"), \ - "value of param messagin_type must be \"ACCOUNT_UPDATE\",\"CONFIRMED_EVENT_UPDATE\",\"CUSTOMER_FEEDBACK\",\"HUMAN_AGENT\" or \"POST_PURCHASE_UPDATE\"" + if kwargs.get("tag") not in ( + "ACCOUNT_UPDATE", "CONFIRMED_EVENT_UPDATE", + "CUSTOMER_FEEDBACK", "HUMAN_AGENT", "POST_PURCHASE_UPDATE", + ): + raise ValueError( + "tag must be one of ACCOUNT_UPDATE, CONFIRMED_EVENT_UPDATE, " + "CUSTOMER_FEEDBACK, HUMAN_AGENT, or POST_PURCHASE_UPDATE" + ) request_body["tag"] = kwargs.get("tag") - return requests.post( + return self._post_json( self.get_def_api_url() + self.get_def_endpoint(), - params={"access_token": self.get_access_token()}, - json=request_body).json() + request_body, + ) """ Send an attachment from an URL of a file @@ -130,17 +150,17 @@ def send_generic_message(self, elements: str, recipient_id: str, } if quick_replies is not None: - assert isinstance( - quick_replies, list), f"type of param quick_replies must be a list , not {type(quick_replies)}" - assert len( - quick_replies) > 0, "param quick_replies must be non empty" + if not isinstance(quick_replies, list): + raise TypeError("quick_replies must be a list") + if len(quick_replies) == 0: + raise ValueError("quick_replies must be non-empty") request_body["message"]["quick_replies"] = quick_replies - return requests.post( + return self._post_json( self.get_def_api_url() + self.get_def_endpoint(), - params={"access_token": self.get_access_token()}, - json=request_body).json() + request_body, + ) def mark_seen_message(self, recipient_id: str): """Mark 'seen' the message""" @@ -179,10 +199,10 @@ def send_quick_replies(self, message: str, quick_replies: str, } } - return requests.post( + return self._post_json( self.get_def_api_url() + self.get_def_endpoint(), - params={"access_token": self.get_access_token()}, - json=request_body).json() + request_body, + ) """ Send an attachment from a local file @@ -285,7 +305,8 @@ def send_buttons(self, text: str, buttons: list, recipient_id: str): json=request_body).json() def __send_sender_actions(self, sender_action: str, recipient_id: str): - assert self.get_alt_api_url() is not None, "The page id is not defined for this instance." + if self.get_alt_api_url() is None: + raise ValueError("The page id is not defined for this instance.") request_body = { "recipient": { @@ -294,10 +315,10 @@ def __send_sender_actions(self, sender_action: str, recipient_id: str): "sender_action": sender_action } - return requests.post( + return self._post_json( self.get_alt_api_url() + self.get_def_endpoint(), - params={"access_token": self.get_access_token()}, - json=request_body).json() + request_body, + ) def __send_saved_attachment(self, attachment_id: str, attachment_type: str, recipient_id: str): request_body = { @@ -314,10 +335,10 @@ def __send_saved_attachment(self, attachment_id: str, attachment_type: str, reci } } - return requests.post( + return self._post_json( self.get_def_api_url() + self.get_def_endpoint(), - params={"access_token": self.get_access_token()}, - json=request_body).json() + request_body, + ) def __send_local_attachment(self, asset_type: str, file_location: str, recipient_id: str, is_reusable: str = "true", mimetype: str = None @@ -333,38 +354,34 @@ def __send_local_attachment(self, asset_type: str, file_location: str, else: mimetype = mimetype - print(f"File MIMETYPE : {mimetype}") - - multipart_data = MultipartEncoder( - fields={ - "recipient": json.dumps({"id": recipient_id}), - "message": json.dumps( - { - "attachment": { - "type": asset_type, - "payload": { - "is_reusable": is_reusable + with open(file_location, "rb") as file_data: + multipart_data = MultipartEncoder( + fields={ + "recipient": json.dumps({"id": recipient_id}), + "message": json.dumps( + { + "attachment": { + "type": asset_type, + "payload": { + "is_reusable": is_reusable + } } } - } - ), - "filedata": ( - os.path.basename(file_location), - open(file_location, "rb"), - mimetype + ), + "filedata": ( + os.path.basename(file_location), + file_data, + mimetype + ) + } + ) - ) - } - ) - headers = {"content-type": multipart_data.content_type} - - return requests.post( - f"{self.get_def_api_url()}{self.get_def_endpoint()}" - if self.get_alt_api_url() is None - else f"{self.get_alt_api_url()}{self.get_def_endpoint()}", - params={"access_token": self.get_access_token()}, - data=multipart_data, - headers=headers).json() + api_url = ( + f"{self.get_def_api_url()}{self.get_def_endpoint()}" + if self.get_alt_api_url() is None + else f"{self.get_alt_api_url()}{self.get_def_endpoint()}" + ) + return self._post_multipart(api_url, multipart_data, multipart_data.content_type) def __send_attachment_message(self, attachment_type: str, attachment_url: str, recipient_id: str, is_reusable: str = "false" @@ -384,20 +401,21 @@ def __send_attachment_message(self, attachment_type: str, attachment_url: str, } } - return requests.post( + return self._post_json( self.get_def_api_url() + self.get_def_endpoint(), - params={"access_token": self.get_access_token()}, - json=request_body).json() + request_body, + ) def send_batch_image_attachments(self, image_urls: list, recipient_id: str): - assert self.get_page_id() is not None, "The page id is not defined for this instance." + if self.get_page_id() is None: + raise ValueError("The page id is not defined for this instance.") batch_request_body = [] for image_url in image_urls: request_body = { "method": "POST", "relative_url": f"{self.get_page_id()}" + self.get_def_endpoint(), - "body": urllib.parse.urlencode({ + "body": urlencode({ "recipient": {"id": recipient_id}, "message": { "attachment": { @@ -415,7 +433,7 @@ def send_batch_image_attachments(self, image_urls: list, recipient_id: str): "batch": batch_request_body } - return requests.post( + return self._post_json( f"https://graph.facebook.com/{self.get_graph_version()}", - params={"access_token": self.get_access_token()}, - json=request_body).json() + request_body, + ) diff --git a/pyproject.toml b/pyproject.toml index 90a69b6..65a4f76 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,3 +1,3 @@ [build-system] -requires = ["setuptools", "wheel"] +requires = ["setuptools>=68", "wheel>=0.41"] build-backend = "setuptools.build_meta" diff --git a/setup.cfg b/setup.cfg index 4a99e0a..7f6f3dc 100644 --- a/setup.cfg +++ b/setup.cfg @@ -1,6 +1,6 @@ [metadata] name = messenger-api-python -version = 2024.03.17 +version = 2026.07.22 author = krishna2206 author_email = fitiavana.krishna@gmail.com description = Python wrapper to the various APIs in Facebook Messenger Platform @@ -9,14 +9,18 @@ long_description_content_type = text/markdown url = https://github.com/krishna2206/messenger-api-python license = MIT License classifiers = - Programming Language :: Python :: 3.7 + Programming Language :: Python :: 3 + Programming Language :: Python :: 3.9 + Programming Language :: Python :: 3.10 + Programming Language :: Python :: 3.11 + Programming Language :: Python :: 3.12 License :: OSI Approved :: MIT License Operating System :: OS Independent [options] packages = find: -python_requires = >= 3.7 +python_requires = >= 3.9 install_requires = - python_magic - requests - requests_toolbelt \ No newline at end of file + python-magic>=0.4.27,<1 + requests>=2.31.0,<3 + requests-toolbelt>=1.0.0,<2 \ No newline at end of file