From 0696ff9f4b0639e859c29edadf659961e545fe54 Mon Sep 17 00:00:00 2001 From: Mads Marquart Date: Thu, 16 Jan 2020 16:53:18 +0100 Subject: [PATCH] Move ClientPayload parsing to separate file and add tests --- fbchat/__init__.py | 7 ++ fbchat/_client.py | 174 +---------------------------------- fbchat/_client_payload.py | 136 +++++++++++++++++++++++++++ fbchat/_message.py | 7 +- tests/test_client_payload.py | 172 ++++++++++++++++++++++++++++++++++ 5 files changed, 322 insertions(+), 174 deletions(-) create mode 100644 fbchat/_client_payload.py create mode 100644 tests/test_client_payload.py diff --git a/fbchat/__init__.py b/fbchat/__init__.py index 6c769d9..0cd6d70 100644 --- a/fbchat/__init__.py +++ b/fbchat/__init__.py @@ -43,6 +43,13 @@ from ._poll import Poll, PollOption from ._plan import GuestStatus, Plan, PlanData from ._event_common import Event, UnknownEvent, ThreadEvent +from ._client_payload import ( + ReactionEvent, + UserStatusEvent, + LiveLocationEvent, + UnsendEvent, + MessageReplyEvent, +) from ._client import Client diff --git a/fbchat/_client.py b/fbchat/_client.py index eee243a..ddde55e 100644 --- a/fbchat/_client.py +++ b/fbchat/_client.py @@ -14,6 +14,7 @@ from . import ( _thread, _message, _event_common, + _client_payload, ) from ._thread import ThreadLocation @@ -964,92 +965,8 @@ class Client: # Client payload (that weird numbers) elif delta_class == "ClientPayload": - payload = _util.parse_json("".join(chr(z) for z in delta["payload"])) - # Hack - at = datetime.datetime.utcnow().replace(tzinfo=datetime.timezone.utc) - for d in payload.get("deltas", []): - - # Message reaction - if d.get("deltaMessageReaction"): - i = d["deltaMessageReaction"] - mid = i["messageId"] - author_id = str(i["userId"]) - add_reaction = not bool(i["action"]) - if add_reaction: - self.on_reaction_added( - mid=mid, - reaction=i.get("reaction"), - author_id=author_id, - thread=get_thread(i), - at=at, - ) - else: - self.on_reaction_removed( - mid=mid, author_id=author_id, thread=get_thread(i), at=at, - ) - - # Viewer status change - elif d.get("deltaChangeViewerStatus"): - i = d["deltaChangeViewerStatus"] - author_id = str(i["actorFbid"]) - reason = i["reason"] - can_reply = i["canViewerReply"] - if reason == 2: - if can_reply: - self.on_unblock( - author_id=author_id, thread=get_thread(i), at=at - ) - else: - self.on_block( - author_id=author_id, thread=get_thread(i), at=at - ) - - # Live location info - elif d.get("liveLocationData"): - i = d["liveLocationData"] - for l in i["messageLiveLocations"]: - mid = l["messageId"] - author_id = str(l["senderId"]) - location = LiveLocationAttachment._from_pull(l) - self.on_live_location( - mid=mid, - location=location, - author_id=author_id, - thread=get_thread(i), - at=at, - ) - - # Message deletion - elif d.get("deltaRecallMessageData"): - i = d["deltaRecallMessageData"] - mid = i["messageID"] - at = _util.millis_to_datetime(i["deletionTimestamp"]) - author_id = str(i["senderID"]) - self.on_message_unsent( - mid=mid, author_id=author_id, thread=get_thread(i), at=at - ) - - elif d.get("deltaMessageReply"): - i = d["deltaMessageReply"] - metadata = i["message"]["messageMetadata"] - thread = get_thread(metadata) - replied_to = _message.MessageData._from_reply( - thread, i["repliedToMessage"] - ) - message = _message.MessageData._from_reply( - thread, i["message"], replied_to - ) - self.on_message( - mid=message.id, - author_id=message.author, - message_object=message, - thread=thread, - at=message.created_at, - metadata=metadata, - ) - - else: - self.on_unknown_messsage_type(msg=d) + for event in _client_payload.parse_client_payloads(self.session, delta): + self.on_event(event) # New message elif delta.get("class") == "NewMessage": @@ -1413,21 +1330,6 @@ class Client: """ log.info("Marked messages as seen in threads {} at {}".format(threads, seen_at)) - def on_message_unsent(self, mid=None, author_id=None, thread=None, at=None): - """Called when the client is listening, and someone unsends (deletes for everyone) a message. - - Args: - mid: ID of the unsent message - author_id: The ID of the person who unsent the message - thread: Thread that the action was sent to. See :ref:`intro_threads` - at (datetime.datetime): When the action was executed - """ - log.info( - "{} unsent the message {} in {} at {}".format( - author_id, repr(mid), thread, at - ) - ) - def on_people_added( self, mid=None, added_ids=None, author_id=None, group=None, at=None ): @@ -1523,76 +1425,6 @@ class Client: """ log.info('{} played "{}" in {}'.format(author_id, game_name, thread)) - def on_reaction_added( - self, mid=None, reaction=None, author_id=None, thread=None, at=None - ): - """Called when the client is listening, and somebody reacts to a message. - - Args: - mid: Message ID, that user reacted to - reaction: The added reaction. Not limited to the ones in `Message.react` - add_reaction: Whether user added or removed reaction - author_id: The ID of the person who reacted to the message - thread: Thread that the action was sent to. See :ref:`intro_threads` - at (datetime.datetime): When the action was executed - """ - log.info( - "{} reacted to message {} with {} in {}".format( - author_id, mid, reaction, thread - ) - ) - - def on_reaction_removed(self, mid=None, author_id=None, thread=None, at=None): - """Called when the client is listening, and somebody removes reaction from a message. - - Args: - mid: Message ID, that user reacted to - author_id: The ID of the person who removed reaction - thread: Thread that the action was sent to. See :ref:`intro_threads` - at (datetime.datetime): When the action was executed - """ - log.info( - "{} removed reaction from {} message in {}".format(author_id, mid, thread) - ) - - def on_block(self, author_id=None, thread=None, at=None): - """Called when the client is listening, and somebody blocks client. - - Args: - author_id: The ID of the person who blocked - thread: Thread that the action was sent to. See :ref:`intro_threads` - at (datetime.datetime): When the action was executed - """ - log.info("{} blocked {}".format(author_id, thread)) - - def on_unblock(self, author_id=None, thread=None, at=None): - """Called when the client is listening, and somebody blocks client. - - Args: - author_id: The ID of the person who unblocked - thread: Thread that the action was sent to. See :ref:`intro_threads` - at (datetime.datetime): When the action was executed - """ - log.info("{} unblocked {}".format(author_id, thread)) - - def on_live_location( - self, mid=None, location=None, author_id=None, thread=None, at=None - ): - """Called when the client is listening and somebody sends live location info. - - Args: - mid: The action ID - location (LiveLocationAttachment): Sent location info - author_id: The ID of the person who sent location info - thread: Thread that the action was sent to. See :ref:`intro_threads` - at (datetime.datetime): When the action was executed - """ - log.info( - "{} sent live location info in {} with latitude {} and longitude {}".format( - author_id, thread, location.latitude, location.longitude - ) - ) - def on_call_started( self, mid=None, diff --git a/fbchat/_client_payload.py b/fbchat/_client_payload.py new file mode 100644 index 0000000..2543a2a --- /dev/null +++ b/fbchat/_client_payload.py @@ -0,0 +1,136 @@ +import attr +import datetime +from ._event_common import attrs_event, UnknownEvent, ThreadEvent +from . import _exception, _util, _user, _message + +from typing import Optional + + +@attrs_event +class ReactionEvent(ThreadEvent): + """Somebody reacted to a message.""" + + #: Message that the user reacted to + message = attr.ib(type=_message.Message) + + reaction = attr.ib(type=Optional[str]) + """The reaction. + + Not limited to the ones in `Message.react`. + + If ``None``, the reaction was removed. + """ + + @classmethod + def _parse(cls, session, data): + thread = cls._get_thread(session, data) + return cls( + author=_user.User(session=session, id=str(data["userId"])), + thread=thread, + message=_message.Message(thread=thread, id=data["messageId"]), + reaction=data["reaction"] if data["action"] == 0 else None, + ) + + +@attrs_event +class UserStatusEvent(ThreadEvent): + #: Whether the user was blocked or unblocked + blocked = attr.ib(type=bool) + + @classmethod + def _parse(cls, session, data): + return cls( + author=_user.User(session=session, id=str(data["actorFbid"])), + thread=cls._get_thread(session, data), + blocked=not data["canViewerReply"], + ) + + +@attrs_event +class LiveLocationEvent(ThreadEvent): + """Somebody sent live location info.""" + + # TODO: This! + + @classmethod + def _parse(cls, session, data): + from . import _location + + thread = cls._get_thread(session, data) + for location_data in data["messageLiveLocations"]: + message = _message.Message(thread=thread, id=data["messageId"]) + author = _user.User(session=session, id=str(location_data["senderId"])) + location = _location.LiveLocationAttachment._from_pull(location_data) + + return None + + +@attrs_event +class UnsendEvent(ThreadEvent): + """Somebody unsent a message (which deletes it for everyone).""" + + #: The unsent message + message = attr.ib(type=_message.Message) + #: When the message was unsent + at = attr.ib(type=datetime.datetime) + + @classmethod + def _parse(cls, session, data): + thread = cls._get_thread(session, data) + return cls( + author=_user.User(session=session, id=str(data["senderID"])), + thread=thread, + message=_message.Message(thread=thread, id=data["messageID"]), + at=_util.millis_to_datetime(data["deletionTimestamp"]), + ) + + +@attrs_event +class MessageReplyEvent(ThreadEvent): + """Somebody replied to a message.""" + + #: The sent message + message = attr.ib(type=_message.MessageData) + #: The message that was replied to + replied_to = attr.ib(type=_message.MessageData) + + @classmethod + def _parse(cls, session, data): + metadata = data["message"]["messageMetadata"] + thread = cls._get_thread(session, metadata) + return cls( + author=_user.User(session=session, id=str(metadata["actorFbId"])), + thread=thread, + message=_message.MessageData._from_reply(thread, data["message"]), + replied_to=_message.MessageData._from_reply( + thread, data["repliedToMessage"] + ), + ) + + +def parse_client_delta(session, data): + if "deltaMessageReaction" in data: + return ReactionEvent._parse(session, data["deltaMessageReaction"]) + elif "deltaChangeViewerStatus" in data: + # TODO: Parse all `reason` + if data["deltaChangeViewerStatus"]["reason"] == 2: + return UserStatusEvent._parse(session, data["deltaChangeViewerStatus"]) + elif "liveLocationData" in data: + return LiveLocationEvent._parse(session, data["liveLocationData"]) + elif "deltaRecallMessageData" in data: + return UnsendEvent._parse(session, data["deltaRecallMessageData"]) + elif "deltaMessageReply" in data: + return MessageReplyEvent._parse(session, data["deltaMessageReply"]) + return UnknownEvent(data=data) + + +def parse_client_payloads(session, data): + payload = _util.parse_json("".join(chr(z) for z in data["payload"])) + + try: + for delta in payload["deltas"]: + yield parse_client_delta(session, delta) + except _exception.ParseError: + raise + except Exception as e: + raise _exception.ParseError("Error parsing ClientPayload", data=payload) from e diff --git a/fbchat/_message.py b/fbchat/_message.py index b230477..ab33aa3 100644 --- a/fbchat/_message.py +++ b/fbchat/_message.py @@ -319,7 +319,7 @@ class MessageData(Message): ) @classmethod - def _from_reply(cls, thread, data, replied_to=None): + def _from_reply(cls, thread, data): tags = data["messageMetadata"].get("tags") metadata = data.get("messageMetadata", {}) @@ -360,8 +360,9 @@ class MessageData(Message): attachments=attachments, quick_replies=cls._parse_quick_replies(data.get("platform_xmd_encoded")), unsent=unsent, - reply_to_id=replied_to.id if replied_to else None, - replied_to=replied_to, + reply_to_id=data["messageReply"]["replyToMessageId"]["id"] + if "messageReply" in data + else None, forwarded=cls._get_forwarded_from_tags(tags), ) diff --git a/tests/test_client_payload.py b/tests/test_client_payload.py new file mode 100644 index 0000000..b9edc3e --- /dev/null +++ b/tests/test_client_payload.py @@ -0,0 +1,172 @@ +import datetime +import pytest +from fbchat import ( + ParseError, + User, + Group, + UnknownEvent, + ReactionEvent, + UserStatusEvent, + LiveLocationEvent, + UnsendEvent, + MessageReplyEvent, +) +from fbchat._message import Message, MessageData +from fbchat._client_payload import parse_client_delta, parse_client_payloads + + +def test_reaction_event_added(session): + data = { + "threadKey": {"otherUserFbId": 1234}, + "messageId": "mid.$XYZ", + "action": 0, + "userId": 4321, + "reaction": "😍", + "senderId": 4321, + "offlineThreadingId": "6623596674408921967", + } + thread = User(session=session, id="1234") + assert ReactionEvent( + author=User(session=session, id="4321"), + thread=thread, + message=Message(thread=thread, id="mid.$XYZ"), + reaction="😍", + ) == parse_client_delta(session, {"deltaMessageReaction": data}) + + +def test_reaction_event_removed(session): + data = { + "threadKey": {"threadFbId": 1234}, + "messageId": "mid.$XYZ", + "action": 1, + "userId": 4321, + "senderId": 4321, + "offlineThreadingId": "6623586106713014836", + } + thread = Group(session=session, id="1234") + assert ReactionEvent( + author=User(session=session, id="4321"), + thread=thread, + message=Message(thread=thread, id="mid.$XYZ"), + reaction=None, + ) == parse_client_delta(session, {"deltaMessageReaction": data}) + + +def test_user_status_blocked(session): + data = { + "threadKey": {"otherUserFbId": 1234}, + "canViewerReply": False, + "reason": 2, + "actorFbid": 4321, + } + assert UserStatusEvent( + author=User(session=session, id="4321"), + thread=User(session=session, id="1234"), + blocked=True, + ) == parse_client_delta(session, {"deltaChangeViewerStatus": data}) + + +def test_user_status_unblocked(session): + data = { + "threadKey": {"otherUserFbId": 1234}, + "canViewerReply": True, + "reason": 2, + "actorFbid": 1234, + } + assert UserStatusEvent( + author=User(session=session, id="1234"), + thread=User(session=session, id="1234"), + blocked=False, + ) == parse_client_delta(session, {"deltaChangeViewerStatus": data}) + + +@pytest.mark.skip(reason="need to gather test data") +def test_live_location(session): + pass + + +def test_message_reply(session): + message = { + "messageMetadata": { + "threadKey": {"otherUserFbId": 1234}, + "messageId": "mid.$XYZ", + "offlineThreadingId": "112233445566", + "actorFbId": 1234, + "timestamp": 1500000000000, + "tags": ["source:messenger:web", "cg-enabled", "sent", "inbox"], + "threadReadStateEffect": 3, + "skipBumpThread": False, + "skipSnippetUpdate": False, + "unsendType": "can_unsend", + "folderId": {"systemFolderId": 0}, + }, + "body": "xyz", + "attachments": [], + "irisSeqId": 1111111, + "messageReply": {"replyToMessageId": {"id": "mid.$ABC"}, "status": 0,}, + "requestContext": {"apiArgs": "..."}, + "irisTags": ["DeltaNewMessage"], + } + reply = { + "messageMetadata": { + "threadKey": {"otherUserFbId": 1234}, + "messageId": "mid.$ABC", + "offlineThreadingId": "665544332211", + "actorFbId": 4321, + "timestamp": 1600000000000, + "tags": ["inbox", "sent", "source:messenger:web"], + }, + "body": "abc", + "attachments": [], + "requestContext": {"apiArgs": "..."}, + "irisTags": [], + } + data = { + "message": message, + "repliedToMessage": reply, + "status": 0, + } + thread = User(session=session, id="1234") + assert MessageReplyEvent( + author=User(session=session, id="1234"), + thread=thread, + message=MessageData( + thread=thread, + id="mid.$XYZ", + author="1234", + created_at=datetime.datetime( + 2017, 7, 14, 2, 40, tzinfo=datetime.timezone.utc + ), + text="xyz", + reply_to_id="mid.$ABC", + ), + replied_to=MessageData( + thread=thread, + id="mid.$ABC", + author="4321", + created_at=datetime.datetime( + 2020, 9, 13, 12, 26, 40, tzinfo=datetime.timezone.utc + ), + text="abc", + ), + ) == parse_client_delta(session, {"deltaMessageReply": data}) + + +def test_parse_client_delta_unknown(session): + assert UnknownEvent(data={"abc": 10}) == parse_client_delta(session, {"abc": 10}) + + +def test_parse_client_payloads_empty(session): + # This is never something that happens, it's just so that we can test the parsing + # payload = '{"deltas":[]}' + payload = [123, 34, 100, 101, 108, 116, 97, 115, 34, 58, 91, 93, 125] + data = {"payload": payload, "class": "ClientPayload"} + assert [] == list(parse_client_payloads(session, data)) + + +def test_parse_client_payloads_invalid(session): + # payload = '{"invalid":"data"}' + payload = [123, 34, 105, 110, 118, 97, 108, 105, 100, 34, 58, 34, 97, 34, 125] + data = {"payload": payload, "class": "ClientPayload"} + with pytest.raises(ParseError, match="Error parsing ClientPayload"): + list(parse_client_payloads(session, data))