Move ClientPayload parsing to separate file and add tests

This commit is contained in:
Mads Marquart
2020-01-16 16:53:18 +01:00
parent e735823d37
commit 0696ff9f4b
5 changed files with 322 additions and 174 deletions

View File

@@ -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

View File

@@ -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,

136
fbchat/_client_payload.py Normal file
View File

@@ -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

View File

@@ -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),
)

View File

@@ -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))