From 2476686c330a5dbfe18fe29b2486878e641939ed Mon Sep 17 00:00:00 2001 From: Simone Robutti Date: Sat, 20 Nov 2021 15:40:10 +0100 Subject: [PATCH] added facebook publisher (#99) * added facebook publisher * mobilizon-reshare.git: [propagated-inputs]: Add python-sdk-facebook. This package definition has been generated with `guix import pypi -r facebook-sdk`. * mobilizon-reshare.git: [propagated-inputs]: Use python-facebook-sdk.git. Co-authored-by: Giacomo Leidi --- docker/mobilizon-reshare.scm | 44 ++++++++++ mobilizon_reshare/.secrets.toml | 11 ++- mobilizon_reshare/config/notifiers.py | 4 + mobilizon_reshare/config/publishers.py | 9 +- mobilizon_reshare/publishers/abstract.py | 10 +-- mobilizon_reshare/publishers/coordinator.py | 2 +- .../publishers/platforms/facebook.py | 82 +++++++++++++++++++ .../publishers/platforms/mastodon.py | 3 +- .../publishers/platforms/platform_mapping.py | 8 ++ .../publishers/platforms/telegram.py | 3 +- .../publishers/platforms/twitter.py | 4 +- .../publishers/platforms/zulip.py | 7 +- .../publishers/templates/facebook.tmpl.j2 | 9 ++ .../templates/facebook_recap.tmpl.j2 | 9 ++ .../templates/facebook_recap_header.tmpl.j2 | 1 + poetry.lock | 21 ++++- pyproject.toml | 1 + tests/conftest.py | 4 +- tests/publishers/conftest.py | 5 +- 19 files changed, 217 insertions(+), 20 deletions(-) create mode 100644 mobilizon_reshare/publishers/platforms/facebook.py create mode 100644 mobilizon_reshare/publishers/templates/facebook.tmpl.j2 create mode 100644 mobilizon_reshare/publishers/templates/facebook_recap.tmpl.j2 create mode 100644 mobilizon_reshare/publishers/templates/facebook_recap_header.tmpl.j2 diff --git a/docker/mobilizon-reshare.scm b/docker/mobilizon-reshare.scm index b34590e..07f19be 100644 --- a/docker/mobilizon-reshare.scm +++ b/docker/mobilizon-reshare.scm @@ -335,6 +335,49 @@ development, testing, production]}; (description "Twitter library for Python") (license license:expat))) +(define-public python-facebook-sdk + (package + (name "python-facebook-sdk") + (version "3.1.0") + (source + (origin + (method url-fetch) + (uri (pypi-uri "facebook-sdk" version)) + (sha256 + (base32 "138grz0n6plzdqgi4h6hhszf58bsvx9v76cwj51g1nd3kvkd5g6a")))) + (build-system python-build-system) + (propagated-inputs `(("python-requests" ,python-requests))) + (home-page "https://facebook-sdk.readthedocs.io") + (synopsis + "Facebook Graph API client in Python") + (description + "This client library is designed to support the Facebook Graph API and +the official Facebook JavaScript SDK, which is the canonical way to implement +Facebook authentication.") + (license license:asl2.0))) + +(define-public python-facebook-sdk.git + (let ((version (package-version python-facebook-sdk)) + (revision "0") + (commit "3fa89fec6a20dd070ccf57968c6f89256f237f54")) + (package (inherit python-facebook-sdk) + (name "python-facebook-sdk.git") + (version (git-version version revision commit)) + (source + (origin + (method git-fetch) + (uri + (git-reference + (url "https://github.com/mobolic/facebook-sdk") + (commit commit))) + (file-name (git-file-name name version)) + (sha256 + (base32 + "0vayxkg6p8wdj63qvzr24dj3q7rkyhr925b31z2qv2mnbas01dmg")))) + (arguments + ;; Tests depend on network access. + `(#:tests? #false))))) + (define-public mobilizon-reshare.git (let ((source-version (with-input-from-file (string-append %source-dir @@ -389,6 +432,7 @@ development, testing, production]}; ("python-beautifulsoup4" ,python-beautifulsoup4) ("python-click" ,python-click) ("python-dynaconf" ,python-dynaconf) + ("python-facebook-sdk" ,python-facebook-sdk.git) ("python-jinja2" ,python-jinja2) ("python-markdownify" ,python-markdownify) ("python-requests" ,python-requests) diff --git a/mobilizon_reshare/.secrets.toml b/mobilizon_reshare/.secrets.toml index 979ff57..9e86094 100644 --- a/mobilizon_reshare/.secrets.toml +++ b/mobilizon_reshare/.secrets.toml @@ -3,8 +3,6 @@ active=true chat_id="xxx" token="xxx" username="xxx" -[default.publisher.facebook] -active=false [default.publisher.zulip] active=true instance="xxx" @@ -25,6 +23,11 @@ token="xxx" name="xxx" toot_length=500 +[default.publisher.facebook] + +active=true +page_access_token="xxx" + [default.notifier.telegram] active=true chat_id="xxx" @@ -45,3 +48,7 @@ access_token="xxx" access_secret="xxx" [default.notifier.mastodon] active=false + +[default.notifier.facebook] +active=false +page_access_token="xxx" \ No newline at end of file diff --git a/mobilizon_reshare/config/notifiers.py b/mobilizon_reshare/config/notifiers.py index f44b7f9..4322e5d 100644 --- a/mobilizon_reshare/config/notifiers.py +++ b/mobilizon_reshare/config/notifiers.py @@ -26,7 +26,11 @@ twitter_validators = [ Validator("publisher.twitter.access_secret", must_exist=True), ] +facebook_validators = [ + Validator("publisher.facebook.page_access_token", must_exist=True), +] notifier_name_to_validators = { + "facebook": facebook_validators, "telegram": telegram_validators, "twitter": twitter_validators, "mastodon": mastodon_validators, diff --git a/mobilizon_reshare/config/publishers.py b/mobilizon_reshare/config/publishers.py index 3659109..edf3366 100644 --- a/mobilizon_reshare/config/publishers.py +++ b/mobilizon_reshare/config/publishers.py @@ -45,7 +45,14 @@ twitter_validators = [ Validator("publisher.twitter.access_token", must_exist=True), Validator("publisher.twitter.access_secret", must_exist=True), ] -facebook_validators = [] +facebook_validators = [ + Validator("publisher.facebook.msg_template_path", must_exist=True, default=None), + Validator("publisher.facebook.recap_template_path", must_exist=True, default=None), + Validator( + "publisher.facebook.recap_header_template_path", must_exist=True, default=None + ), + Validator("publisher.facebook.page_access_token", must_exist=True), +] publisher_name_to_validators = { "telegram": telegram_validators, diff --git a/mobilizon_reshare/publishers/abstract.py b/mobilizon_reshare/publishers/abstract.py index 0e03c39..31e6482 100644 --- a/mobilizon_reshare/publishers/abstract.py +++ b/mobilizon_reshare/publishers/abstract.py @@ -10,9 +10,7 @@ from jinja2 import Environment, FileSystemLoader, Template from mobilizon_reshare.config.config import get_settings from mobilizon_reshare.event.event import MobilizonEvent -from mobilizon_reshare.models.publication import ( - Publication as PublicationModel, -) +from mobilizon_reshare.models.publication import Publication as PublicationModel from .exceptions import PublisherError, InvalidAttribute JINJA_ENV = Environment(loader=FileSystemLoader("/")) @@ -86,15 +84,15 @@ class AbstractPlatform(ABC, LoggerMixin, ConfLoaderMixin): pass @abstractmethod - def _send(self, message: str): + def _send(self, message: str, event: Optional[MobilizonEvent] = None): raise NotImplementedError # pragma: no cover - def send(self, message: str): + def send(self, message: str, event: Optional[MobilizonEvent] = None): """ Sends a message to the target channel """ message = self._preprocess_message(message) - response = self._send(message) + response = self._send(message, event) self._validate_response(response) def _preprocess_message(self, message: str): diff --git a/mobilizon_reshare/publishers/coordinator.py b/mobilizon_reshare/publishers/coordinator.py index eba980b..a830f3e 100644 --- a/mobilizon_reshare/publishers/coordinator.py +++ b/mobilizon_reshare/publishers/coordinator.py @@ -85,7 +85,7 @@ class PublisherCoordinator: message = publication.formatter.get_message_from_event( publication.event ) - publication.publisher.send(message) + publication.publisher.send(message, publication.event) reports.append( EventPublicationReport( status=PublicationStatus.COMPLETED, diff --git a/mobilizon_reshare/publishers/platforms/facebook.py b/mobilizon_reshare/publishers/platforms/facebook.py new file mode 100644 index 0000000..bf03092 --- /dev/null +++ b/mobilizon_reshare/publishers/platforms/facebook.py @@ -0,0 +1,82 @@ +from typing import Optional + +import facebook +import pkg_resources + +from mobilizon_reshare.event.event import MobilizonEvent +from mobilizon_reshare.publishers.abstract import ( + AbstractPlatform, + AbstractEventFormatter, +) +from mobilizon_reshare.publishers.exceptions import ( + InvalidCredentials, + InvalidEvent, +) + + +class FacebookFormatter(AbstractEventFormatter): + + _conf = ("publisher", "facebook") + default_template_path = pkg_resources.resource_filename( + "mobilizon_reshare.publishers.templates", "facebook.tmpl.j2" + ) + + default_recap_template_path = pkg_resources.resource_filename( + "mobilizon_reshare.publishers.templates", "facebook_recap.tmpl.j2" + ) + + default_recap_header_template_path = pkg_resources.resource_filename( + "mobilizon_reshare.publishers.templates", "facebook_recap_header.tmpl.j2" + ) + + def validate_event(self, event: MobilizonEvent) -> None: + text = event.description + if not (text and text.strip()): + self._log_error("No text was found", raise_error=InvalidEvent) + + def validate_message(self, message) -> None: + pass + + +class FacebookPlatform(AbstractPlatform): + """ + Facebook publisher class. + """ + + name = "facebook" + + def _get_api(self): + return facebook.GraphAPI( + access_token=self.conf["page_access_token"], version="8.0" + ) + + def _send(self, message: str, event: Optional[MobilizonEvent] = None): + self._get_api().put_object( + parent_object="me", + connection_name="feed", + message=message, + link=event.mobilizon_link if event else None, + ) + + def validate_credentials(self): + + try: + self._get_api().get_object(id="me", field="name") + except Exception: + self._log_error( + "Invalid Facebook credentials. Authentication Failed", + raise_error=InvalidCredentials, + ) + + def _validate_response(self, response): + pass + + +class FacebookPublisher(FacebookPlatform): + + _conf = ("publisher", "facebook") + + +class FacebookNotifier(FacebookPlatform): + + _conf = ("notifier", "facebook") diff --git a/mobilizon_reshare/publishers/platforms/mastodon.py b/mobilizon_reshare/publishers/platforms/mastodon.py index b80af9a..082d4a8 100644 --- a/mobilizon_reshare/publishers/platforms/mastodon.py +++ b/mobilizon_reshare/publishers/platforms/mastodon.py @@ -1,3 +1,4 @@ +from typing import Optional from urllib.parse import urljoin import pkg_resources @@ -52,7 +53,7 @@ class MastodonPlatform(AbstractPlatform): api_uri = "api/v1/" name = "mastodon" - def _send(self, message: str) -> Response: + def _send(self, message: str, event: Optional[MobilizonEvent] = None) -> Response: """ Send messages """ diff --git a/mobilizon_reshare/publishers/platforms/platform_mapping.py b/mobilizon_reshare/publishers/platforms/platform_mapping.py index c2b4fee..47ea6eb 100644 --- a/mobilizon_reshare/publishers/platforms/platform_mapping.py +++ b/mobilizon_reshare/publishers/platforms/platform_mapping.py @@ -1,3 +1,8 @@ +from mobilizon_reshare.publishers.platforms.facebook import ( + FacebookPublisher, + FacebookFormatter, + FacebookNotifier, +) from mobilizon_reshare.publishers.platforms.mastodon import ( MastodonPublisher, MastodonFormatter, @@ -29,18 +34,21 @@ name_to_publisher_class = { "telegram": TelegramPublisher, "zulip": ZulipPublisher, "twitter": TwitterPublisher, + "facebook": FacebookPublisher, } name_to_formatter_class = { "mastodon": MastodonFormatter, "telegram": TelegramFormatter, "zulip": ZulipFormatter, "twitter": TwitterFormatter, + "facebook": FacebookFormatter, } name_to_notifier_class = { "mastodon": MastodonNotifier, "telegram": TelegramNotifier, "zulip": ZulipNotifier, "twitter": TwitterNotifier, + "facebook": FacebookNotifier, } diff --git a/mobilizon_reshare/publishers/platforms/telegram.py b/mobilizon_reshare/publishers/platforms/telegram.py index 57d62ef..106548f 100644 --- a/mobilizon_reshare/publishers/platforms/telegram.py +++ b/mobilizon_reshare/publishers/platforms/telegram.py @@ -1,4 +1,5 @@ import re +from typing import Optional import pkg_resources import requests @@ -98,7 +99,7 @@ class TelegramPlatform(AbstractPlatform): "Found a different bot than the expected one", raise_error=InvalidBot, ) - def _send(self, message) -> Response: + def _send(self, message: str, event: Optional[MobilizonEvent] = None) -> Response: return requests.post( url=f"https://api.telegram.org/bot{self.conf.token}/sendMessage", json={ diff --git a/mobilizon_reshare/publishers/platforms/twitter.py b/mobilizon_reshare/publishers/platforms/twitter.py index bf9bca2..7d04673 100644 --- a/mobilizon_reshare/publishers/platforms/twitter.py +++ b/mobilizon_reshare/publishers/platforms/twitter.py @@ -1,3 +1,5 @@ +from typing import Optional + import pkg_resources from tweepy import OAuthHandler, API, TweepyException from tweepy.models import Status @@ -59,7 +61,7 @@ class TwitterPlatform(AbstractPlatform): auth.set_access_token(access_token, access_secret) return API(auth) - def _send(self, message: str) -> Status: + def _send(self, message: str, event: Optional[MobilizonEvent] = None) -> Status: try: return self._get_api().update_status(message) except TweepyException as e: diff --git a/mobilizon_reshare/publishers/platforms/zulip.py b/mobilizon_reshare/publishers/platforms/zulip.py index 5335a72..25d2fa6 100644 --- a/mobilizon_reshare/publishers/platforms/zulip.py +++ b/mobilizon_reshare/publishers/platforms/zulip.py @@ -1,3 +1,4 @@ +from typing import Optional from urllib.parse import urljoin import pkg_resources @@ -59,7 +60,9 @@ class ZulipPlatform(AbstractPlatform): api_uri = "api/v1/" name = "zulip" - def _send_private(self, message: str) -> Response: + def _send_private( + self, message: str, event: Optional[MobilizonEvent] = None + ) -> Response: """ Send private messages """ @@ -69,7 +72,7 @@ class ZulipPlatform(AbstractPlatform): data={"type": "private", "to": f"[{self.user_id}]", "content": message}, ) - def _send(self, message: str) -> Response: + def _send(self, message: str, event: Optional[MobilizonEvent] = None) -> Response: """ Send stream messages """ diff --git a/mobilizon_reshare/publishers/templates/facebook.tmpl.j2 b/mobilizon_reshare/publishers/templates/facebook.tmpl.j2 new file mode 100644 index 0000000..06a4062 --- /dev/null +++ b/mobilizon_reshare/publishers/templates/facebook.tmpl.j2 @@ -0,0 +1,9 @@ +# {{ name }} + +🕒 {{ begin_datetime.format('DD MMMM, HH:mm') }} - {{ end_datetime.format('DD MMMM, HH:mm') }} + +{% if location %} +📍 {{ location }} + +{% endif %} +{{ description }} diff --git a/mobilizon_reshare/publishers/templates/facebook_recap.tmpl.j2 b/mobilizon_reshare/publishers/templates/facebook_recap.tmpl.j2 new file mode 100644 index 0000000..b368094 --- /dev/null +++ b/mobilizon_reshare/publishers/templates/facebook_recap.tmpl.j2 @@ -0,0 +1,9 @@ +# {{ name }} + +🕒 {{ begin_datetime.format('DD MMMM, HH:mm') }} - {{ end_datetime.format('DD MMMM, HH:mm') }} + +{% if location %} +📍 {{ location }} + +{% endif %} +🔗 {{mobilizon_link}} \ No newline at end of file diff --git a/mobilizon_reshare/publishers/templates/facebook_recap_header.tmpl.j2 b/mobilizon_reshare/publishers/templates/facebook_recap_header.tmpl.j2 new file mode 100644 index 0000000..a65d634 --- /dev/null +++ b/mobilizon_reshare/publishers/templates/facebook_recap_header.tmpl.j2 @@ -0,0 +1 @@ +Upcoming events \ No newline at end of file diff --git a/poetry.lock b/poetry.lock index 3d36818..0a82a26 100644 --- a/poetry.lock +++ b/poetry.lock @@ -128,6 +128,24 @@ toml = ["toml"] vault = ["hvac"] yaml = ["ruamel.yaml"] +[[package]] +name = "facebook-sdk" +version = "4.0.0rc0" +description = "This client library is designed to support the Facebook Graph API and the official Facebook JavaScript SDK, which is the canonical way to implement Facebook authentication." +category = "main" +optional = false +python-versions = "*" +develop = false + +[package.dependencies] +requests = "*" + +[package.source] +type = "git" +url = "https://github.com/mobolic/facebook-sdk.git" +reference = "master" +resolved_reference = "3fa89fec6a20dd070ccf57968c6f89256f237f54" + [[package]] name = "idna" version = "3.2" @@ -446,7 +464,7 @@ socks = ["PySocks (>=1.5.6,!=1.5.7,<2.0)"] [metadata] lock-version = "1.1" python-versions = "^3.9" -content-hash = "763106b0d68a1b95c690e2ad828a4e847ad2532a3b13354c227f35b70f1c8ad7" +content-hash = "eb95c081f2b389a4702ee0a144a29d3bb0a9d7e26174c2c650acaa14b0f980a7" [metadata.files] aiosqlite = [ @@ -498,6 +516,7 @@ dynaconf = [ {file = "dynaconf-3.1.5-py2.py3-none-any.whl", hash = "sha256:98f0e5d861e945c1b06ed33844b9341e6412bc121c1a9ea88668df7891d8f308"}, {file = "dynaconf-3.1.5.tar.gz", hash = "sha256:40979cc454d8533fada490dc0945a1dbf5ea96f95dc9113f8a3b3053670fd569"}, ] +facebook-sdk = [] idna = [ {file = "idna-3.2-py3-none-any.whl", hash = "sha256:14475042e284991034cb48e06f6851428fb14c4dc953acd9be9a5e95c7b6dd7a"}, {file = "idna-3.2.tar.gz", hash = "sha256:467fbad99067910785144ce333826c71fb0e63a425657295239737f7ecd125f3"}, diff --git a/pyproject.toml b/pyproject.toml index c3e9709..0f88c77 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -21,6 +21,7 @@ beautifulsoup4 = "^4.9" markdownify = "^0.9" appdirs = "^1.4" tweepy = "^4.1.0" +facebook-sdk = {git = "https://github.com/mobolic/facebook-sdk.git"} [tool.poetry.dev-dependencies] responses = "^0.13" diff --git a/tests/conftest.py b/tests/conftest.py index 5229670..c391ff7 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -206,7 +206,7 @@ def mock_publisher_class(message_collector): class MockPublisher(AbstractPlatform): name = "mock" - def _send(self, message): + def _send(self, message, event): message_collector.append(message) def _validate_response(self, response): @@ -281,7 +281,7 @@ def mock_publisher_invalid_class(message_collector): name = "mock" - def _send(self, message): + def _send(self, message, event): message_collector.append(message) def _validate_response(self, response): diff --git a/tests/publishers/conftest.py b/tests/publishers/conftest.py index 7b7dd1d..533c70f 100644 --- a/tests/publishers/conftest.py +++ b/tests/publishers/conftest.py @@ -1,4 +1,5 @@ from datetime import timedelta +from typing import Optional from uuid import UUID import arrow @@ -48,7 +49,7 @@ def mock_publisher_invalid(message_collector): name = "mock" - def _send(self, message): + def _send(self, message: str, event: Optional[MobilizonEvent] = None): message_collector.append(message) def _validate_response(self, response): @@ -66,7 +67,7 @@ def mock_publisher_invalid_response(message_collector): name = "mock" - def _send(self, message): + def _send(self, message, event): message_collector.append(message) def _validate_response(self, response):