From 637ea97ffe71fbc7c3997caaed3e1df7526eb9f2 Mon Sep 17 00:00:00 2001 From: Mads Marquart Date: Mon, 28 Oct 2019 11:21:46 +0100 Subject: [PATCH 1/9] Add Image model --- fbchat/__init__.py | 1 + fbchat/_attachment.py | 19 ++--- fbchat/_core.py | 43 ++++++++++ fbchat/_file.py | 170 ++++++++------------------------------- fbchat/_group.py | 3 +- fbchat/_location.py | 19 ++--- fbchat/_page.py | 3 +- fbchat/_sticker.py | 13 +-- fbchat/_thread.py | 4 +- fbchat/_user.py | 8 +- tests/test_attachment.py | 46 ++++++----- tests/test_file.py | 118 +++++++++++++-------------- tests/test_location.py | 9 ++- tests/test_page.py | 3 +- tests/test_sticker.py | 17 ++-- tests/test_user.py | 7 +- 16 files changed, 207 insertions(+), 276 deletions(-) diff --git a/fbchat/__init__.py b/fbchat/__init__.py index 36d1659..1586476 100644 --- a/fbchat/__init__.py +++ b/fbchat/__init__.py @@ -11,6 +11,7 @@ _logging.getLogger(__name__).addHandler(_logging.NullHandler()) # The order of these is somewhat significant, e.g. User has to be imported after Thread! from . import _core, _util +from ._core import Image from ._exception import FBchatException, FBchatFacebookError from ._thread import ThreadType, ThreadLocation, ThreadColor, Thread from ._user import TypingStatus, User, ActiveStatus diff --git a/fbchat/_attachment.py b/fbchat/_attachment.py index afc54fb..1ddfbbb 100644 --- a/fbchat/_attachment.py +++ b/fbchat/_attachment.py @@ -1,4 +1,5 @@ import attr +from ._core import Image from . import _util @@ -31,14 +32,10 @@ class ShareAttachment(Attachment): description = attr.ib(None) #: Name of the source source = attr.ib(None) - #: URL of the attachment image - image_url = attr.ib(None) + #: The attached image + image = attr.ib(None) #: URL of the original image if Facebook uses ``safe_image`` original_image_url = attr.ib(None) - #: Width of the image - image_width = attr.ib(None) - #: Height of the image - image_height = attr.ib(None) #: List of additional attachments attachments = attr.ib(factory=list, converter=lambda x: [] if x is None else x) @@ -72,12 +69,10 @@ class ShareAttachment(Attachment): media = data.get("media") if media and media.get("image"): image = media["image"] - rtn.image_url = image.get("uri") + rtn.image = Image._from_uri(image) rtn.original_image_url = ( - _util.get_url_parameter(rtn.image_url, "url") - if "/safe_image.php" in rtn.image_url - else rtn.image_url + _util.get_url_parameter(rtn.image.url, "url") + if "/safe_image.php" in rtn.image.url + else rtn.image.url ) - rtn.image_width = image.get("width") - rtn.image_height = image.get("height") return rtn diff --git a/fbchat/_core.py b/fbchat/_core.py index 693584f..afc90bd 100644 --- a/fbchat/_core.py +++ b/fbchat/_core.py @@ -1,3 +1,4 @@ +import attr import logging import aenum @@ -21,3 +22,45 @@ class Enum(aenum.Enum): ) aenum.extend_enum(cls, "UNKNOWN_{}".format(value).upper(), value) return cls(value) + + +@attr.s(frozen=True, slots=True) # TODO: Add kw_only=True +class Image: + #: URL to the image + url = attr.ib(type=str) + #: Width of the image + width = attr.ib(None, type=int) + #: Height of the image + height = attr.ib(None, type=int) + + @classmethod + def _from_uri(cls, data): + return cls( + url=data["uri"], + width=int(data["width"]) if data.get("width") else None, + height=int(data["height"]) if data.get("height") else None, + ) + + @classmethod + def _from_url(cls, data): + return cls( + url=data["url"], + width=int(data["width"]) if data.get("width") else None, + height=int(data["height"]) if data.get("height") else None, + ) + + @classmethod + def _from_uri_or_none(cls, data): + if data is None: + return None + if data.get("uri") is None: + return None + return cls._from_uri(data) + + @classmethod + def _from_url_or_none(cls, data): + if data is None: + return None + if data.get("url") is None: + return None + return cls._from_url(data) diff --git a/fbchat/_file.py b/fbchat/_file.py index f547138..6b1f3b1 100644 --- a/fbchat/_file.py +++ b/fbchat/_file.py @@ -1,4 +1,5 @@ import attr +from ._core import Image from . import _util from ._attachment import Attachment @@ -55,7 +56,7 @@ class AudioAttachment(Attachment): ) -@attr.s(init=False) +@attr.s class ImageAttachment(Attachment): """Represents an image that has been sent as a Facebook attachment. @@ -73,70 +74,14 @@ class ImageAttachment(Attachment): #: Whether the image is animated is_animated = attr.ib(None) - #: URL to a thumbnail of the image - thumbnail_url = attr.ib(None) - - #: URL to a medium preview of the image - preview_url = attr.ib(None) - #: Width of the medium preview image - preview_width = attr.ib(None) - #: Height of the medium preview image - preview_height = attr.ib(None) - - #: URL to a large preview of the image - large_preview_url = attr.ib(None) - #: Width of the large preview image - large_preview_width = attr.ib(None) - #: Height of the large preview image - large_preview_height = attr.ib(None) - - #: URL to an animated preview of the image (e.g. for GIFs) - animated_preview_url = attr.ib(None) - #: Width of the animated preview image - animated_preview_width = attr.ib(None) - #: Height of the animated preview image - animated_preview_height = attr.ib(None) - - def __init__( - self, - original_extension=None, - width=None, - height=None, - is_animated=None, - thumbnail_url=None, - preview=None, - large_preview=None, - animated_preview=None, - **kwargs - ): - super(ImageAttachment, self).__init__(**kwargs) - self.original_extension = original_extension - if width is not None: - width = int(width) - self.width = width - if height is not None: - height = int(height) - self.height = height - self.is_animated = is_animated - self.thumbnail_url = thumbnail_url - - if preview is None: - preview = {} - self.preview_url = preview.get("uri") - self.preview_width = preview.get("width") - self.preview_height = preview.get("height") - - if large_preview is None: - large_preview = {} - self.large_preview_url = large_preview.get("uri") - self.large_preview_width = large_preview.get("width") - self.large_preview_height = large_preview.get("height") - - if animated_preview is None: - animated_preview = {} - self.animated_preview_url = animated_preview.get("uri") - self.animated_preview_width = animated_preview.get("width") - self.animated_preview_height = animated_preview.get("height") + #: A thumbnail of the image + thumbnail = attr.ib(None) + #: A medium preview of the image + preview = attr.ib(None) + #: A large preview of the image + large_preview = attr.ib(None) + #: An animated preview of the image (e.g. for GIFs) + animated_preview = attr.ib(None) @classmethod def _from_graphql(cls, data): @@ -146,10 +91,12 @@ class ImageAttachment(Attachment): width=data.get("original_dimensions", {}).get("width"), height=data.get("original_dimensions", {}).get("height"), is_animated=data["__typename"] == "MessageAnimatedImage", - thumbnail_url=data.get("thumbnail", {}).get("uri"), - preview=data.get("preview") or data.get("preview_image"), - large_preview=data.get("large_preview"), - animated_preview=data.get("animated_image"), + thumbnail=Image._from_uri_or_none(data.get("thumbnail")), + preview=Image._from_uri_or_none( + data.get("preview") or data.get("preview_image") + ), + large_preview=Image._from_uri_or_none(data.get("large_preview")), + animated_preview=Image._from_uri_or_none(data.get("animated_image")), uid=data.get("legacy_attachment_id"), ) @@ -159,14 +106,14 @@ class ImageAttachment(Attachment): return cls( width=data["original_dimensions"].get("x"), height=data["original_dimensions"].get("y"), - thumbnail_url=data["image"].get("uri"), - large_preview=data["image2"], - preview=data["image1"], + thumbnail=Image._from_uri_or_none(data["image"]), + large_preview=Image._from_uri(data["image2"]), + preview=Image._from_uri(data["image1"]), uid=data["legacy_attachment_id"], ) -@attr.s(init=False) +@attr.s class VideoAttachment(Attachment): """Represents a video that has been sent as a Facebook attachment.""" @@ -181,63 +128,12 @@ class VideoAttachment(Attachment): #: URL to very compressed preview video preview_url = attr.ib(None) - #: URL to a small preview image of the video - small_image_url = attr.ib(None) - #: Width of the small preview image - small_image_width = attr.ib(None) - #: Height of the small preview image - small_image_height = attr.ib(None) - - #: URL to a medium preview image of the video - medium_image_url = attr.ib(None) - #: Width of the medium preview image - medium_image_width = attr.ib(None) - #: Height of the medium preview image - medium_image_height = attr.ib(None) - - #: URL to a large preview image of the video - large_image_url = attr.ib(None) - #: Width of the large preview image - large_image_width = attr.ib(None) - #: Height of the large preview image - large_image_height = attr.ib(None) - - def __init__( - self, - size=None, - width=None, - height=None, - duration=None, - preview_url=None, - small_image=None, - medium_image=None, - large_image=None, - **kwargs - ): - super(VideoAttachment, self).__init__(**kwargs) - self.size = size - self.width = width - self.height = height - self.duration = duration - self.preview_url = preview_url - - if small_image is None: - small_image = {} - self.small_image_url = small_image.get("uri") - self.small_image_width = small_image.get("width") - self.small_image_height = small_image.get("height") - - if medium_image is None: - medium_image = {} - self.medium_image_url = medium_image.get("uri") - self.medium_image_width = medium_image.get("width") - self.medium_image_height = medium_image.get("height") - - if large_image is None: - large_image = {} - self.large_image_url = large_image.get("uri") - self.large_image_width = large_image.get("width") - self.large_image_height = large_image.get("height") + #: A small preview image of the video + small_image = attr.ib(None) + #: A medium preview image of the video + medium_image = attr.ib(None) + #: A large preview image of the video + large_image = attr.ib(None) @classmethod def _from_graphql(cls, data): @@ -246,9 +142,9 @@ class VideoAttachment(Attachment): height=data.get("original_dimensions", {}).get("height"), duration=_util.millis_to_timedelta(data.get("playable_duration_in_ms")), preview_url=data.get("playable_url"), - small_image=data.get("chat_image"), - medium_image=data.get("inbox_image"), - large_image=data.get("large_image"), + small_image=Image._from_uri_or_none(data.get("chat_image")), + medium_image=Image._from_uri_or_none(data.get("inbox_image")), + large_image=Image._from_uri_or_none(data.get("large_image")), uid=data.get("legacy_attachment_id"), ) @@ -258,7 +154,7 @@ class VideoAttachment(Attachment): return cls( duration=_util.millis_to_timedelta(media.get("playable_duration_in_ms")), preview_url=media.get("playable_url"), - medium_image=media.get("image"), + medium_image=Image._from_uri_or_none(media.get("image")), uid=data["target"].get("video_id"), ) @@ -268,9 +164,9 @@ class VideoAttachment(Attachment): return cls( width=data["original_dimensions"].get("x"), height=data["original_dimensions"].get("y"), - small_image=data["image"], - medium_image=data["image1"], - large_image=data["image2"], + small_image=Image._from_uri(data["image"]), + medium_image=Image._from_uri(data["image1"]), + large_image=Image._from_uri(data["image2"]), uid=data["legacy_attachment_id"], ) diff --git a/fbchat/_group.py b/fbchat/_group.py index 2c1b1da..179142d 100644 --- a/fbchat/_group.py +++ b/fbchat/_group.py @@ -1,4 +1,5 @@ import attr +from ._core import Image from . import _util, _plan from ._thread import ThreadType, Thread @@ -64,7 +65,7 @@ class Group(Thread): if data.get("group_approval_queue") else None, join_link=data["joinable_mode"].get("link"), - photo=data["image"].get("uri"), + photo=Image._from_uri_or_none(data["image"]), name=data.get("name"), message_count=data.get("messages_count"), last_active=last_active, diff --git a/fbchat/_location.py b/fbchat/_location.py index 823364a..8ca8e63 100644 --- a/fbchat/_location.py +++ b/fbchat/_location.py @@ -1,4 +1,5 @@ import attr +from ._core import Image from ._attachment import Attachment from . import _util @@ -14,12 +15,8 @@ class LocationAttachment(Attachment): latitude = attr.ib(None) #: Longitude of the location longitude = attr.ib(None) - #: URL of image showing the map of the location - image_url = attr.ib(None, init=False) - #: Width of the image - image_width = attr.ib(None, init=False) - #: Height of the image - image_height = attr.ib(None, init=False) + #: Image showing the map of the location + image = attr.ib(None, init=False) #: URL to Bing maps with the location url = attr.ib(None, init=False) # Address of the location @@ -45,10 +42,7 @@ class LocationAttachment(Attachment): ) media = data.get("media") if media and media.get("image"): - image = media["image"] - rtn.image_url = image.get("uri") - rtn.image_width = image.get("width") - rtn.image_height = image.get("height") + rtn.image = Image._from_uri(media["image"]) rtn.url = url return rtn @@ -96,9 +90,6 @@ class LiveLocationAttachment(LocationAttachment): ) media = data.get("media") if media and media.get("image"): - image = media["image"] - rtn.image_url = image.get("uri") - rtn.image_width = image.get("width") - rtn.image_height = image.get("height") + rtn.image = Image._from_uri(media["image"]) rtn.url = data.get("url") return rtn diff --git a/fbchat/_page.py b/fbchat/_page.py index dea3523..53edf6c 100644 --- a/fbchat/_page.py +++ b/fbchat/_page.py @@ -1,4 +1,5 @@ import attr +from ._core import Image from . import _plan from ._thread import ThreadType, Thread @@ -35,7 +36,7 @@ class Page(Thread): url=data.get("url"), city=data.get("city").get("name"), category=data.get("category_type"), - photo=data["profile_picture"].get("uri"), + photo=Image._from_uri(data["profile_picture"]), name=data.get("name"), message_count=data.get("messages_count"), plan=plan, diff --git a/fbchat/_sticker.py b/fbchat/_sticker.py index cf0f0eb..6c781d2 100644 --- a/fbchat/_sticker.py +++ b/fbchat/_sticker.py @@ -1,4 +1,5 @@ import attr +from ._core import Image from ._attachment import Attachment @@ -23,12 +24,8 @@ class Sticker(Attachment): #: The frame rate the spritemap is intended to be played in frame_rate = attr.ib(None) - #: URL to the sticker's image - url = attr.ib(None) - #: Width of the sticker - width = attr.ib(None) - #: Height of the sticker - height = attr.ib(None) + #: The sticker's image + image = attr.ib(None) #: The sticker's label/name label = attr.ib(None) @@ -46,9 +43,7 @@ class Sticker(Attachment): self.frames_per_row = data.get("frames_per_row") self.frames_per_col = data.get("frames_per_column") self.frame_rate = data.get("frame_rate") - self.url = data.get("url") - self.width = data.get("width") - self.height = data.get("height") + self.image = Image._from_url_or_none(data) if data.get("label"): self.label = data["label"] return self diff --git a/fbchat/_thread.py b/fbchat/_thread.py index 4a098e3..6540a24 100644 --- a/fbchat/_thread.py +++ b/fbchat/_thread.py @@ -1,5 +1,5 @@ import attr -from ._core import Enum +from ._core import Enum, Image class ThreadType(Enum): @@ -75,7 +75,7 @@ class Thread: uid = attr.ib(converter=str) #: Specifies the type of thread. Can be used a ``thread_type``. See :ref:`intro_threads` for more info type = None - #: A URL to the thread's picture + #: The thread's picture photo = attr.ib(None) #: The name of the thread name = attr.ib(None) diff --git a/fbchat/_user.py b/fbchat/_user.py index 8e168fe..6e7e698 100644 --- a/fbchat/_user.py +++ b/fbchat/_user.py @@ -1,5 +1,5 @@ import attr -from ._core import Enum +from ._core import Enum, Image from . import _util, _plan from ._thread import ThreadType, Thread @@ -89,7 +89,7 @@ class User(Thread): color=c_info.get("color"), emoji=c_info.get("emoji"), own_nickname=c_info.get("own_nickname"), - photo=data["profile_picture"].get("uri"), + photo=Image._from_uri(data["profile_picture"]), name=data.get("name"), message_count=data.get("messages_count"), plan=plan, @@ -135,7 +135,7 @@ class User(Thread): color=c_info.get("color"), emoji=c_info.get("emoji"), own_nickname=c_info.get("own_nickname"), - photo=user["big_image_src"].get("uri"), + photo=Image._from_uri(user["big_image_src"]), message_count=data.get("messages_count"), last_active=last_active, plan=plan, @@ -147,7 +147,7 @@ class User(Thread): data["id"], first_name=data.get("firstName"), url=data.get("uri"), - photo=data.get("thumbSrc"), + photo=Image(url=data.get("thumbSrc")), name=data.get("name"), is_friend=data.get("is_friend"), gender=GENDERS.get(data.get("gender")), diff --git a/tests/test_attachment.py b/tests/test_attachment.py index f94d6ca..6887f73 100644 --- a/tests/test_attachment.py +++ b/tests/test_attachment.py @@ -72,10 +72,8 @@ def test_share_from_graphql_link(): title="a.com", description="", source="a.com", - image_url=None, + image=None, original_image_url=None, - image_width=None, - image_height=None, attachments=[], uid="ee.mid.$xyz", ) == ShareAttachment._from_graphql(data) @@ -125,10 +123,10 @@ def test_share_from_graphql_link_with_image(): " Share photos and videos, send messages and get updates." ), source=None, - image_url="https://www.facebook.com/rsrc.php/v3/x.png", + image=fbchat.Image( + url="https://www.facebook.com/rsrc.php/v3/x.png", width=325, height=325 + ), original_image_url="https://www.facebook.com/rsrc.php/v3/x.png", - image_width=325, - image_height=325, attachments=[], uid="deadbeef123", ) == ShareAttachment._from_graphql(data) @@ -187,14 +185,14 @@ def test_share_from_graphql_video(): " Subscribe to the official Rick As..." ), source="youtube.com", - image_url=( - "https://external-arn2-1.xx.fbcdn.net/safe_image.php?d=xyz123" + image=fbchat.Image( + url="https://external-arn2-1.xx.fbcdn.net/safe_image.php?d=xyz123" "&w=960&h=540&url=https%3A%2F%2Fi.ytimg.com%2Fvi%2FdQw4w9WgXcQ" - "%2Fmaxresdefault.jpg&sx=0&sy=0&sw=1280&sh=720&_nc_hash=abc123" + "%2Fmaxresdefault.jpg&sx=0&sy=0&sw=1280&sh=720&_nc_hash=abc123", + width=960, + height=540, ), original_image_url="https://i.ytimg.com/vi/dQw4w9WgXcQ/maxresdefault.jpg", - image_width=960, - image_height=540, attachments=[], uid="ee.mid.$gAAT4Sw1WSGhzQ9uRWVtEpZHZ8ZPV", ) == ShareAttachment._from_graphql(data) @@ -310,10 +308,12 @@ def test_share_with_image_subattachment(): title="", description="Abc", source="Def", - image_url="https://scontent-arn2-1.xx.fbcdn.net/v/t1.0-9/1.jpg", + image=fbchat.Image( + url="https://scontent-arn2-1.xx.fbcdn.net/v/t1.0-9/1.jpg", + width=720, + height=960, + ), original_image_url="https://scontent-arn2-1.xx.fbcdn.net/v/t1.0-9/1.jpg", - image_width=720, - image_height=960, attachments=[None], uid="deadbeef123", ) == ShareAttachment._from_graphql(data) @@ -436,20 +436,22 @@ def test_share_with_video_subattachment(): title="", description="Abc", source="Def", - image_url="https://scontent-arn2-1.xx.fbcdn.net/v/t15.5256-10/p180x540/1.jpg", + image=fbchat.Image( + url="https://scontent-arn2-1.xx.fbcdn.net/v/t15.5256-10/p180x540/1.jpg", + width=960, + height=540, + ), original_image_url="https://scontent-arn2-1.xx.fbcdn.net/v/t15.5256-10/p180x540/1.jpg", - image_width=960, - image_height=540, attachments=[ fbchat.VideoAttachment( uid="2222", duration=datetime.timedelta(seconds=24, microseconds=469000), preview_url="https://video-arn2-1.xx.fbcdn.net/v/t42.9040-2/vid.mp4", - medium_image={ - "uri": "https://scontent-arn2-1.xx.fbcdn.net/v/t15.5256-10/p180x540/1.jpg", - "width": 960, - "height": 540, - }, + medium_image=fbchat.Image( + url="https://scontent-arn2-1.xx.fbcdn.net/v/t15.5256-10/p180x540/1.jpg", + width=960, + height=540, + ), ) ], uid="deadbeef123", diff --git a/tests/test_file.py b/tests/test_file.py index a403968..5ae97d5 100644 --- a/tests/test_file.py +++ b/tests/test_file.py @@ -33,17 +33,17 @@ def test_imageattachment_from_list(): uid="1234", width=2833, height=1367, - thumbnail_url="https://scontent-arn2-1.xx.fbcdn.net/v/s261x260/1.jpg", - preview={ - "uri": "https://scontent-arn2-1.xx.fbcdn.net/v/2.jpg", - "width": 960, - "height": 463, - }, - large_preview={ - "uri": "https://scontent-arn2-1.xx.fbcdn.net/v/s2048x2048/3.jpg", - "width": 2048, - "height": 988, - }, + thumbnail=fbchat.Image( + url="https://scontent-arn2-1.xx.fbcdn.net/v/s261x260/1.jpg" + ), + preview=fbchat.Image( + url="https://scontent-arn2-1.xx.fbcdn.net/v/2.jpg", width=960, height=463 + ), + large_preview=fbchat.Image( + url="https://scontent-arn2-1.xx.fbcdn.net/v/s2048x2048/3.jpg", + width=2048, + height=988, + ), ) == ImageAttachment._from_list({"node": data}) @@ -71,19 +71,19 @@ def test_videoattachment_from_list(): uid="1234", width=640, height=368, - small_image={ - "uri": "https://scontent-arn2-1.xx.fbcdn.net/v/t15.3394-10/p261x260/1.jpg" - }, - medium_image={ - "uri": "https://scontent-arn2-1.xx.fbcdn.net/v/t15.3394-10/2.jpg", - "width": 640, - "height": 368, - }, - large_image={ - "uri": "https://scontent-arn2-1.xx.fbcdn.net/v/t15.3394-10/3.jpg", - "width": 640, - "height": 368, - }, + small_image=fbchat.Image( + url="https://scontent-arn2-1.xx.fbcdn.net/v/t15.3394-10/p261x260/1.jpg" + ), + medium_image=fbchat.Image( + url="https://scontent-arn2-1.xx.fbcdn.net/v/t15.3394-10/2.jpg", + width=640, + height=368, + ), + large_image=fbchat.Image( + url="https://scontent-arn2-1.xx.fbcdn.net/v/t15.3394-10/3.jpg", + width=640, + height=368, + ), ) == VideoAttachment._from_list({"node": data}) @@ -170,17 +170,15 @@ def test_graphql_to_attachment_image1(): width=None, height=None, is_animated=False, - thumbnail_url="https://scontent-arn2-1.xx.fbcdn.net/v/p50x50/3.png", - preview={ - "uri": "https://scontent-arn2-1.xx.fbcdn.net/v/1.png", - "width": 128, - "height": 128, - }, - large_preview={ - "uri": "https://scontent-arn2-1.xx.fbcdn.net/v/2.png", - "width": 128, - "height": 128, - }, + thumbnail=fbchat.Image( + url="https://scontent-arn2-1.xx.fbcdn.net/v/p50x50/3.png" + ), + preview=fbchat.Image( + url="https://scontent-arn2-1.xx.fbcdn.net/v/1.png", width=128, height=128 + ), + large_preview=fbchat.Image( + url="https://scontent-arn2-1.xx.fbcdn.net/v/2.png", width=128, height=128 + ), ) == graphql_to_attachment(data) @@ -209,12 +207,12 @@ def test_graphql_to_attachment_image2(): width=None, height=None, is_animated=True, - preview={"uri": "https://cdn.fbsbx.com/v/1.gif", "width": 128, "height": 128}, - animated_preview={ - "uri": "https://cdn.fbsbx.com/v/1.gif", - "width": 128, - "height": 128, - }, + preview=fbchat.Image( + url="https://cdn.fbsbx.com/v/1.gif", width=128, height=128 + ), + animated_preview=fbchat.Image( + url="https://cdn.fbsbx.com/v/1.gif", width=128, height=128 + ), ) == graphql_to_attachment(data) @@ -251,21 +249,19 @@ def test_graphql_to_attachment_video(): height=None, duration=datetime.timedelta(seconds=6), preview_url="https://video-arn2-1.xx.fbcdn.net/v/video-4321.mp4", - small_image={ - "uri": "https://scontent-arn2-1.xx.fbcdn.net/v/s168x128/1.jpg", - "width": 168, - "height": 96, - }, - medium_image={ - "uri": "https://scontent-arn2-1.xx.fbcdn.net/v/p261x260/3.jpg", - "width": 452, - "height": 260, - }, - large_image={ - "uri": "https://scontent-arn2-1.xx.fbcdn.net/v/2.jpg", - "width": 640, - "height": 368, - }, + small_image=fbchat.Image( + url="https://scontent-arn2-1.xx.fbcdn.net/v/s168x128/1.jpg", + width=168, + height=96, + ), + medium_image=fbchat.Image( + url="https://scontent-arn2-1.xx.fbcdn.net/v/p261x260/3.jpg", + width=452, + height=260, + ), + large_image=fbchat.Image( + url="https://scontent-arn2-1.xx.fbcdn.net/v/2.jpg", width=640, height=368 + ), ) == graphql_to_attachment(data) @@ -350,9 +346,9 @@ def test_graphql_to_subattachment_video(): uid="1234", duration=datetime.timedelta(seconds=24, microseconds=469000), preview_url="https://video-arn2-1.xx.fbcdn.net/v/t42.9040-2/vid.mp4", - medium_image={ - "uri": "https://scontent-arn2-1.xx.fbcdn.net/v/t15.5256-10/p180x540/1.jpg", - "width": 960, - "height": 540, - }, + medium_image=fbchat.Image( + url="https://scontent-arn2-1.xx.fbcdn.net/v/t15.5256-10/p180x540/1.jpg", + width=960, + height=540, + ), ) == graphql_to_subattachment(data) diff --git a/tests/test_location.py b/tests/test_location.py index 3c5215d..2572c9a 100644 --- a/tests/test_location.py +++ b/tests/test_location.py @@ -1,5 +1,6 @@ import pytest import datetime +import fbchat from fbchat._location import LocationAttachment, LiveLocationAttachment @@ -34,9 +35,11 @@ def test_location_attachment_from_graphql(): "subattachments": [], } expected = LocationAttachment(latitude=55.4, longitude=12.4322, uid=400828513928715) - expected.image_url = "https://external-arn2-1.xx.fbcdn.net/static_map.php?v=1020&osm_provider=2&size=545x280&zoom=15&markers=55.40000000%2C12.43220000&language=en" - expected.image_width = 545 - expected.image_height = 280 + expected.image = fbchat.Image( + url="https://external-arn2-1.xx.fbcdn.net/static_map.php?v=1020&osm_provider=2&size=545x280&zoom=15&markers=55.40000000%2C12.43220000&language=en", + width=545, + height=280, + ) expected.url = "https://l.facebook.com/l.php?u=https%3A%2F%2Fwww.bing.com%2Fmaps%2Fdefault.aspx%3Fv%3D2%26pc%3DFACEBK%26mid%3D8100%26where1%3D55.4%252C%2B12.4322%26FORM%3DFBKPL1%26mkt%3Den-GB&h=a&s=1" assert expected == LocationAttachment._from_graphql(data) diff --git a/tests/test_page.py b/tests/test_page.py index e5ba22e..959f0f2 100644 --- a/tests/test_page.py +++ b/tests/test_page.py @@ -1,3 +1,4 @@ +import fbchat from fbchat._page import Page @@ -12,7 +13,7 @@ def test_page_from_graphql(): } assert Page( uid="123456", - photo="https://scontent-arn2-1.xx.fbcdn.net/v/...", + photo=fbchat.Image(url="https://scontent-arn2-1.xx.fbcdn.net/v/..."), name="Some school", url="https://www.facebook.com/some-school/", city=None, diff --git a/tests/test_sticker.py b/tests/test_sticker.py index d56af9f..b5aedec 100644 --- a/tests/test_sticker.py +++ b/tests/test_sticker.py @@ -1,4 +1,5 @@ import pytest +import fbchat from fbchat._sticker import Sticker @@ -20,9 +21,11 @@ def test_from_graphql_normal(): frames_per_row=None, frames_per_col=None, frame_rate=None, - url="https://scontent-arn2-1.xx.fbcdn.net/v/redacted.png", - width=274, - height=274, + image=fbchat.Image( + url="https://scontent-arn2-1.xx.fbcdn.net/v/redacted.png", + width=274, + height=274, + ), label="Like, thumbs up", ) == Sticker._from_graphql( { @@ -54,9 +57,11 @@ def test_from_graphql_animated(): frames_per_row=2, frames_per_col=2, frame_rate=142, - url="https://scontent-arn2-1.fbcdn.net/v/redacted1.png", - width=240, - height=293, + image=fbchat.Image( + url="https://scontent-arn2-1.fbcdn.net/v/redacted1.png", + width=240, + height=293, + ), label="Love, cat with heart", ) == Sticker._from_graphql( { diff --git a/tests/test_user.py b/tests/test_user.py index b8ed5ea..8338878 100644 --- a/tests/test_user.py +++ b/tests/test_user.py @@ -1,5 +1,6 @@ import pytest import datetime +import fbchat from fbchat._user import User, ActiveStatus @@ -17,7 +18,7 @@ def test_user_from_graphql(): } assert User( uid="1234", - photo="https://scontent-arn2-1.xx.fbcdn.net/v/...", + photo=fbchat.Image(url="https://scontent-arn2-1.xx.fbcdn.net/v/..."), name="Abc Def Ghi", url="https://www.facebook.com/profile.php?id=1234", first_name="Abc", @@ -137,7 +138,7 @@ def test_user_from_thread_fetch(): } assert User( uid="1234", - photo="https://scontent-arn2-1.xx.fbcdn.net/v/...", + photo=fbchat.Image(url="https://scontent-arn2-1.xx.fbcdn.net/v/..."), name="Abc Def Ghi", last_active=datetime.datetime(2017, 7, 14, 2, 40, tzinfo=datetime.timezone.utc), message_count=1111, @@ -175,7 +176,7 @@ def test_user_from_all_fetch(): } assert User( uid="1234", - photo="https://scontent-arn2-1.xx.fbcdn.net/v/...", + photo=fbchat.Image(url="https://scontent-arn2-1.xx.fbcdn.net/v/..."), name="Abc Def Ghi", url="https://www.facebook.com/profile.php?id=1234", first_name="Abc", From b03d0ae3b7b72119af040954c44bd18f038d21be Mon Sep 17 00:00:00 2001 From: Mads Marquart Date: Mon, 28 Oct 2019 12:23:46 +0100 Subject: [PATCH 2/9] Allow specifying class variables in init --- fbchat/_attachment.py | 5 +---- fbchat/_file.py | 6 ------ fbchat/_group.py | 10 ++++------ fbchat/_location.py | 7 ++----- fbchat/_message.py | 24 ++++++++++++------------ fbchat/_plan.py | 8 ++++---- tests/test_location.py | 26 ++++++++++++++------------ 7 files changed, 37 insertions(+), 49 deletions(-) diff --git a/fbchat/_attachment.py b/fbchat/_attachment.py index 1ddfbbb..be24101 100644 --- a/fbchat/_attachment.py +++ b/fbchat/_attachment.py @@ -37,10 +37,7 @@ class ShareAttachment(Attachment): #: URL of the original image if Facebook uses ``safe_image`` original_image_url = attr.ib(None) #: List of additional attachments - attachments = attr.ib(factory=list, converter=lambda x: [] if x is None else x) - - # Put here for backwards compatibility, so that the init argument order is preserved - uid = attr.ib(None) + attachments = attr.ib(factory=list) @classmethod def _from_graphql(cls, data): diff --git a/fbchat/_file.py b/fbchat/_file.py index 6b1f3b1..90ae20c 100644 --- a/fbchat/_file.py +++ b/fbchat/_file.py @@ -17,9 +17,6 @@ class FileAttachment(Attachment): #: Whether Facebook determines that this file may be harmful is_malicious = attr.ib(None) - # Put here for backwards compatibility, so that the init argument order is preserved - uid = attr.ib(None) - @classmethod def _from_graphql(cls, data): return cls( @@ -43,9 +40,6 @@ class AudioAttachment(Attachment): #: Audio type audio_type = attr.ib(None) - # Put here for backwards compatibility, so that the init argument order is preserved - uid = attr.ib(None) - @classmethod def _from_graphql(cls, data): return cls( diff --git a/fbchat/_group.py b/fbchat/_group.py index 179142d..c0ef982 100644 --- a/fbchat/_group.py +++ b/fbchat/_group.py @@ -11,21 +11,19 @@ class Group(Thread): type = ThreadType.GROUP #: Unique list (set) of the group thread's participant user IDs - participants = attr.ib(factory=set, converter=lambda x: set() if x is None else x) + participants = attr.ib(factory=set) #: A dictionary, containing user nicknames mapped to their IDs - nicknames = attr.ib(factory=dict, converter=lambda x: {} if x is None else x) + nicknames = attr.ib(factory=dict) #: A :class:`ThreadColor`. The groups's message color color = attr.ib(None) #: The groups's default emoji emoji = attr.ib(None) # Set containing user IDs of thread admins - admins = attr.ib(factory=set, converter=lambda x: set() if x is None else x) + admins = attr.ib(factory=set) # True if users need approval to join approval_mode = attr.ib(None) # Set containing user IDs requesting to join - approval_requests = attr.ib( - factory=set, converter=lambda x: set() if x is None else x - ) + approval_requests = attr.ib(factory=set) # Link for joining group join_link = attr.ib(None) diff --git a/fbchat/_location.py b/fbchat/_location.py index 8ca8e63..95bda96 100644 --- a/fbchat/_location.py +++ b/fbchat/_location.py @@ -16,15 +16,12 @@ class LocationAttachment(Attachment): #: Longitude of the location longitude = attr.ib(None) #: Image showing the map of the location - image = attr.ib(None, init=False) + image = attr.ib(None) #: URL to Bing maps with the location - url = attr.ib(None, init=False) + url = attr.ib(None) # Address of the location address = attr.ib(None) - # Put here for backwards compatibility, so that the init argument order is preserved - uid = attr.ib(None) - @classmethod def _from_graphql(cls, data): url = data.get("url") diff --git a/fbchat/_message.py b/fbchat/_message.py index 90f037b..c4ab144 100644 --- a/fbchat/_message.py +++ b/fbchat/_message.py @@ -61,35 +61,35 @@ class Message: #: The actual message text = attr.ib(None) #: A list of :class:`Mention` objects - mentions = attr.ib(factory=list, converter=lambda x: [] if x is None else x) + mentions = attr.ib(factory=list) #: A :class:`EmojiSize`. Size of a sent emoji emoji_size = attr.ib(None) #: The message ID - uid = attr.ib(None, init=False) + uid = attr.ib(None) #: ID of the sender - author = attr.ib(None, init=False) + author = attr.ib(None) #: Datetime of when the message was sent - created_at = attr.ib(None, init=False) + created_at = attr.ib(None) #: Whether the message is read - is_read = attr.ib(None, init=False) + is_read = attr.ib(None) #: A list of people IDs who read the message, works only with :func:`fbchat.Client.fetch_thread_messages` - read_by = attr.ib(factory=list, init=False) + read_by = attr.ib(factory=list) #: A dictionary with user's IDs as keys, and their :class:`MessageReaction` as values - reactions = attr.ib(factory=dict, init=False) + reactions = attr.ib(factory=dict) #: A :class:`Sticker` sticker = attr.ib(None) #: A list of attachments - attachments = attr.ib(factory=list, converter=lambda x: [] if x is None else x) + attachments = attr.ib(factory=list) #: A list of :class:`QuickReply` - quick_replies = attr.ib(factory=list, converter=lambda x: [] if x is None else x) + quick_replies = attr.ib(factory=list) #: Whether the message is unsent (deleted for everyone) - unsent = attr.ib(False, init=False) + unsent = attr.ib(False) #: Message ID you want to reply to reply_to_id = attr.ib(None) #: Replied message - replied_to = attr.ib(None, init=False) + replied_to = attr.ib(None) #: Whether the message was forwarded - forwarded = attr.ib(False, init=False) + forwarded = attr.ib(False) @classmethod def format_mentions(cls, text, *args, **kwargs): diff --git a/fbchat/_plan.py b/fbchat/_plan.py index 8daa86b..fdc23fc 100644 --- a/fbchat/_plan.py +++ b/fbchat/_plan.py @@ -14,20 +14,20 @@ class GuestStatus(Enum): class Plan: """Represents a plan.""" - #: ID of the plan - uid = attr.ib(None, init=False) #: Plan time (datetime), only precise down to the minute time = attr.ib() #: Plan title title = attr.ib() + #: ID of the plan + uid = attr.ib(None) #: Plan location name location = attr.ib(None, converter=lambda x: x or "") #: Plan location ID location_id = attr.ib(None, converter=lambda x: x or "") #: ID of the plan creator - author_id = attr.ib(None, init=False) + author_id = attr.ib(None) #: Dictionary of `User` IDs mapped to their `GuestStatus` - guests = attr.ib(None, init=False) + guests = attr.ib(None) @property def going(self): diff --git a/tests/test_location.py b/tests/test_location.py index 2572c9a..567fca8 100644 --- a/tests/test_location.py +++ b/tests/test_location.py @@ -34,14 +34,17 @@ def test_location_attachment_from_graphql(): "target": {"__typename": "MessageLocation"}, "subattachments": [], } - expected = LocationAttachment(latitude=55.4, longitude=12.4322, uid=400828513928715) - expected.image = fbchat.Image( - url="https://external-arn2-1.xx.fbcdn.net/static_map.php?v=1020&osm_provider=2&size=545x280&zoom=15&markers=55.40000000%2C12.43220000&language=en", - width=545, - height=280, - ) - expected.url = "https://l.facebook.com/l.php?u=https%3A%2F%2Fwww.bing.com%2Fmaps%2Fdefault.aspx%3Fv%3D2%26pc%3DFACEBK%26mid%3D8100%26where1%3D55.4%252C%2B12.4322%26FORM%3DFBKPL1%26mkt%3Den-GB&h=a&s=1" - assert expected == LocationAttachment._from_graphql(data) + assert LocationAttachment( + uid=400828513928715, + latitude=55.4, + longitude=12.4322, + image=fbchat.Image( + url="https://external-arn2-1.xx.fbcdn.net/static_map.php?v=1020&osm_provider=2&size=545x280&zoom=15&markers=55.40000000%2C12.43220000&language=en", + width=545, + height=280, + ), + url="https://l.facebook.com/l.php?u=https%3A%2F%2Fwww.bing.com%2Fmaps%2Fdefault.aspx%3Fv%3D2%26pc%3DFACEBK%26mid%3D8100%26where1%3D55.4%252C%2B12.4322%26FORM%3DFBKPL1%26mkt%3Den-GB&h=a&s=1", + ) == LocationAttachment._from_graphql(data) @pytest.mark.skip(reason="need to gather test data") @@ -76,16 +79,15 @@ def test_live_location_from_graphql_expired(): }, "subattachments": [], } - expected = LiveLocationAttachment( + assert LiveLocationAttachment( uid=2254535444791641, name="Location-sharing ended", expires_at=datetime.datetime( 2019, 1, 4, 18, 25, 45, tzinfo=datetime.timezone.utc ), is_expired=True, - ) - expected.url = "https://www.facebook.com/" - assert expected == LiveLocationAttachment._from_graphql(data) + url="https://www.facebook.com/", + ) == LiveLocationAttachment._from_graphql(data) @pytest.mark.skip(reason="need to gather test data") From 27ae1c9f88230604928ac17c63d6fede352d3452 Mon Sep 17 00:00:00 2001 From: Mads Marquart Date: Wed, 11 Dec 2019 14:14:47 +0100 Subject: [PATCH 3/9] Stop mutating models --- fbchat/_attachment.py | 25 ++-- fbchat/_client.py | 56 ++++----- fbchat/_file.py | 12 +- fbchat/_location.py | 27 +++-- fbchat/_message.py | 264 +++++++++++++++++++++-------------------- fbchat/_plan.py | 39 +++--- fbchat/_quick_reply.py | 2 +- fbchat/_sticker.py | 31 ++--- tests/test_plan.py | 80 ++++++------- tests/test_sticker.py | 8 +- 10 files changed, 278 insertions(+), 266 deletions(-) diff --git a/fbchat/_attachment.py b/fbchat/_attachment.py index be24101..a10c027 100644 --- a/fbchat/_attachment.py +++ b/fbchat/_attachment.py @@ -43,8 +43,19 @@ class ShareAttachment(Attachment): def _from_graphql(cls, data): from . import _file + image = None + original_image_url = None + media = data.get("media") + if media and media.get("image"): + image = Image._from_uri(media["image"]) + original_image_url = ( + _util.get_url_parameter(image.url, "url") + if "/safe_image.php" in image.url + else image.url + ) + url = data.get("url") - rtn = cls( + return cls( uid=data.get("deduplication_key"), author=data["target"]["actors"][0]["id"] if data["target"].get("actors") @@ -58,18 +69,10 @@ class ShareAttachment(Attachment): if data.get("description") else None, source=data["source"].get("text") if data.get("source") else None, + image=image, + original_image_url=original_image_url, attachments=[ _file.graphql_to_subattachment(attachment) for attachment in data.get("subattachments") ], ) - media = data.get("media") - if media and media.get("image"): - image = media["image"] - rtn.image = Image._from_uri(image) - rtn.original_image_url = ( - _util.get_url_parameter(rtn.image.url, "url") - if "/safe_image.php" in rtn.image.url - else rtn.image.url - ) - return rtn diff --git a/fbchat/_client.py b/fbchat/_client.py index 20a0733..47b23ed 100644 --- a/fbchat/_client.py +++ b/fbchat/_client.py @@ -686,22 +686,14 @@ class Client: if j.get("message_thread") is None: raise FBchatException("Could not fetch thread {}: {}".format(thread_id, j)) + read_receipts = j["message_thread"]["read_receipts"]["nodes"] + messages = [ - Message._from_graphql(message) + Message._from_graphql(message, read_receipts) for message in j["message_thread"]["messages"]["nodes"] ] messages.reverse() - read_receipts = j["message_thread"]["read_receipts"]["nodes"] - - for message in messages: - for receipt in read_receipts: - if ( - _util.millis_to_datetime(int(receipt["watermark"])) - >= message.created_at - ): - message.read_by.append(receipt["actor"]["id"]) - return messages def fetch_thread_list( @@ -1009,11 +1001,16 @@ class Client: Raises: FBchatException: If request failed """ - quick_reply.is_response = True if isinstance(quick_reply, QuickReplyText): - return self.send( - Message(text=quick_reply.title, quick_replies=[quick_reply]) + new = QuickReplyText( + payload=quick_reply.payload, + external_payload=quick_reply.external_payload, + data=quick_reply.data, + is_response=True, + title=quick_reply.title, + image_url=quick_reply.image_url, ) + return self.send(Message(text=quick_reply.title, quick_replies=[new])) elif isinstance(quick_reply, QuickReplyLocation): if not isinstance(payload, LocationAttachment): raise TypeError( @@ -1023,17 +1020,23 @@ class Client: payload, thread_id=thread_id, thread_type=thread_type ) elif isinstance(quick_reply, QuickReplyEmail): - if not payload: - payload = self.get_emails()[0] - quick_reply.external_payload = quick_reply.payload - quick_reply.payload = payload - return self.send(Message(text=payload, quick_replies=[quick_reply])) + new = QuickReplyEmail( + payload=payload if payload else self.get_emails()[0], + external_payload=quick_reply.payload, + data=quick_reply.data, + is_response=True, + image_url=quick_reply.image_url, + ) + return self.send(Message(text=payload, quick_replies=[new])) elif isinstance(quick_reply, QuickReplyPhoneNumber): - if not payload: - payload = self.get_phone_numbers()[0] - quick_reply.external_payload = quick_reply.payload - quick_reply.payload = payload - return self.send(Message(text=payload, quick_replies=[quick_reply])) + new = QuickReplyPhoneNumber( + payload=payload if payload else self.get_phone_numbers()[0], + external_payload=quick_reply.payload, + data=quick_reply.data, + is_response=True, + image_url=quick_reply.image_url, + ) + return self.send(Message(text=payload, quick_replies=[new])) def unsend(self, mid): """Unsend message by it's ID (removes it for everyone). @@ -2533,9 +2536,8 @@ class Client: i = d["deltaMessageReply"] metadata = i["message"]["messageMetadata"] thread_id, thread_type = get_thread_id_and_thread_type(metadata) - message = Message._from_reply(i["message"]) - message.replied_to = Message._from_reply(i["repliedToMessage"]) - message.reply_to_id = message.replied_to.uid + replied_to = Message._from_reply(i["repliedToMessage"]) + message = Message._from_reply(i["message"], replied_to) self.on_message( mid=message.uid, author_id=message.author, diff --git a/fbchat/_file.py b/fbchat/_file.py index 90ae20c..ef0b8af 100644 --- a/fbchat/_file.py +++ b/fbchat/_file.py @@ -18,9 +18,10 @@ class FileAttachment(Attachment): is_malicious = attr.ib(None) @classmethod - def _from_graphql(cls, data): + def _from_graphql(cls, data, size=None): return cls( url=data.get("url"), + size=size, name=data.get("filename"), is_malicious=data.get("is_malicious"), uid=data.get("message_file_fbid"), @@ -130,8 +131,9 @@ class VideoAttachment(Attachment): large_image = attr.ib(None) @classmethod - def _from_graphql(cls, data): + def _from_graphql(cls, data, size=None): return cls( + size=size, width=data.get("original_dimensions", {}).get("width"), height=data.get("original_dimensions", {}).get("height"), duration=_util.millis_to_timedelta(data.get("playable_duration_in_ms")), @@ -165,16 +167,16 @@ class VideoAttachment(Attachment): ) -def graphql_to_attachment(data): +def graphql_to_attachment(data, size=None): _type = data["__typename"] if _type in ["MessageImage", "MessageAnimatedImage"]: return ImageAttachment._from_graphql(data) elif _type == "MessageVideo": - return VideoAttachment._from_graphql(data) + return VideoAttachment._from_graphql(data, size=size) elif _type == "MessageAudio": return AudioAttachment._from_graphql(data) elif _type == "MessageFile": - return FileAttachment._from_graphql(data) + return FileAttachment._from_graphql(data, size=size) return Attachment(uid=data.get("legacy_attachment_id")) diff --git a/fbchat/_location.py b/fbchat/_location.py index 95bda96..28f5be7 100644 --- a/fbchat/_location.py +++ b/fbchat/_location.py @@ -31,17 +31,17 @@ class LocationAttachment(Attachment): address = None except ValueError: latitude, longitude = None, None - rtn = cls( + + return cls( uid=int(data["deduplication_key"]), latitude=latitude, longitude=longitude, + image=Image._from_uri_or_none(data["media"].get("image")) + if data.get("media") + else None, + url=url, address=address, ) - media = data.get("media") - if media and media.get("image"): - rtn.image = Image._from_uri(media["image"]) - rtn.url = url - return rtn @attr.s @@ -73,7 +73,13 @@ class LiveLocationAttachment(LocationAttachment): @classmethod def _from_graphql(cls, data): target = data["target"] - rtn = cls( + + image = None + media = data.get("media") + if media and media.get("image"): + image = Image._from_uri(media["image"]) + + return cls( uid=int(target["live_location_id"]), latitude=target["coordinate"]["latitude"] if target.get("coordinate") @@ -81,12 +87,9 @@ class LiveLocationAttachment(LocationAttachment): longitude=target["coordinate"]["longitude"] if target.get("coordinate") else None, + image=image, + url=data.get("url"), name=data["title_with_entities"]["text"], expires_at=_util.seconds_to_datetime(target.get("expiration_time")), is_expired=target.get("is_expired"), ) - media = data.get("media") - if media and media.get("image"): - rtn.image = Image._from_uri(media["image"]) - rtn.url = data.get("url") - return rtn diff --git a/fbchat/_message.py b/fbchat/_message.py index c4ab144..8546d25 100644 --- a/fbchat/_message.py +++ b/fbchat/_message.py @@ -139,8 +139,7 @@ class Message: ) offset += len(name) - message = cls(text=result, mentions=mentions) - return message + return cls(text=result, mentions=mentions) @staticmethod def _get_forwarded_from_tags(tags): @@ -197,14 +196,43 @@ class Message: return data + @staticmethod + def _parse_quick_replies(data): + if data: + data = json.loads(data).get("quick_replies") + if isinstance(data, list): + return [_quick_reply.graphql_to_quick_reply(q) for q in data] + elif isinstance(data, dict): + return [_quick_reply.graphql_to_quick_reply(data, is_response=True)] + return [] + @classmethod - def _from_graphql(cls, data): + def _from_graphql(cls, data, read_receipts=None): if data.get("message_sender") is None: data["message_sender"] = {} if data.get("message") is None: data["message"] = {} tags = data.get("tags_list") - rtn = cls( + + created_at = _util.millis_to_datetime(int(data.get("timestamp_precise"))) + + attachments = [ + _file.graphql_to_attachment(attachment) + for attachment in data["blob_attachments"] or () + ] + unsent = False + if data.get("extensible_attachment") is not None: + attachment = graphql_to_extensible_attachment(data["extensible_attachment"]) + if isinstance(attachment, _attachment.UnsentMessage): + unsent = True + elif attachment: + attachments.append(attachment) + + replied_to = None + if data.get("replied_to_message"): + replied_to = cls._from_graphql(data["replied_to_message"]["message"]) + + return cls( text=data["message"].get("text"), mentions=[ Mention( @@ -215,107 +243,80 @@ class Message: for m in data["message"].get("ranges") or () ], emoji_size=EmojiSize._from_tags(tags), + uid=str(data["message_id"]), + author=str(data["message_sender"]["id"]), + created_at=created_at, + is_read=not data["unread"] if data.get("unread") is not None else None, + read_by=[ + receipt["actor"]["id"] + for receipt in read_receipts or () + if _util.millis_to_datetime(int(receipt["watermark"])) >= created_at + ], + reactions={ + str(r["user"]["id"]): MessageReaction._extend_if_invalid(r["reaction"]) + for r in data["message_reactions"] + }, sticker=_sticker.Sticker._from_graphql(data.get("sticker")), + attachments=attachments, + quick_replies=cls._parse_quick_replies(data.get("platform_xmd_encoded")), + unsent=unsent, + reply_to_id=replied_to.uid if replied_to else None, + replied_to=replied_to, + forwarded=cls._get_forwarded_from_tags(tags), ) - rtn.forwarded = cls._get_forwarded_from_tags(tags) - rtn.uid = str(data["message_id"]) - rtn.author = str(data["message_sender"]["id"]) - rtn.created_at = _util.millis_to_datetime(int(data.get("timestamp_precise"))) - rtn.unsent = False - if data.get("unread") is not None: - rtn.is_read = not data["unread"] - rtn.reactions = { - str(r["user"]["id"]): MessageReaction._extend_if_invalid(r["reaction"]) - for r in data["message_reactions"] - } - if data.get("blob_attachments") is not None: - rtn.attachments = [ - _file.graphql_to_attachment(attachment) - for attachment in data["blob_attachments"] - ] - if data.get("platform_xmd_encoded"): - quick_replies = json.loads(data["platform_xmd_encoded"]).get( - "quick_replies" - ) - if isinstance(quick_replies, list): - rtn.quick_replies = [ - _quick_reply.graphql_to_quick_reply(q) for q in quick_replies - ] - elif isinstance(quick_replies, dict): - rtn.quick_replies = [ - _quick_reply.graphql_to_quick_reply(quick_replies, is_response=True) - ] - if data.get("extensible_attachment") is not None: - attachment = graphql_to_extensible_attachment(data["extensible_attachment"]) - if isinstance(attachment, _attachment.UnsentMessage): - rtn.unsent = True - elif attachment: - rtn.attachments.append(attachment) - if data.get("replied_to_message") is not None: - rtn.replied_to = cls._from_graphql(data["replied_to_message"]["message"]) - rtn.reply_to_id = rtn.replied_to.uid - return rtn @classmethod - def _from_reply(cls, data): + def _from_reply(cls, data, replied_to=None): tags = data["messageMetadata"].get("tags") - rtn = cls( + metadata = data.get("messageMetadata", {}) + + attachments = [] + unsent = False + sticker = None + for attachment in data.get("attachments") or (): + attachment = json.loads(attachment["mercuryJSON"]) + if attachment.get("blob_attachment"): + attachments.append( + _file.graphql_to_attachment(attachment["blob_attachment"]) + ) + if attachment.get("extensible_attachment"): + extensible_attachment = graphql_to_extensible_attachment( + attachment["extensible_attachment"] + ) + if isinstance(extensible_attachment, _attachment.UnsentMessage): + unsent = True + else: + attachments.append(extensible_attachment) + if attachment.get("sticker_attachment"): + sticker = _sticker.Sticker._from_graphql( + attachment["sticker_attachment"] + ) + + return cls( text=data.get("body"), mentions=[ Mention(m.get("i"), offset=m.get("o"), length=m.get("l")) for m in json.loads(data.get("data", {}).get("prng", "[]")) ], emoji_size=EmojiSize._from_tags(tags), + uid=metadata.get("messageId"), + author=str(metadata.get("actorFbId")), + created_at=_util.millis_to_datetime(metadata.get("timestamp")), + sticker=sticker, + attachments=attachments, + quick_replies=cls._parse_quick_replies(data.get("platform_xmd_encoded")), + unsent=unsent, + reply_to_id=replied_to.uid if replied_to else None, + replied_to=replied_to, + forwarded=cls._get_forwarded_from_tags(tags), ) - metadata = data.get("messageMetadata", {}) - rtn.forwarded = cls._get_forwarded_from_tags(tags) - rtn.uid = metadata.get("messageId") - rtn.author = str(metadata.get("actorFbId")) - rtn.created_at = _util.millis_to_datetime(metadata.get("timestamp")) - rtn.unsent = False - if data.get("data", {}).get("platform_xmd"): - quick_replies = json.loads(data["data"]["platform_xmd"]).get( - "quick_replies" - ) - if isinstance(quick_replies, list): - rtn.quick_replies = [ - _quick_reply.graphql_to_quick_reply(q) for q in quick_replies - ] - elif isinstance(quick_replies, dict): - rtn.quick_replies = [ - _quick_reply.graphql_to_quick_reply(quick_replies, is_response=True) - ] - if data.get("attachments") is not None: - for attachment in data["attachments"]: - attachment = json.loads(attachment["mercuryJSON"]) - if attachment.get("blob_attachment"): - rtn.attachments.append( - _file.graphql_to_attachment(attachment["blob_attachment"]) - ) - if attachment.get("extensible_attachment"): - extensible_attachment = graphql_to_extensible_attachment( - attachment["extensible_attachment"] - ) - if isinstance(extensible_attachment, _attachment.UnsentMessage): - rtn.unsent = True - else: - rtn.attachments.append(extensible_attachment) - if attachment.get("sticker_attachment"): - rtn.sticker = _sticker.Sticker._from_graphql( - attachment["sticker_attachment"] - ) - return rtn @classmethod def _from_pull(cls, data, mid=None, tags=None, author=None, created_at=None): - rtn = cls(text=data.get("body")) - rtn.uid = mid - rtn.author = author - rtn.created_at = created_at - + mentions = [] if data.get("data") and data["data"].get("prng"): try: - rtn.mentions = [ + mentions = [ Mention( str(mention.get("i")), offset=mention.get("o"), @@ -326,50 +327,53 @@ class Message: except Exception: log.exception("An exception occured while reading attachments") - if data.get("attachments"): - try: - for a in data["attachments"]: - mercury = a["mercury"] - if mercury.get("blob_attachment"): - image_metadata = a.get("imageMetadata", {}) - attach_type = mercury["blob_attachment"]["__typename"] - attachment = _file.graphql_to_attachment( - mercury["blob_attachment"] - ) - - if attach_type in [ - "MessageFile", - "MessageVideo", - "MessageAudio", - ]: - # TODO: Add more data here for audio files - attachment.size = int(a["fileSize"]) - rtn.attachments.append(attachment) - - elif mercury.get("sticker_attachment"): - rtn.sticker = _sticker.Sticker._from_graphql( - mercury["sticker_attachment"] - ) - - elif mercury.get("extensible_attachment"): - attachment = graphql_to_extensible_attachment( - mercury["extensible_attachment"] - ) - if isinstance(attachment, _attachment.UnsentMessage): - rtn.unsent = True - elif attachment: - rtn.attachments.append(attachment) - - except Exception: - log.exception( - "An exception occured while reading attachments: {}".format( - data["attachments"] + attachments = [] + unsent = False + sticker = None + try: + for a in data.get("attachments") or (): + mercury = a["mercury"] + if mercury.get("blob_attachment"): + image_metadata = a.get("imageMetadata", {}) + attach_type = mercury["blob_attachment"]["__typename"] + attachment = _file.graphql_to_attachment( + mercury["blob_attachment"], a["fileSize"] ) - ) + attachments.append(attachment) - rtn.emoji_size = EmojiSize._from_tags(tags) - rtn.forwarded = cls._get_forwarded_from_tags(tags) - return rtn + elif mercury.get("sticker_attachment"): + sticker = _sticker.Sticker._from_graphql( + mercury["sticker_attachment"] + ) + + elif mercury.get("extensible_attachment"): + attachment = graphql_to_extensible_attachment( + mercury["extensible_attachment"] + ) + if isinstance(attachment, _attachment.UnsentMessage): + unsent = True + elif attachment: + attachments.append(attachment) + + except Exception: + log.exception( + "An exception occured while reading attachments: {}".format( + data["attachments"] + ) + ) + + return cls( + text=data.get("body"), + mentions=mentions, + emoji_size=EmojiSize._from_tags(tags), + uid=mid, + author=author, + created_at=created_at, + sticker=sticker, + attachments=attachments, + unsent=unsent, + forwarded=cls._get_forwarded_from_tags(tags), + ) def graphql_to_extensible_attachment(data): diff --git a/fbchat/_plan.py b/fbchat/_plan.py index fdc23fc..cb0a0c7 100644 --- a/fbchat/_plan.py +++ b/fbchat/_plan.py @@ -58,44 +58,41 @@ class Plan: @classmethod def _from_pull(cls, data): - rtn = cls( + return cls( + uid=data.get("event_id"), time=_util.seconds_to_datetime(int(data.get("event_time"))), title=data.get("event_title"), location=data.get("event_location_name"), location_id=data.get("event_location_id"), + author_id=data.get("event_creator_id"), + guests={ + x["node"]["id"]: GuestStatus[x["guest_list_state"]] + for x in json.loads(data["guest_state_list"]) + }, ) - rtn.uid = data.get("event_id") - rtn.author_id = data.get("event_creator_id") - rtn.guests = { - x["node"]["id"]: GuestStatus[x["guest_list_state"]] - for x in json.loads(data["guest_state_list"]) - } - return rtn @classmethod def _from_fetch(cls, data): - rtn = cls( + return cls( + uid=data.get("oid"), time=_util.seconds_to_datetime(data.get("event_time")), title=data.get("title"), location=data.get("location_name"), location_id=str(data["location_id"]) if data.get("location_id") else None, + author_id=data.get("creator_id"), + guests={id_: GuestStatus[s] for id_, s in data["event_members"].items()}, ) - rtn.uid = data.get("oid") - rtn.author_id = data.get("creator_id") - rtn.guests = {id_: GuestStatus[s] for id_, s in data["event_members"].items()} - return rtn @classmethod def _from_graphql(cls, data): - rtn = cls( + return cls( + uid=data.get("id"), time=_util.seconds_to_datetime(data.get("time")), title=data.get("event_title"), location=data.get("location_name"), + author_id=data["lightweight_event_creator"].get("id"), + guests={ + x["node"]["id"]: GuestStatus[x["guest_list_state"]] + for x in data["event_reminder_members"]["edges"] + }, ) - rtn.uid = data.get("id") - rtn.author_id = data["lightweight_event_creator"].get("id") - rtn.guests = { - x["node"]["id"]: GuestStatus[x["guest_list_state"]] - for x in data["event_reminder_members"]["edges"] - } - return rtn diff --git a/fbchat/_quick_reply.py b/fbchat/_quick_reply.py index 802f473..653f31e 100644 --- a/fbchat/_quick_reply.py +++ b/fbchat/_quick_reply.py @@ -9,7 +9,7 @@ class QuickReply: #: Payload of the quick reply payload = attr.ib(None) #: External payload for responses - external_payload = attr.ib(None, init=False) + external_payload = attr.ib(None) #: Additional data data = attr.ib(None) #: Whether it's a response for a quick reply diff --git a/fbchat/_sticker.py b/fbchat/_sticker.py index 6c781d2..312b759 100644 --- a/fbchat/_sticker.py +++ b/fbchat/_sticker.py @@ -33,17 +33,20 @@ class Sticker(Attachment): def _from_graphql(cls, data): if not data: return None - self = cls(uid=data["id"]) - if data.get("pack"): - self.pack = data["pack"].get("id") - if data.get("sprite_image"): - self.is_animated = True - self.medium_sprite_image = data["sprite_image"].get("uri") - self.large_sprite_image = data["sprite_image_2x"].get("uri") - self.frames_per_row = data.get("frames_per_row") - self.frames_per_col = data.get("frames_per_column") - self.frame_rate = data.get("frame_rate") - self.image = Image._from_url_or_none(data) - if data.get("label"): - self.label = data["label"] - return self + + return cls( + uid=data["id"], + pack=data["pack"].get("id") if data.get("pack") else None, + is_animated=bool(data.get("sprite_image")), + medium_sprite_image=data["sprite_image"].get("uri") + if data.get("sprite_image") + else None, + large_sprite_image=data["sprite_image_2x"].get("uri") + if data.get("sprite_image_2x") + else None, + frames_per_row=data.get("frames_per_row"), + frames_per_col=data.get("frames_per_column"), + frame_rate=data.get("frame_rate"), + image=Image._from_url_or_none(data), + label=data["label"] if data.get("label") else None, + ) diff --git a/tests/test_plan.py b/tests/test_plan.py index f483744..4baeda4 100644 --- a/tests/test_plan.py +++ b/tests/test_plan.py @@ -3,13 +3,16 @@ from fbchat._plan import GuestStatus, Plan def test_plan_properties(): - plan = Plan(time=..., title=...) - plan.guests = { - "1234": GuestStatus.INVITED, - "2345": GuestStatus.INVITED, - "3456": GuestStatus.GOING, - "4567": GuestStatus.DECLINED, - } + plan = Plan( + time=..., + title=..., + guests={ + "1234": GuestStatus.INVITED, + "2345": GuestStatus.INVITED, + "3456": GuestStatus.GOING, + "4567": GuestStatus.DECLINED, + }, + ) assert set(plan.invited) == {"1234", "2345"} assert plan.going == ["3456"] assert plan.declined == ["4567"] @@ -32,19 +35,18 @@ def test_plan_from_pull(): '{"guest_list_state":"GOING","node":{"id":"4567"}}]' ), } - plan = Plan( + assert Plan( + uid="1111", time=datetime.datetime(2017, 7, 14, 2, 40, tzinfo=datetime.timezone.utc), title="abc", - ) - plan.uid = "1111" - plan.author_id = "1234" - plan.guests = { - "1234": GuestStatus.INVITED, - "2356": GuestStatus.INVITED, - "3456": GuestStatus.DECLINED, - "4567": GuestStatus.GOING, - } - assert plan == Plan._from_pull(data) + author_id="1234", + guests={ + "1234": GuestStatus.INVITED, + "2356": GuestStatus.INVITED, + "3456": GuestStatus.DECLINED, + "4567": GuestStatus.GOING, + }, + ) == Plan._from_pull(data) def test_plan_from_fetch(): @@ -90,21 +92,20 @@ def test_plan_from_fetch(): "4567": "GOING", }, } - plan = Plan( + assert Plan( + uid=1111, time=datetime.datetime(2017, 7, 14, 2, 40, tzinfo=datetime.timezone.utc), title="abc", location="", location_id="", - ) - plan.uid = 1111 - plan.author_id = 1234 - plan.guests = { - "1234": GuestStatus.INVITED, - "2356": GuestStatus.INVITED, - "3456": GuestStatus.DECLINED, - "4567": GuestStatus.GOING, - } - assert plan == Plan._from_fetch(data) + author_id=1234, + guests={ + "1234": GuestStatus.INVITED, + "2356": GuestStatus.INVITED, + "3456": GuestStatus.DECLINED, + "4567": GuestStatus.GOING, + }, + ) == Plan._from_fetch(data) def test_plan_from_graphql(): @@ -133,18 +134,17 @@ def test_plan_from_graphql(): ] }, } - plan = Plan( + assert Plan( time=datetime.datetime(2017, 7, 14, 2, 40, tzinfo=datetime.timezone.utc), title="abc", location="", location_id="", - ) - plan.uid = "1111" - plan.author_id = "1234" - plan.guests = { - "1234": GuestStatus.INVITED, - "2356": GuestStatus.INVITED, - "3456": GuestStatus.DECLINED, - "4567": GuestStatus.GOING, - } - assert plan == Plan._from_graphql(data) + uid="1111", + author_id="1234", + guests={ + "1234": GuestStatus.INVITED, + "2356": GuestStatus.INVITED, + "3456": GuestStatus.DECLINED, + "4567": GuestStatus.GOING, + }, + ) == Plan._from_graphql(data) diff --git a/tests/test_sticker.py b/tests/test_sticker.py index b5aedec..80c2cc5 100644 --- a/tests/test_sticker.py +++ b/tests/test_sticker.py @@ -16,11 +16,9 @@ def test_from_graphql_normal(): uid="369239383222810", pack="227877430692340", is_animated=False, - medium_sprite_image=None, - large_sprite_image=None, - frames_per_row=None, - frames_per_col=None, - frame_rate=None, + frames_per_row=1, + frames_per_col=1, + frame_rate=83, image=fbchat.Image( url="https://scontent-arn2-1.xx.fbcdn.net/v/redacted.png", width=274, From 523c320c0834f6443d3db1e5849001820bf1872d Mon Sep 17 00:00:00 2001 From: Mads Marquart Date: Wed, 11 Dec 2019 13:52:31 +0100 Subject: [PATCH 4/9] Make models use slots --- fbchat/_attachment.py | 8 ++++---- fbchat/_core.py | 3 +++ fbchat/_file.py | 10 +++++----- fbchat/_group.py | 4 ++-- fbchat/_location.py | 6 +++--- fbchat/_message.py | 6 +++--- fbchat/_page.py | 4 ++-- fbchat/_plan.py | 4 ++-- fbchat/_poll.py | 5 +++-- fbchat/_quick_reply.py | 11 ++++++----- fbchat/_state.py | 4 ++-- fbchat/_sticker.py | 4 ++-- fbchat/_thread.py | 4 ++-- fbchat/_user.py | 4 ++-- 14 files changed, 41 insertions(+), 36 deletions(-) diff --git a/fbchat/_attachment.py b/fbchat/_attachment.py index a10c027..847377e 100644 --- a/fbchat/_attachment.py +++ b/fbchat/_attachment.py @@ -1,9 +1,9 @@ import attr -from ._core import Image +from ._core import attrs_default, Image from . import _util -@attr.s +@attrs_default class Attachment: """Represents a Facebook attachment.""" @@ -11,12 +11,12 @@ class Attachment: uid = attr.ib(None) -@attr.s +@attrs_default class UnsentMessage(Attachment): """Represents an unsent message attachment.""" -@attr.s +@attrs_default class ShareAttachment(Attachment): """Represents a shared item (e.g. URL) attachment.""" diff --git a/fbchat/_core.py b/fbchat/_core.py index afc90bd..ae7ecb1 100644 --- a/fbchat/_core.py +++ b/fbchat/_core.py @@ -4,6 +4,9 @@ import aenum log = logging.getLogger("fbchat") +#: Default attrs settings for classes +attrs_default = attr.s(slots=True) # TODO: Add kw_only=True + class Enum(aenum.Enum): """Used internally by ``fbchat`` to support enumerations""" diff --git a/fbchat/_file.py b/fbchat/_file.py index ef0b8af..9d255cd 100644 --- a/fbchat/_file.py +++ b/fbchat/_file.py @@ -1,10 +1,10 @@ import attr -from ._core import Image +from ._core import attrs_default, Image from . import _util from ._attachment import Attachment -@attr.s +@attrs_default class FileAttachment(Attachment): """Represents a file that has been sent as a Facebook attachment.""" @@ -28,7 +28,7 @@ class FileAttachment(Attachment): ) -@attr.s +@attrs_default class AudioAttachment(Attachment): """Represents an audio file that has been sent as a Facebook attachment.""" @@ -51,7 +51,7 @@ class AudioAttachment(Attachment): ) -@attr.s +@attrs_default class ImageAttachment(Attachment): """Represents an image that has been sent as a Facebook attachment. @@ -108,7 +108,7 @@ class ImageAttachment(Attachment): ) -@attr.s +@attrs_default class VideoAttachment(Attachment): """Represents a video that has been sent as a Facebook attachment.""" diff --git a/fbchat/_group.py b/fbchat/_group.py index c0ef982..bb73b2c 100644 --- a/fbchat/_group.py +++ b/fbchat/_group.py @@ -1,10 +1,10 @@ import attr -from ._core import Image +from ._core import attrs_default, Image from . import _util, _plan from ._thread import ThreadType, Thread -@attr.s +@attrs_default class Group(Thread): """Represents a Facebook group. Inherits `Thread`.""" diff --git a/fbchat/_location.py b/fbchat/_location.py index 28f5be7..e94ddb6 100644 --- a/fbchat/_location.py +++ b/fbchat/_location.py @@ -1,10 +1,10 @@ import attr -from ._core import Image +from ._core import attrs_default, Image from ._attachment import Attachment from . import _util -@attr.s +@attrs_default class LocationAttachment(Attachment): """Represents a user location. @@ -44,7 +44,7 @@ class LocationAttachment(Attachment): ) -@attr.s +@attrs_default class LiveLocationAttachment(LocationAttachment): """Represents a live user location.""" diff --git a/fbchat/_message.py b/fbchat/_message.py index 8546d25..ef0c7c8 100644 --- a/fbchat/_message.py +++ b/fbchat/_message.py @@ -1,7 +1,7 @@ import attr import json from string import Formatter -from ._core import log, Enum +from ._core import log, attrs_default, Enum from . import _util, _attachment, _location, _file, _quick_reply, _sticker @@ -42,7 +42,7 @@ class MessageReaction(Enum): NO = "👎" -@attr.s +@attrs_default class Mention: """Represents a ``@mention``.""" @@ -54,7 +54,7 @@ class Mention: length = attr.ib(10) -@attr.s +@attrs_default class Message: """Represents a Facebook message.""" diff --git a/fbchat/_page.py b/fbchat/_page.py index 53edf6c..aceb88b 100644 --- a/fbchat/_page.py +++ b/fbchat/_page.py @@ -1,10 +1,10 @@ import attr -from ._core import Image +from ._core import attrs_default, Image from . import _plan from ._thread import ThreadType, Thread -@attr.s +@attrs_default class Page(Thread): """Represents a Facebook page. Inherits `Thread`.""" diff --git a/fbchat/_plan.py b/fbchat/_plan.py index cb0a0c7..59e3941 100644 --- a/fbchat/_plan.py +++ b/fbchat/_plan.py @@ -1,6 +1,6 @@ import attr import json -from ._core import Enum +from ._core import attrs_default, Enum from . import _util @@ -10,7 +10,7 @@ class GuestStatus(Enum): DECLINED = 3 -@attr.s +@attrs_default class Plan: """Represents a plan.""" diff --git a/fbchat/_poll.py b/fbchat/_poll.py index 86a7c14..3273484 100644 --- a/fbchat/_poll.py +++ b/fbchat/_poll.py @@ -1,7 +1,8 @@ import attr +from ._core import attrs_default -@attr.s +@attrs_default class Poll: """Represents a poll.""" @@ -24,7 +25,7 @@ class Poll: ) -@attr.s +@attrs_default class PollOption: """Represents a poll option.""" diff --git a/fbchat/_quick_reply.py b/fbchat/_quick_reply.py index 653f31e..eb5ae86 100644 --- a/fbchat/_quick_reply.py +++ b/fbchat/_quick_reply.py @@ -1,8 +1,9 @@ import attr +from ._core import attrs_default from ._attachment import Attachment -@attr.s +@attrs_default class QuickReply: """Represents a quick reply.""" @@ -16,7 +17,7 @@ class QuickReply: is_response = attr.ib(False) -@attr.s +@attrs_default class QuickReplyText(QuickReply): """Represents a text quick reply.""" @@ -28,7 +29,7 @@ class QuickReplyText(QuickReply): _type = "text" -@attr.s +@attrs_default class QuickReplyLocation(QuickReply): """Represents a location quick reply (Doesn't work on mobile).""" @@ -36,7 +37,7 @@ class QuickReplyLocation(QuickReply): _type = "location" -@attr.s +@attrs_default class QuickReplyPhoneNumber(QuickReply): """Represents a phone number quick reply (Doesn't work on mobile).""" @@ -46,7 +47,7 @@ class QuickReplyPhoneNumber(QuickReply): _type = "user_phone_number" -@attr.s +@attrs_default class QuickReplyEmail(QuickReply): """Represents an email quick reply (Doesn't work on mobile).""" diff --git a/fbchat/_state.py b/fbchat/_state.py index 38cf767..78092fd 100644 --- a/fbchat/_state.py +++ b/fbchat/_state.py @@ -5,7 +5,7 @@ import requests import random import urllib.parse -from ._core import log +from ._core import log, attrs_default from . import _graphql, _util, _exception FB_DTSG_REGEX = re.compile(r'name="fb_dtsg" value="(.*?)"') @@ -98,7 +98,7 @@ def _2fa_helper(session, code, r): return r -@attr.s(slots=True) # TODO i Python 3: Add kw_only=True +@attrs_default # TODO i Python 3: Add kw_only=True class State: """Stores and manages state required for most Facebook requests.""" diff --git a/fbchat/_sticker.py b/fbchat/_sticker.py index 312b759..7189232 100644 --- a/fbchat/_sticker.py +++ b/fbchat/_sticker.py @@ -1,9 +1,9 @@ import attr -from ._core import Image +from ._core import attrs_default, Image from ._attachment import Attachment -@attr.s +@attrs_default class Sticker(Attachment): """Represents a Facebook sticker that has been sent to a thread as an attachment.""" diff --git a/fbchat/_thread.py b/fbchat/_thread.py index 6540a24..b517d1b 100644 --- a/fbchat/_thread.py +++ b/fbchat/_thread.py @@ -1,5 +1,5 @@ import attr -from ._core import Enum, Image +from ._core import attrs_default, Enum, Image class ThreadType(Enum): @@ -67,7 +67,7 @@ class ThreadColor(Enum): return cls._extend_if_invalid(value) -@attr.s +@attrs_default class Thread: """Represents a Facebook thread.""" diff --git a/fbchat/_user.py b/fbchat/_user.py index 6e7e698..18dd773 100644 --- a/fbchat/_user.py +++ b/fbchat/_user.py @@ -1,5 +1,5 @@ import attr -from ._core import Enum, Image +from ._core import attrs_default, Enum, Image from . import _util, _plan from ._thread import ThreadType, Thread @@ -41,7 +41,7 @@ class TypingStatus(Enum): TYPING = 1 -@attr.s +@attrs_default class User(Thread): """Represents a Facebook user. Inherits `Thread`.""" From 91d40555453e4d954fbed8382e82e5bbdfec4106 Mon Sep 17 00:00:00 2001 From: Mads Marquart Date: Wed, 11 Dec 2019 14:57:40 +0100 Subject: [PATCH 5/9] Make models use kw_only (on Python > 3.5) --- fbchat/_client.py | 10 +++++----- fbchat/_core.py | 8 ++++++-- fbchat/_group.py | 2 +- fbchat/_message.py | 6 +++--- fbchat/_page.py | 2 +- fbchat/_state.py | 2 +- fbchat/_user.py | 6 +++--- tests/test_plans.py | 6 +++--- tests/test_polls.py | 20 ++++++++++---------- tests/utils.py | 10 +++++++--- 10 files changed, 40 insertions(+), 32 deletions(-) diff --git a/fbchat/_client.py b/fbchat/_client.py index 47b23ed..a2841aa 100644 --- a/fbchat/_client.py +++ b/fbchat/_client.py @@ -956,7 +956,7 @@ class Client: Raises: FBchatException: If request failed """ - thread = thread_type._to_class()(thread_id) + thread = thread_type._to_class()(uid=thread_id) data = thread._to_send_data() data.update(message._to_send_data()) return self._do_send_request(data) @@ -975,7 +975,7 @@ class Client: Raises: FBchatException: If request failed """ - thread = thread_type._to_class()(thread_id) + thread = thread_type._to_class()(uid=thread_id) data = thread._to_send_data() data["action_type"] = "ma-type:user-generated-message" data["lightweight_action_attachment[lwa_state]"] = ( @@ -1050,7 +1050,7 @@ class Client: def _send_location( self, location, current=True, message=None, thread_id=None, thread_type=None ): - thread = thread_type._to_class()(thread_id) + thread = thread_type._to_class()(uid=thread_id) data = thread._to_send_data() if message is not None: data.update(message._to_send_data()) @@ -1118,7 +1118,7 @@ class Client: `files` should be a list of tuples, with a file's ID and mimetype. """ - thread = thread_type._to_class()(thread_id) + thread = thread_type._to_class()(uid=thread_id) data = thread._to_send_data() data.update(self._old_message(message)._to_send_data()) data["action_type"] = "ma-type:user-generated-message" @@ -1284,7 +1284,7 @@ class Client: Raises: FBchatException: If request failed """ - data = Group(thread_id)._to_send_data() + data = Group(uid=thread_id)._to_send_data() data["action_type"] = "ma-type:log-message" data["log_message_type"] = "log:subscribe" diff --git a/fbchat/_core.py b/fbchat/_core.py index ae7ecb1..1827198 100644 --- a/fbchat/_core.py +++ b/fbchat/_core.py @@ -1,11 +1,15 @@ +import sys import attr import logging import aenum log = logging.getLogger("fbchat") +# Enable kw_only if the python version supports it +kw_only = sys.version_info[:2] > (3, 5) + #: Default attrs settings for classes -attrs_default = attr.s(slots=True) # TODO: Add kw_only=True +attrs_default = attr.s(slots=True, kw_only=kw_only) class Enum(aenum.Enum): @@ -27,7 +31,7 @@ class Enum(aenum.Enum): return cls(value) -@attr.s(frozen=True, slots=True) # TODO: Add kw_only=True +@attr.s(frozen=True, slots=True, kw_only=kw_only) class Image: #: URL to the image url = attr.ib(type=str) diff --git a/fbchat/_group.py b/fbchat/_group.py index bb73b2c..eef0b0a 100644 --- a/fbchat/_group.py +++ b/fbchat/_group.py @@ -42,7 +42,7 @@ class Group(Thread): plan = _plan.Plan._from_graphql(data["event_reminders"]["nodes"][0]) return cls( - data["thread_key"]["thread_fbid"], + uid=data["thread_key"]["thread_fbid"], participants=set( [ node["messaging_actor"]["id"] diff --git a/fbchat/_message.py b/fbchat/_message.py index ef0c7c8..87c928d 100644 --- a/fbchat/_message.py +++ b/fbchat/_message.py @@ -236,7 +236,7 @@ class Message: text=data["message"].get("text"), mentions=[ Mention( - m.get("entity", {}).get("id"), + thread_id=m.get("entity", {}).get("id"), offset=m.get("offset"), length=m.get("length"), ) @@ -295,7 +295,7 @@ class Message: return cls( text=data.get("body"), mentions=[ - Mention(m.get("i"), offset=m.get("o"), length=m.get("l")) + Mention(thread_id=m.get("i"), offset=m.get("o"), length=m.get("l")) for m in json.loads(data.get("data", {}).get("prng", "[]")) ], emoji_size=EmojiSize._from_tags(tags), @@ -318,7 +318,7 @@ class Message: try: mentions = [ Mention( - str(mention.get("i")), + thread_id=str(mention.get("i")), offset=mention.get("o"), length=mention.get("l"), ) diff --git a/fbchat/_page.py b/fbchat/_page.py index aceb88b..e675df8 100644 --- a/fbchat/_page.py +++ b/fbchat/_page.py @@ -32,7 +32,7 @@ class Page(Thread): plan = _plan.Plan._from_graphql(data["event_reminders"]["nodes"][0]) return cls( - data["id"], + uid=data["id"], url=data.get("url"), city=data.get("city").get("name"), category=data.get("category_type"), diff --git a/fbchat/_state.py b/fbchat/_state.py index 78092fd..9f69a16 100644 --- a/fbchat/_state.py +++ b/fbchat/_state.py @@ -98,7 +98,7 @@ def _2fa_helper(session, code, r): return r -@attrs_default # TODO i Python 3: Add kw_only=True +@attrs_default class State: """Stores and manages state required for most Facebook requests.""" diff --git a/fbchat/_user.py b/fbchat/_user.py index 18dd773..5071c38 100644 --- a/fbchat/_user.py +++ b/fbchat/_user.py @@ -78,7 +78,7 @@ class User(Thread): plan = _plan.Plan._from_graphql(data["event_reminders"]["nodes"][0]) return cls( - data["id"], + uid=data["id"], url=data.get("url"), first_name=data.get("first_name"), last_name=data.get("last_name"), @@ -123,7 +123,7 @@ class User(Thread): plan = _plan.Plan._from_graphql(data["event_reminders"]["nodes"][0]) return cls( - user["id"], + uid=user["id"], url=user.get("url"), name=user.get("name"), first_name=first_name, @@ -144,7 +144,7 @@ class User(Thread): @classmethod def _from_all_fetch(cls, data): return cls( - data["id"], + uid=data["id"], first_name=data.get("firstName"), url=data.get("uri"), photo=Image(url=data.get("thumbSrc")), diff --git a/tests/test_plans.py b/tests/test_plans.py index 12807ee..411f576 100644 --- a/tests/test_plans.py +++ b/tests/test_plans.py @@ -10,12 +10,12 @@ pytestmark = pytest.mark.online @pytest.fixture( scope="module", params=[ - Plan(int(time()) + 100, random_hex()), + Plan(time=int(time()) + 100, title=random_hex()), pytest.param( - Plan(int(time()), random_hex()), + Plan(time=int(time()), title=random_hex()), marks=[pytest.mark.xfail(raises=FBchatFacebookError)], ), - pytest.param(Plan(0, None), marks=[pytest.mark.xfail()]), + pytest.param(Plan(time=0, title=None), marks=[pytest.mark.xfail()]), ], ) def plan_data(request, client, user, thread, catch_event, compare): diff --git a/tests/test_polls.py b/tests/test_polls.py index 94210b4..f45724e 100644 --- a/tests/test_polls.py +++ b/tests/test_polls.py @@ -13,26 +13,26 @@ pytestmark = pytest.mark.online Poll( title=random_hex(), options=[ - PollOption(random_hex(), vote=True), - PollOption(random_hex(), vote=True), + PollOption(text=random_hex(), vote=True), + PollOption(text=random_hex(), vote=True), ], ), Poll( title=random_hex(), options=[ - PollOption(random_hex(), vote=False), - PollOption(random_hex(), vote=False), + PollOption(text=random_hex(), vote=False), + PollOption(text=random_hex(), vote=False), ], ), Poll( title=random_hex(), options=[ - PollOption(random_hex(), vote=True), - PollOption(random_hex(), vote=True), - PollOption(random_hex(), vote=False), - PollOption(random_hex(), vote=False), - PollOption(random_hex()), - PollOption(random_hex()), + PollOption(text=random_hex(), vote=True), + PollOption(text=random_hex(), vote=True), + PollOption(text=random_hex(), vote=False), + PollOption(text=random_hex(), vote=False), + PollOption(text=random_hex()), + PollOption(text=random_hex()), ], ), pytest.param( diff --git a/tests/utils.py b/tests/utils.py index 93e762b..64ec01c 100644 --- a/tests/utils.py +++ b/tests/utils.py @@ -22,9 +22,13 @@ EMOJI_LIST = [ ] STICKER_LIST = [ - Sticker("767334476626295"), - pytest.param(Sticker("0"), marks=[pytest.mark.xfail(raises=FBchatFacebookError)]), - pytest.param(Sticker(None), marks=[pytest.mark.xfail(raises=FBchatFacebookError)]), + Sticker(uid="767334476626295"), + pytest.param( + Sticker(uid="0"), marks=[pytest.mark.xfail(raises=FBchatFacebookError)] + ), + pytest.param( + Sticker(uid=None), marks=[pytest.mark.xfail(raises=FBchatFacebookError)] + ), ] TEXT_LIST = [ From a7b08fefe40689cf55350a8b7284d463598b1499 Mon Sep 17 00:00:00 2001 From: Mads Marquart Date: Wed, 11 Dec 2019 16:20:38 +0100 Subject: [PATCH 6/9] Use attrs on exception classes --- fbchat/_exception.py | 40 +++++++++++++++++++++------------------- pyproject.toml | 2 +- 2 files changed, 22 insertions(+), 20 deletions(-) diff --git a/fbchat/_exception.py b/fbchat/_exception.py index b84c5b9..64632cb 100644 --- a/fbchat/_exception.py +++ b/fbchat/_exception.py @@ -1,32 +1,32 @@ +import attr + +# Not frozen, since that doesn't work in PyPy +attrs_exception = attr.s(slots=True, auto_exc=True) + + +@attrs_exception class FBchatException(Exception): """Custom exception thrown by ``fbchat``. All exceptions in the ``fbchat`` module inherits this. """ + message = attr.ib() + +@attrs_exception class FBchatFacebookError(FBchatException): + """Raised when Facebook returns an error.""" + #: The error code that Facebook returned - fb_error_code = None + fb_error_code = attr.ib(None) #: The error message that Facebook returned (In the user's own language) - fb_error_message = None + fb_error_message = attr.ib(None) #: The status code that was sent in the HTTP response (e.g. 404) (Usually only set if not successful, aka. not 200) - request_status_code = None - - def __init__( - self, - message, - fb_error_code=None, - fb_error_message=None, - request_status_code=None, - ): - super(FBchatFacebookError, self).__init__(message) - """Thrown by ``fbchat`` when Facebook returns an error""" - self.fb_error_code = str(fb_error_code) - self.fb_error_message = fb_error_message - self.request_status_code = request_status_code + request_status_code = attr.ib(None) +@attrs_exception class FBchatInvalidParameters(FBchatFacebookError): """Raised by Facebook if: @@ -36,17 +36,19 @@ class FBchatInvalidParameters(FBchatFacebookError): """ +@attrs_exception class FBchatNotLoggedIn(FBchatFacebookError): """Raised by Facebook if the client has been logged out.""" - fb_error_code = "1357001" + fb_error_code = attr.ib("1357001") +@attrs_exception class FBchatPleaseRefresh(FBchatFacebookError): """Raised by Facebook if the client has been inactive for too long. This error usually happens after 1-2 days of inactivity. """ - fb_error_code = "1357004" - fb_error_message = "Please try closing and re-opening your browser window." + fb_error_code = attr.ib("1357004") + fb_error_message = attr.ib("Please try closing and re-opening your browser window.") diff --git a/pyproject.toml b/pyproject.toml index 8613c70..fa008f4 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -14,7 +14,7 @@ maintainer-email = "madsmtm@gmail.com" home-page = "https://github.com/carpedm20/fbchat/" requires = [ "aenum~=2.0", - "attrs>=18.2", + "attrs>=19.1", "requests~=2.19", "beautifulsoup4~=4.0", ] From 1f96c624e7e232a8a71c5792a77185ef7adbdef9 Mon Sep 17 00:00:00 2001 From: Mads Marquart Date: Wed, 11 Dec 2019 14:49:53 +0100 Subject: [PATCH 7/9] Combine variously sized previews to a single key --- fbchat/_core.py | 1 + fbchat/_file.py | 70 +++++++++++----------- tests/test_attachment.py | 12 ++-- tests/test_file.py | 124 ++++++++++++++++++++------------------- 4 files changed, 110 insertions(+), 97 deletions(-) diff --git a/fbchat/_core.py b/fbchat/_core.py index 1827198..9161ef6 100644 --- a/fbchat/_core.py +++ b/fbchat/_core.py @@ -31,6 +31,7 @@ class Enum(aenum.Enum): return cls(value) +# Frozen, so that it can be used in sets @attr.s(frozen=True, slots=True, kw_only=kw_only) class Image: #: URL to the image diff --git a/fbchat/_file.py b/fbchat/_file.py index 9d255cd..1c51442 100644 --- a/fbchat/_file.py +++ b/fbchat/_file.py @@ -65,45 +65,44 @@ class ImageAttachment(Attachment): width = attr.ib(None, converter=lambda x: None if x is None else int(x)) #: Height of original image height = attr.ib(None, converter=lambda x: None if x is None else int(x)) - #: Whether the image is animated is_animated = attr.ib(None) - - #: A thumbnail of the image - thumbnail = attr.ib(None) - #: A medium preview of the image - preview = attr.ib(None) - #: A large preview of the image - large_preview = attr.ib(None) - #: An animated preview of the image (e.g. for GIFs) - animated_preview = attr.ib(None) + #: A set, containing variously sized / various types of previews of the image + previews = attr.ib(factory=set) @classmethod def _from_graphql(cls, data): + previews = { + Image._from_uri_or_none(data.get("thumbnail")), + Image._from_uri_or_none(data.get("preview") or data.get("preview_image")), + Image._from_uri_or_none(data.get("large_preview")), + Image._from_uri_or_none(data.get("animated_image")), + } + return cls( original_extension=data.get("original_extension") or (data["filename"].split("-")[0] if data.get("filename") else None), width=data.get("original_dimensions", {}).get("width"), height=data.get("original_dimensions", {}).get("height"), is_animated=data["__typename"] == "MessageAnimatedImage", - thumbnail=Image._from_uri_or_none(data.get("thumbnail")), - preview=Image._from_uri_or_none( - data.get("preview") or data.get("preview_image") - ), - large_preview=Image._from_uri_or_none(data.get("large_preview")), - animated_preview=Image._from_uri_or_none(data.get("animated_image")), + previews={p for p in previews if p}, uid=data.get("legacy_attachment_id"), ) @classmethod def _from_list(cls, data): data = data["node"] + + previews = { + Image._from_uri_or_none(data["image"]), + Image._from_uri(data["image1"]), + Image._from_uri(data["image2"]), + } + return cls( width=data["original_dimensions"].get("x"), height=data["original_dimensions"].get("y"), - thumbnail=Image._from_uri_or_none(data["image"]), - large_preview=Image._from_uri(data["image2"]), - preview=Image._from_uri(data["image1"]), + previews={p for p in previews if p}, uid=data["legacy_attachment_id"], ) @@ -122,47 +121,52 @@ class VideoAttachment(Attachment): duration = attr.ib(None) #: URL to very compressed preview video preview_url = attr.ib(None) - - #: A small preview image of the video - small_image = attr.ib(None) - #: A medium preview image of the video - medium_image = attr.ib(None) - #: A large preview image of the video - large_image = attr.ib(None) + #: A set, containing variously sized previews of the video + previews = attr.ib(factory=set) @classmethod def _from_graphql(cls, data, size=None): + previews = { + Image._from_uri_or_none(data.get("chat_image")), + Image._from_uri_or_none(data.get("inbox_image")), + Image._from_uri_or_none(data.get("large_image")), + } + return cls( size=size, width=data.get("original_dimensions", {}).get("width"), height=data.get("original_dimensions", {}).get("height"), duration=_util.millis_to_timedelta(data.get("playable_duration_in_ms")), preview_url=data.get("playable_url"), - small_image=Image._from_uri_or_none(data.get("chat_image")), - medium_image=Image._from_uri_or_none(data.get("inbox_image")), - large_image=Image._from_uri_or_none(data.get("large_image")), + previews={p for p in previews if p}, uid=data.get("legacy_attachment_id"), ) @classmethod def _from_subattachment(cls, data): media = data["media"] + image = Image._from_uri_or_none(media.get("image")) + return cls( duration=_util.millis_to_timedelta(media.get("playable_duration_in_ms")), preview_url=media.get("playable_url"), - medium_image=Image._from_uri_or_none(media.get("image")), + previews={image} if image else {}, uid=data["target"].get("video_id"), ) @classmethod def _from_list(cls, data): data = data["node"] + previews = { + Image._from_uri(data["image"]), + Image._from_uri(data["image1"]), + Image._from_uri(data["image2"]), + } + return cls( width=data["original_dimensions"].get("x"), height=data["original_dimensions"].get("y"), - small_image=Image._from_uri(data["image"]), - medium_image=Image._from_uri(data["image1"]), - large_image=Image._from_uri(data["image2"]), + previews=previews, uid=data["legacy_attachment_id"], ) diff --git a/tests/test_attachment.py b/tests/test_attachment.py index 6887f73..1d6ed5a 100644 --- a/tests/test_attachment.py +++ b/tests/test_attachment.py @@ -447,11 +447,13 @@ def test_share_with_video_subattachment(): uid="2222", duration=datetime.timedelta(seconds=24, microseconds=469000), preview_url="https://video-arn2-1.xx.fbcdn.net/v/t42.9040-2/vid.mp4", - medium_image=fbchat.Image( - url="https://scontent-arn2-1.xx.fbcdn.net/v/t15.5256-10/p180x540/1.jpg", - width=960, - height=540, - ), + previews={ + fbchat.Image( + url="https://scontent-arn2-1.xx.fbcdn.net/v/t15.5256-10/p180x540/1.jpg", + width=960, + height=540, + ) + }, ) ], uid="deadbeef123", diff --git a/tests/test_file.py b/tests/test_file.py index 5ae97d5..106bc3e 100644 --- a/tests/test_file.py +++ b/tests/test_file.py @@ -33,17 +33,19 @@ def test_imageattachment_from_list(): uid="1234", width=2833, height=1367, - thumbnail=fbchat.Image( - url="https://scontent-arn2-1.xx.fbcdn.net/v/s261x260/1.jpg" - ), - preview=fbchat.Image( - url="https://scontent-arn2-1.xx.fbcdn.net/v/2.jpg", width=960, height=463 - ), - large_preview=fbchat.Image( - url="https://scontent-arn2-1.xx.fbcdn.net/v/s2048x2048/3.jpg", - width=2048, - height=988, - ), + previews={ + fbchat.Image(url="https://scontent-arn2-1.xx.fbcdn.net/v/s261x260/1.jpg"), + fbchat.Image( + url="https://scontent-arn2-1.xx.fbcdn.net/v/2.jpg", + width=960, + height=463, + ), + fbchat.Image( + url="https://scontent-arn2-1.xx.fbcdn.net/v/s2048x2048/3.jpg", + width=2048, + height=988, + ), + }, ) == ImageAttachment._from_list({"node": data}) @@ -71,19 +73,21 @@ def test_videoattachment_from_list(): uid="1234", width=640, height=368, - small_image=fbchat.Image( - url="https://scontent-arn2-1.xx.fbcdn.net/v/t15.3394-10/p261x260/1.jpg" - ), - medium_image=fbchat.Image( - url="https://scontent-arn2-1.xx.fbcdn.net/v/t15.3394-10/2.jpg", - width=640, - height=368, - ), - large_image=fbchat.Image( - url="https://scontent-arn2-1.xx.fbcdn.net/v/t15.3394-10/3.jpg", - width=640, - height=368, - ), + previews={ + fbchat.Image( + url="https://scontent-arn2-1.xx.fbcdn.net/v/t15.3394-10/p261x260/1.jpg" + ), + fbchat.Image( + url="https://scontent-arn2-1.xx.fbcdn.net/v/t15.3394-10/2.jpg", + width=640, + height=368, + ), + fbchat.Image( + url="https://scontent-arn2-1.xx.fbcdn.net/v/t15.3394-10/3.jpg", + width=640, + height=368, + ), + }, ) == VideoAttachment._from_list({"node": data}) @@ -152,11 +156,11 @@ def test_graphql_to_attachment_image1(): "width": 128, }, "large_preview": { - "uri": "https://scontent-arn2-1.xx.fbcdn.net/v/2.png", + "uri": "https://scontent-arn2-1.xx.fbcdn.net/v/1.png", "height": 128, "width": 128, }, - "thumbnail": {"uri": "https://scontent-arn2-1.xx.fbcdn.net/v/p50x50/3.png"}, + "thumbnail": {"uri": "https://scontent-arn2-1.xx.fbcdn.net/v/p50x50/2.png"}, "photo_encodings": [], "legacy_attachment_id": "1234", "original_dimensions": {"x": 128, "y": 128}, @@ -170,15 +174,14 @@ def test_graphql_to_attachment_image1(): width=None, height=None, is_animated=False, - thumbnail=fbchat.Image( - url="https://scontent-arn2-1.xx.fbcdn.net/v/p50x50/3.png" - ), - preview=fbchat.Image( - url="https://scontent-arn2-1.xx.fbcdn.net/v/1.png", width=128, height=128 - ), - large_preview=fbchat.Image( - url="https://scontent-arn2-1.xx.fbcdn.net/v/2.png", width=128, height=128 - ), + previews={ + fbchat.Image(url="https://scontent-arn2-1.xx.fbcdn.net/v/p50x50/2.png"), + fbchat.Image( + url="https://scontent-arn2-1.xx.fbcdn.net/v/1.png", + width=128, + height=128, + ), + }, ) == graphql_to_attachment(data) @@ -207,12 +210,9 @@ def test_graphql_to_attachment_image2(): width=None, height=None, is_animated=True, - preview=fbchat.Image( - url="https://cdn.fbsbx.com/v/1.gif", width=128, height=128 - ), - animated_preview=fbchat.Image( - url="https://cdn.fbsbx.com/v/1.gif", width=128, height=128 - ), + previews={ + fbchat.Image(url="https://cdn.fbsbx.com/v/1.gif", width=128, height=128) + }, ) == graphql_to_attachment(data) @@ -249,19 +249,23 @@ def test_graphql_to_attachment_video(): height=None, duration=datetime.timedelta(seconds=6), preview_url="https://video-arn2-1.xx.fbcdn.net/v/video-4321.mp4", - small_image=fbchat.Image( - url="https://scontent-arn2-1.xx.fbcdn.net/v/s168x128/1.jpg", - width=168, - height=96, - ), - medium_image=fbchat.Image( - url="https://scontent-arn2-1.xx.fbcdn.net/v/p261x260/3.jpg", - width=452, - height=260, - ), - large_image=fbchat.Image( - url="https://scontent-arn2-1.xx.fbcdn.net/v/2.jpg", width=640, height=368 - ), + previews={ + fbchat.Image( + url="https://scontent-arn2-1.xx.fbcdn.net/v/s168x128/1.jpg", + width=168, + height=96, + ), + fbchat.Image( + url="https://scontent-arn2-1.xx.fbcdn.net/v/p261x260/3.jpg", + width=452, + height=260, + ), + fbchat.Image( + url="https://scontent-arn2-1.xx.fbcdn.net/v/2.jpg", + width=640, + height=368, + ), + }, ) == graphql_to_attachment(data) @@ -346,9 +350,11 @@ def test_graphql_to_subattachment_video(): uid="1234", duration=datetime.timedelta(seconds=24, microseconds=469000), preview_url="https://video-arn2-1.xx.fbcdn.net/v/t42.9040-2/vid.mp4", - medium_image=fbchat.Image( - url="https://scontent-arn2-1.xx.fbcdn.net/v/t15.5256-10/p180x540/1.jpg", - width=960, - height=540, - ), + previews={ + fbchat.Image( + url="https://scontent-arn2-1.xx.fbcdn.net/v/t15.5256-10/p180x540/1.jpg", + width=960, + height=540, + ) + }, ) == graphql_to_subattachment(data) From aaf26691d6ba417bcf0bea4a809f6faf75f8a692 Mon Sep 17 00:00:00 2001 From: Mads Marquart Date: Wed, 11 Dec 2019 15:10:18 +0100 Subject: [PATCH 8/9] Move Mention parsing into the class itself --- fbchat/_message.py | 44 ++++++++++++++++++++++++++------------------ 1 file changed, 26 insertions(+), 18 deletions(-) diff --git a/fbchat/_message.py b/fbchat/_message.py index 87c928d..d9fee2c 100644 --- a/fbchat/_message.py +++ b/fbchat/_message.py @@ -53,6 +53,26 @@ class Mention: #: The length of the mention length = attr.ib(10) + @classmethod + def _from_range(cls, data): + return cls( + thread_id=data.get("entity", {}).get("id"), + offset=data.get("offset"), + length=data.get("length"), + ) + + @classmethod + def _from_prng(cls, data): + return cls(thread_id=data.get("i"), offset=data.get("o"), length=data.get("l")) + + def _to_send_data(self, i): + return { + "profile_xmd[{}][id]".format(i): self.thread_id, + "profile_xmd[{}][offset]".format(i): self.offset, + "profile_xmd[{}][length]".format(i): self.length, + "profile_xmd[{}][type]".format(i): "p", + } + @attrs_default class Message: @@ -157,10 +177,7 @@ class Message: data["body"] = self.text for i, mention in enumerate(self.mentions): - data["profile_xmd[{}][id]".format(i)] = mention.thread_id - data["profile_xmd[{}][offset]".format(i)] = mention.offset - data["profile_xmd[{}][length]".format(i)] = mention.length - data["profile_xmd[{}][type]".format(i)] = "p" + data.update(mention._to_send_data(i)) if self.emoji_size: if self.text: @@ -235,12 +252,7 @@ class Message: return cls( text=data["message"].get("text"), mentions=[ - Mention( - thread_id=m.get("entity", {}).get("id"), - offset=m.get("offset"), - length=m.get("length"), - ) - for m in data["message"].get("ranges") or () + Mention._from_range(m) for m in data["message"].get("ranges") or () ], emoji_size=EmojiSize._from_tags(tags), uid=str(data["message_id"]), @@ -295,8 +307,8 @@ class Message: return cls( text=data.get("body"), mentions=[ - Mention(thread_id=m.get("i"), offset=m.get("o"), length=m.get("l")) - for m in json.loads(data.get("data", {}).get("prng", "[]")) + Mention._from_prng(m) + for m in _util.parse_json(data.get("data", {}).get("prng", "[]")) ], emoji_size=EmojiSize._from_tags(tags), uid=metadata.get("messageId"), @@ -317,12 +329,8 @@ class Message: if data.get("data") and data["data"].get("prng"): try: mentions = [ - Mention( - thread_id=str(mention.get("i")), - offset=mention.get("o"), - length=mention.get("l"), - ) - for mention in _util.parse_json(data["data"]["prng"]) + Mention._from_prng(m) + for m in _util.parse_json(data["data"]["prng"]) ] except Exception: log.exception("An exception occured while reading attachments") From d1fbf0ba0a4854d5d03683eeae613009a0632890 Mon Sep 17 00:00:00 2001 From: Mads Marquart Date: Wed, 11 Dec 2019 15:18:26 +0100 Subject: [PATCH 9/9] Clean up doc references --- docs/intro.rst | 36 ++++++++++++++++----------------- fbchat/_client.py | 48 +++++++++++++++++++++----------------------- fbchat/_core.py | 2 +- fbchat/_exception.py | 2 +- fbchat/_group.py | 2 +- fbchat/_message.py | 12 +++++------ fbchat/_poll.py | 2 +- fbchat/_thread.py | 2 +- fbchat/_user.py | 2 +- 9 files changed, 53 insertions(+), 55 deletions(-) diff --git a/docs/intro.rst b/docs/intro.rst index 6a9e956..873fc7e 100644 --- a/docs/intro.rst +++ b/docs/intro.rst @@ -13,8 +13,8 @@ You should also make sure that the file's access control is appropriately restri Logging In ---------- -Simply create an instance of :class:`Client`. If you have two factor authentication enabled, type the code in the terminal prompt -(If you want to supply the code in another fashion, overwrite :func:`Client.on_2fa_code`):: +Simply create an instance of `Client`. If you have two factor authentication enabled, type the code in the terminal prompt +(If you want to supply the code in another fashion, overwrite `Client.on_2fa_code`):: from fbchat import Client from fbchat.models import * @@ -26,15 +26,15 @@ Replace ```` and ```` with your email and password respectively For ease of use then most of the code snippets in this document will assume you've already completed the login process Though the second line, ``from fbchat.models import *``, is not strictly necessary here, later code snippets will assume you've done this -If you want to change how verbose ``fbchat`` is, change the logging level (in :class:`Client`) +If you want to change how verbose ``fbchat`` is, change the logging level (in `Client`) -Throughout your code, if you want to check whether you are still logged in, use :func:`Client.is_logged_in`. -An example would be to login again if you've been logged out, using :func:`Client.login`:: +Throughout your code, if you want to check whether you are still logged in, use `Client.is_logged_in`. +An example would be to login again if you've been logged out, using `Client.login`:: if not client.is_logged_in(): client.login('', '') -When you're done using the client, and want to securely logout, use :func:`Client.logout`:: +When you're done using the client, and want to securely logout, use `Client.logout`:: client.logout() @@ -46,14 +46,14 @@ Threads A thread can refer to two things: A Messenger group chat or a single Facebook user -:class:`ThreadType` is an enumerator with two values: ``USER`` and ``GROUP``. +`ThreadType` is an enumerator with two values: ``USER`` and ``GROUP``. These will specify whether the thread is a single user chat or a group chat. This is required for many of ``fbchat``'s functions, since Facebook differentiates between these two internally -Searching for group chats and finding their ID can be done via. :func:`Client.search_for_groups`, -and searching for users is possible via. :func:`Client.search_for_users`. See :ref:`intro_fetching` +Searching for group chats and finding their ID can be done via. `Client.search_for_groups`, +and searching for users is possible via. `Client.search_for_users`. See :ref:`intro_fetching` -You can get your own user ID by using :any:`Client.uid` +You can get your own user ID by using `Client.uid` Getting the ID of a group chat is fairly trivial otherwise, since you only need to navigate to ``_, click on the group you want to find the ID of, and then read the id from the address bar. @@ -71,7 +71,7 @@ corresponds to the ID of a single user, and the ID of a group respectively:: client.send(Message(text=''), thread_id='', thread_type=ThreadType.USER) client.send(Message(text=''), thread_id='', thread_type=ThreadType.GROUP) -Some functions (e.g. :func:`Client.change_thread_color`) don't require a thread type, so in these cases you just provide the thread ID:: +Some functions (e.g. `Client.change_thread_color`) don't require a thread type, so in these cases you just provide the thread ID:: client.change_thread_color(ThreadColor.BILOBA_FLOWER, thread_id='') client.change_thread_color(ThreadColor.MESSENGER_BLUE, thread_id='') @@ -85,8 +85,8 @@ Message IDs Every message you send on Facebook has a unique ID, and every action you do in a thread, like changing a nickname or adding a person, has a unique ID too. -Some of ``fbchat``'s functions require these ID's, like :func:`Client.react_to_message`, -and some of then provide this ID, like :func:`Client.send`. +Some of ``fbchat``'s functions require these ID's, like `Client.react_to_message`, +and some of then provide this ID, like `Client.send`. This snippet shows how to send a message, and then use the returned ID to react to that message with a 😍 emoji:: message_id = client.send(Message(text='message'), thread_id=thread_id, thread_type=thread_type) @@ -118,7 +118,7 @@ Fetching Information You can use ``fbchat`` to fetch basic information like user names, profile pictures, thread names and user IDs -You can retrieve a user's ID with :func:`Client.search_for_users`. +You can retrieve a user's ID with `Client.search_for_users`. The following snippet will search for users by their name, take the first (and most likely) user, and then get their user ID from the result:: users = client.search_for_users('') @@ -140,11 +140,11 @@ Sessions ``fbchat`` provides functions to retrieve and set the session cookies. This will enable you to store the session cookies in a separate file, so that you don't have to login each time you start your script. -Use :func:`Client.get_gession` to retrieve the cookies:: +Use `Client.get_gession` to retrieve the cookies:: session_cookies = client.get_gession() -Then you can use :func:`Client.set_gession`:: +Then you can use `Client.set_gession`:: client.set_gession(session_cookies) @@ -162,7 +162,7 @@ Or you can set the ``session_cookies`` on your initial login. Listening & Events ------------------ -To use the listening functions ``fbchat`` offers (like :func:`Client.listen`), +To use the listening functions ``fbchat`` offers (like `Client.listen`), you have to define what should be executed when certain events happen. By default, (most) events will just be a `logging.info` statement, meaning it will simply print information to the console when an event happens @@ -170,7 +170,7 @@ meaning it will simply print information to the console when an event happens .. note:: You can identify the event methods by their ``on`` prefix, e.g. ``on_message`` -The event actions can be changed by subclassing the :class:`Client`, and then overwriting the event methods:: +The event actions can be changed by subclassing the `Client`, and then overwriting the event methods:: class CustomClient(Client): def on_message(self, mid, author_id, message_object, thread_id, thread_type, ts, metadata, msg, **kwargs): diff --git a/fbchat/_client.py b/fbchat/_client.py index a2841aa..0b14591 100644 --- a/fbchat/_client.py +++ b/fbchat/_client.py @@ -38,9 +38,9 @@ ACONTEXT = { class Client: """A client for the Facebook Chat (Messenger). - This is the main class of ``fbchat``, which contains all the methods you use to - interact with Facebook. You can extend this class, and overwrite the ``on`` methods, - to provide custom event handling (mainly useful while listening). + This is the main class, which contains all the methods you use to interact with + Facebook. You can extend this class, and overwrite the ``on`` methods, to provide + custom event handling (mainly useful while listening). """ @property @@ -215,7 +215,7 @@ class Client: limit: The max. amount of threads to fetch (default all threads) Returns: - list: :class:`Thread` objects + list: `Thread` objects Raises: FBchatException: If request failed @@ -266,7 +266,7 @@ class Client: threads: Thread: List of threads to check for users Returns: - list: :class:`User` objects + list: `User` objects Raises: FBchatException: If request failed @@ -292,7 +292,7 @@ class Client: """Fetch all users the client is currently chatting with. Returns: - list: :class:`User` objects + list: `User` objects Raises: FBchatException: If request failed @@ -317,7 +317,7 @@ class Client: limit: The max. amount of users to fetch Returns: - list: :class:`User` objects, ordered by relevance + list: `User` objects, ordered by relevance Raises: FBchatException: If request failed @@ -334,7 +334,7 @@ class Client: name: Name of the page Returns: - list: :class:`Page` objects, ordered by relevance + list: `Page` objects, ordered by relevance Raises: FBchatException: If request failed @@ -352,7 +352,7 @@ class Client: limit: The max. amount of groups to fetch Returns: - list: :class:`Group` objects, ordered by relevance + list: `Group` objects, ordered by relevance Raises: FBchatException: If request failed @@ -370,7 +370,7 @@ class Client: limit: The max. amount of groups to fetch Returns: - list: :class:`User`, :class:`Group` and :class:`Page` objects, ordered by relevance + list: `User`, `Group` and `Page` objects, ordered by relevance Raises: FBchatException: If request failed @@ -441,7 +441,7 @@ class Client: thread_id: User/Group ID to search in. See :ref:`intro_threads` Returns: - typing.Iterable: Found :class:`Message` objects + typing.Iterable: Found `Message` objects Raises: FBchatException: If request failed @@ -457,7 +457,7 @@ class Client: Args: query: Text to search for - fetch_messages: Whether to fetch :class:`Message` objects or IDs only + fetch_messages: Whether to fetch `Message` objects or IDs only thread_limit (int): Max. number of threads to retrieve message_limit (int): Max. number of messages to retrieve @@ -531,7 +531,7 @@ class Client: user_ids: One or more user ID(s) to query Returns: - dict: :class:`User` objects, labeled by their ID + dict: `User` objects, labeled by their ID Raises: FBchatException: If request failed @@ -556,7 +556,7 @@ class Client: page_ids: One or more page ID(s) to query Returns: - dict: :class:`Page` objects, labeled by their ID + dict: `Page` objects, labeled by their ID Raises: FBchatException: If request failed @@ -578,7 +578,7 @@ class Client: group_ids: One or more group ID(s) to query Returns: - dict: :class:`Group` objects, labeled by their ID + dict: `Group` objects, labeled by their ID Raises: FBchatException: If request failed @@ -603,7 +603,7 @@ class Client: thread_ids: One or more thread ID(s) to query Returns: - dict: :class:`Thread` objects, labeled by their ID + dict: `Thread` objects, labeled by their ID Raises: FBchatException: If request failed @@ -669,7 +669,7 @@ class Client: before (datetime.datetime): The point from which to retrieve messages Returns: - list: :class:`Message` objects + list: `Message` objects Raises: FBchatException: If request failed @@ -707,7 +707,7 @@ class Client: before (datetime.datetime): The point from which to retrieve threads Returns: - list: :class:`Thread` objects + list: `Thread` objects Raises: FBchatException: If request failed @@ -806,7 +806,7 @@ class Client: thread_id: User/Group ID to get message info from. See :ref:`intro_threads` Returns: - Message: :class:`Message` object + Message: `Message` object Raises: FBchatException: If request failed @@ -837,7 +837,7 @@ class Client: plan_id: Plan ID to fetch from Returns: - Plan: :class:`Plan` object + Plan: `Plan` object Raises: FBchatException: If request failed @@ -893,7 +893,7 @@ class Client: thread_id: ID of the thread Returns: - typing.Iterable: :class:`ImageAttachment` or :class:`VideoAttachment` + typing.Iterable: `ImageAttachment` or `VideoAttachment` """ data = {"id": thread_id, "first": 48} thread_id = str(thread_id) @@ -1013,9 +1013,7 @@ class Client: return self.send(Message(text=quick_reply.title, quick_replies=[new])) elif isinstance(quick_reply, QuickReplyLocation): if not isinstance(payload, LocationAttachment): - raise TypeError( - "Payload must be an instance of `fbchat.LocationAttachment`" - ) + raise TypeError("Payload must be an instance of `LocationAttachment`") return self.send_location( payload, thread_id=thread_id, thread_type=thread_type ) @@ -3655,7 +3653,7 @@ class Client: """Called when the client is listening and client receives information about friend active status. Args: - statuses (dict): Dictionary with user IDs as keys and :class:`ActiveStatus` as values + statuses (dict): Dictionary with user IDs as keys and `ActiveStatus` as values msg: A full set of the data received """ log.debug("Buddylist overlay received: {}".format(statuses)) diff --git a/fbchat/_core.py b/fbchat/_core.py index 9161ef6..3f03a4b 100644 --- a/fbchat/_core.py +++ b/fbchat/_core.py @@ -13,7 +13,7 @@ attrs_default = attr.s(slots=True, kw_only=kw_only) class Enum(aenum.Enum): - """Used internally by ``fbchat`` to support enumerations""" + """Used internally to support enumerations""" def __repr__(self): # For documentation: diff --git a/fbchat/_exception.py b/fbchat/_exception.py index 64632cb..92c654d 100644 --- a/fbchat/_exception.py +++ b/fbchat/_exception.py @@ -8,7 +8,7 @@ attrs_exception = attr.s(slots=True, auto_exc=True) class FBchatException(Exception): """Custom exception thrown by ``fbchat``. - All exceptions in the ``fbchat`` module inherits this. + All exceptions in the module inherits this. """ message = attr.ib() diff --git a/fbchat/_group.py b/fbchat/_group.py index eef0b0a..10d1666 100644 --- a/fbchat/_group.py +++ b/fbchat/_group.py @@ -14,7 +14,7 @@ class Group(Thread): participants = attr.ib(factory=set) #: A dictionary, containing user nicknames mapped to their IDs nicknames = attr.ib(factory=dict) - #: A :class:`ThreadColor`. The groups's message color + #: A `ThreadColor`. The groups's message color color = attr.ib(None) #: The groups's default emoji emoji = attr.ib(None) diff --git a/fbchat/_message.py b/fbchat/_message.py index d9fee2c..9c7380a 100644 --- a/fbchat/_message.py +++ b/fbchat/_message.py @@ -80,9 +80,9 @@ class Message: #: The actual message text = attr.ib(None) - #: A list of :class:`Mention` objects + #: A list of `Mention` objects mentions = attr.ib(factory=list) - #: A :class:`EmojiSize`. Size of a sent emoji + #: A `EmojiSize`. Size of a sent emoji emoji_size = attr.ib(None) #: The message ID uid = attr.ib(None) @@ -92,15 +92,15 @@ class Message: created_at = attr.ib(None) #: Whether the message is read is_read = attr.ib(None) - #: A list of people IDs who read the message, works only with :func:`fbchat.Client.fetch_thread_messages` + #: A list of people IDs who read the message, works only with `Client.fetch_thread_messages` read_by = attr.ib(factory=list) - #: A dictionary with user's IDs as keys, and their :class:`MessageReaction` as values + #: A dictionary with user's IDs as keys, and their `MessageReaction` as values reactions = attr.ib(factory=dict) - #: A :class:`Sticker` + #: A `Sticker` sticker = attr.ib(None) #: A list of attachments attachments = attr.ib(factory=list) - #: A list of :class:`QuickReply` + #: A list of `QuickReply` quick_replies = attr.ib(factory=list) #: Whether the message is unsent (deleted for everyone) unsent = attr.ib(False) diff --git a/fbchat/_poll.py b/fbchat/_poll.py index 3273484..8f31575 100644 --- a/fbchat/_poll.py +++ b/fbchat/_poll.py @@ -8,7 +8,7 @@ class Poll: #: Title of the poll title = attr.ib() - #: List of :class:`PollOption`, can be fetched with :func:`fbchat.Client.fetch_poll_options` + #: List of `PollOption`, can be fetched with `Client.fetch_poll_options` options = attr.ib() #: Options count options_count = attr.ib(None) diff --git a/fbchat/_thread.py b/fbchat/_thread.py index b517d1b..3369df4 100644 --- a/fbchat/_thread.py +++ b/fbchat/_thread.py @@ -83,7 +83,7 @@ class Thread: last_active = attr.ib(None) #: Number of messages in the thread message_count = attr.ib(None) - #: Set :class:`Plan` + #: Set `Plan` plan = attr.ib(None) @staticmethod diff --git a/fbchat/_user.py b/fbchat/_user.py index 5071c38..4dcfbf4 100644 --- a/fbchat/_user.py +++ b/fbchat/_user.py @@ -63,7 +63,7 @@ class User(Thread): nickname = attr.ib(None) #: The clients nickname, as seen by the user own_nickname = attr.ib(None) - #: A :class:`ThreadColor`. The message color + #: A `ThreadColor`. The message color color = attr.ib(None) #: The default emoji emoji = attr.ib(None)