From e0be9029e471e11c551d0314a0c13e44004d2c2b Mon Sep 17 00:00:00 2001 From: Kacper Ziubryniewicz Date: Wed, 12 Sep 2018 17:48:00 +0200 Subject: [PATCH 01/32] Added extensible attachments models --- fbchat/models.py | 76 ++++++++++++++++++++++++++++++++++++++++++++++-- 1 file changed, 74 insertions(+), 2 deletions(-) diff --git a/fbchat/models.py b/fbchat/models.py index cb4f678..e25ca31 100644 --- a/fbchat/models.py +++ b/fbchat/models.py @@ -248,9 +248,81 @@ class Sticker(Attachment): super(Sticker, self).__init__(*args, **kwargs) class ShareAttachment(Attachment): - def __init__(self, **kwargs): - """Represents a shared item (eg. URL) that has been sent as a Facebook attachment - *Currently Incomplete!*""" + #: ID of the author of the shared post + author = None + #: Target URL + url = None + #: Original URL if Facebook redirects the URL + original_url = None + #: Title of the attachment + title = None + #: Description of the attachment + description = None + #: Name of the source + source = None + #: URL of the attachment image + image_url = None + #: URL of the original image if Facebook uses `safe_image` + original_image_url = None + #: Width of the image + image_width = None + #: Height of the image + image_height = None + #: List of additional attachments + attachments = None + + def __init__(self, author=None, url=None, original_url=None, title=None, description=None, source=None, image_url=None, original_image_url=None, image_width=None, image_height=None, attachments=None, **kwargs): + """Represents a shared item (eg. URL) that has been sent as a Facebook attachment""" super(ShareAttachment, self).__init__(**kwargs) + self.author = author + self.url = url + self.original_url = original_url + self.title = title + self.description = description + self.source = source + self.image_url = image_url + self.original_image_url = original_image_url + self.image_width = image_width + self.image_height = image_height + if attachments is None: + attachments = [] + self.attachments = attachments + +class LocationAttachment(Attachment): + #: Latidute of the location + latitude = None + #: Longitude of the location + longitude = None + #: URL of image showing the map of the location + image_url = None + #: Width of the image + image_width = None + #: Height of the image + image_height = None + #: URL to Bing maps with the location + url = None + + def __init__(self, latitude=None, longitude=None, image=None, url=None, **kwargs): + """Represents a user location""" + super(LocationAttachment, self).__init__(**kwargs) + self.latitude = latitude + self.longitude = longitude + self.image = image + self.url = url + +class LiveLocationAttachment(LocationAttachment): + #: Name of the location + name = None + #: Timestamp when live location expires + expiration_time = None + #: True if live location is expired + is_expired = None + + def __init__(self, name=None, expiration_time=None, is_expired=None, **kwargs): + """Represents a live user location""" + super(LiveLocationAttachment, self).__init__(**kwargs) + self.expiration_time = expiration_time + self.is_expired = is_expired class FileAttachment(Attachment): #: Url where you can download the file From 9b4e753a79a15d717902242482e09b0d411f0f2a Mon Sep 17 00:00:00 2001 From: Kacper Ziubryniewicz Date: Wed, 12 Sep 2018 17:48:35 +0200 Subject: [PATCH 02/32] Added graphql methods for extensible attachments --- fbchat/graphql.py | 69 ++++++++++++++++++++++++++++++++++++++++++++--- 1 file changed, 66 insertions(+), 3 deletions(-) diff --git a/fbchat/graphql.py b/fbchat/graphql.py index f023a16..72cfb13 100644 --- a/fbchat/graphql.py +++ b/fbchat/graphql.py @@ -128,9 +128,72 @@ def graphql_to_attachment(a): uid=a.get('legacy_attachment_id') ) +def graphql_to_extensible_attachment(a): + story = a.get('story_attachment') + if story: + _type = story['target']['__typename'] + if _type == 'MessageLocation': + latitude, longitude = get_url_parameter(get_url_parameter(story['url'], 'u'), 'where1').split(", ") + return LocationAttachment( + uid=int(story['deduplication_key']), + latitude=float(latitude), + longitude=float(longitude), + image=story['media']['image']['uri'], + url=story['url'], + ) + elif _type == 'MessageLiveLocation': + return LiveLocationAttachment( + uid=int(story['target']['live_location_id']), + latitude=story['target']['coordinate']['latitude'] if story['target'].get('coordinate') else None, + longitude=story['target']['coordinate']['longitude'] if story['target'].get('coordinate') else None, + image=story['media']['image']['uri'] if story.get('media') else None, + url=story['url'], + name=story['title_with_entities']['text'], + expiration_time=story['target']['expiration_time'] if story['target'].get('expiration_time') else None, + is_expired=story['target']['is_expired'], + ) + elif _type in ['ExternalUrl', 'Story']: + return ShareAttachment( + uid=a.get('legacy_attachment_id'), + author=story['target']['actors'][0]['id'] if story['target'].get('actors') else None, + url=story['url'], + original_url=get_url_parameter(story['url'], 'u') if "/l.php?u=" in story['url'] else story['url'], + title=story['title_with_entities']['text'], + description=story['description']['text'], + source=story['source']['text'], + image_url=story['media']['image']['uri'] if story.get('media') else None, + original_image_url=(get_url_parameter(story['media']['image']['uri'], 'url') if "/safe_image.php" in story['media']['image']['uri'] else story['media']['image']['uri']) if story.get('media') else None, + image_width=story['media']['image']['width'] if story.get('media') else None, + image_height=story['media']['image']['height'] if story.get('media') else None, + attachments=[graphql_to_subattachment(attachment) for attachment in story.get('subattachments')] + ) + + +def graphql_to_subattachment(a): + _type = a['target']['__typename'] + if _type == 'Video': + return VideoAttachment( + duration=a['media'].get('playable_duration_in_ms'), + preview_url=a['media'].get('playable_url'), + small_image=a['media'].get('image'), + medium_image=a['media'].get('image'), + large_image=a['media'].get('image'), + uid=a['target'].get('video_id'), + ) + +def graphql_to_live_location(a): + return LiveLocationAttachment( + uid=a['id'], + latitude=a['coordinate']['latitude'] / (10 ** 8) if not a.get('stopReason') else None, + longitude=a['coordinate']['longitude'] / (10 ** 8) if not a.get('stopReason') else None, + name=a.get('locationTitle'), + expiration_time=a['expirationTime'], + is_expired=bool(a.get('stopReason')), + ) + def graphql_to_poll(a): rtn = Poll( - title=a.get('title') if a.get('title') else a.get("text"), + title=a.get('title') if a.get('title') else a.get('text'), options=[graphql_to_poll_option(m) for m in a.get('options')] ) rtn.uid = int(a["id"]) @@ -212,8 +275,8 @@ def graphql_to_message(message): rtn.reactions = {str(r['user']['id']):MessageReaction(r['reaction']) for r in message.get('message_reactions')} if message.get('blob_attachments') is not None: rtn.attachments = [graphql_to_attachment(attachment) for attachment in message['blob_attachments']] - # TODO: This is still missing parsing: - # message.get('extensible_attachment') + if message.get('extensible_attachment') is not None: + rtn.attachments.append(graphql_to_extensible_attachment(message['extensible_attachment'])) return rtn def graphql_to_user(user): From 940a65954cd6cfa4895ea14022a2f271ba330f7f Mon Sep 17 00:00:00 2001 From: Kacper Ziubryniewicz Date: Wed, 12 Sep 2018 17:52:38 +0200 Subject: [PATCH 03/32] Read commit description Added: - Detecting extensible attachments - Fetching live user location - New methods for message reacting - New `on` methods: `onReactionAdded`, `onReactionRemoved`, `onBlock`, `onUnblock`, `onLiveLocation` - Fixed `size` of attachments --- fbchat/client.py | 185 +++++++++++++++++++++++++++++++++++++++++------ 1 file changed, 164 insertions(+), 21 deletions(-) diff --git a/fbchat/client.py b/fbchat/client.py index 69c858d..dae3960 100644 --- a/fbchat/client.py +++ b/fbchat/client.py @@ -1485,7 +1485,30 @@ class Client(object): j = self._post(self.req_url.THREAD_EMOJI, data, fix_request=True, as_json=True) + def _react(self, message_id, reaction=None, add_reaction=True): + data = { + "doc_id": 1491398900900362, + "variables": json.dumps({ + "data": { + "action": "ADD_REACTION" if add_reaction else "REMOVE_REACTION", + "client_mutation_id": "1", + "actor_id": self.uid, + "message_id": str(message_id), + "reaction": reaction.value if add_reaction else None + } + }) + } + + r = self._post(self.req_url.MESSAGE_REACTION, data) + r.raise_for_status() + def reactToMessage(self, message_id, reaction): + """ + Deprecated. Use :func:`fbchat.Client.addReaction` instead + """ + self.addReaction(message_id=message_id, reaction=reaction) + + def addReaction(self, message_id, reaction): """ Reacts to a message @@ -1494,20 +1517,16 @@ class Client(object): :type reaction: models.MessageReaction :raises: FBchatException if request failed """ - full_data = { - "doc_id": 1491398900900362, - "variables": json.dumps({ - "data": { - "action": "ADD_REACTION", - "client_mutation_id": "1", - "actor_id": self.uid, - "message_id": str(message_id), - "reaction": reaction.value - } - }) - } + self._react(message_id=message_id, reaction=reaction, add_reaction=True) - j = self._post(self.req_url.MESSAGE_REACTION, full_data, fix_request=True, as_json=True) + def removeReaction(self, message_id): + """ + Removes reaction from a message + + :param message_id: :ref:`Message ID ` to remove reaction from + :raises: FBchatException if request failed + """ + self._react(message_id=message_id, add_reaction=False) def createPlan(self, plan, thread_id=None): """ @@ -2032,6 +2051,7 @@ class Client(object): delta = m["delta"] delta_type = delta.get("type") + delta_class = delta.get("class") metadata = delta.get("messageMetadata") if metadata: @@ -2068,14 +2088,14 @@ class Client(object): thread_type=thread_type, ts=ts, metadata=metadata, msg=m) # Thread title change - elif delta.get("class") == "ThreadName": + elif delta_class == "ThreadName": new_title = delta["name"] thread_id, thread_type = getThreadIdAndThreadType(metadata) self.onTitleChange(mid=mid, author_id=author_id, new_title=new_title, thread_id=thread_id, thread_type=thread_type, ts=ts, metadata=metadata, msg=m) # Forced fetch - elif delta.get("class") == "ForcedFetch": + elif delta_class == "ForcedFetch": mid = delta.get("messageId") if mid is None: self.onUnknownMesssageType(msg=m) @@ -2121,7 +2141,7 @@ class Client(object): thread_id=thread_id, thread_type=thread_type, ts=ts, msg=m) # Message delivered - elif delta.get("class") == "DeliveryReceipt": + elif delta_class == "DeliveryReceipt": message_ids = delta["messageIds"] delivered_for = str(delta.get("actorFbId") or delta["threadKey"]["otherUserFbId"]) ts = int(delta["deliveredWatermarkTimestampMs"]) @@ -2131,7 +2151,7 @@ class Client(object): metadata=metadata, msg=m) # Message seen - elif delta.get("class") == "ReadReceipt": + elif delta_class == "ReadReceipt": seen_by = str(delta.get("actorFbId") or delta["threadKey"]["otherUserFbId"]) seen_ts = int(delta["actionTimestampMs"]) delivered_ts = int(delta["watermarkTimestampMs"]) @@ -2140,7 +2160,7 @@ class Client(object): seen_ts=seen_ts, ts=delivered_ts, metadata=metadata, msg=m) # Messages marked as seen - elif delta.get("class") == "MarkRead": + elif delta_class == "MarkRead": seen_ts = int(delta.get("actionTimestampMs") or delta.get("actionTimestamp")) delivered_ts = int(delta.get("watermarkTimestampMs") or delta.get("watermarkTimestamp")) @@ -2240,6 +2260,51 @@ class Client(object): self.onPlanParticipation(mid=mid, plan=plan, take_part=take_part, author_id=author_id, thread_id=thread_id, thread_type=thread_type, ts=ts, metadata=metadata, msg=m) + # Client payload (that weird numbers) + elif delta_class == "ClientPayload": + payload = json.loads("".join(chr(z) for z in delta['payload'])) + ts = m.get("ofd_ts") + for d in payload.get('deltas', []): + + # Message reaction + if d.get('deltaMessageReaction'): + i = d['deltaMessageReaction'] + thread_id, thread_type = getThreadIdAndThreadType(i) + mid = i["messageId"] + author_id = str(i["userId"]) + reaction = MessageReaction(i["reaction"]) if i.get("reaction") else None + add_reaction = not bool(i["action"]) + if add_reaction: + self.onReactionAdded(mid=mid, reaction=reaction, author_id=author_id, + thread_id=thread_id, thread_type=thread_type, ts=ts, msg=m) + else: + self.onReactionRemoved(mid=mid, author_id=author_id, thread_id=thread_id, + thread_type=thread_type, ts=ts, msg=m) + + # Viewer status change + elif d.get('deltaChangeViewerStatus'): + i = d['deltaChangeViewerStatus'] + thread_id, thread_type = getThreadIdAndThreadType(i) + author_id = str(i["actorFbid"]) + reason = i["reason"] + can_reply = i["canViewerReply"] + if reason == 2: + if can_reply: + self.onUnblock(author_id=author_id, thread_id=thread_id, thread_type=thread_type, ts=ts, msg=m) + else: + self.onBlock(author_id=author_id, thread_id=thread_id, thread_type=thread_type, ts=ts, msg=m) + + # Live location info + elif d.get('liveLocationData'): + i = d['liveLocationData'] + thread_id, thread_type = getThreadIdAndThreadType(i) + for l in i['messageLiveLocations']: + mid = l["messageId"] + author_id = l["senderId"] + location = graphql_to_live_location(l) + self.onLiveLocation(mid=mid, location=location, author_id=author_id, thread_id=thread_id, + thread_type=thread_type, ts=ts, msg=m) + # New message elif delta.get("class") == "NewMessage": mentions = [] @@ -2260,15 +2325,19 @@ class Client(object): attach_type = mercury['blob_attachment']['__typename'] attachment = graphql_to_attachment(mercury.get('blob_attachment', {})) - if attach_type == ['MessageFile', 'MessageVideo', 'MessageAudio']: + if attach_type in ['MessageFile', 'MessageVideo', 'MessageAudio']: # TODO: Add more data here for audio files attachment.size = int(a['fileSize']) attachments.append(attachment) + elif mercury.get('sticker_attachment'): sticker = graphql_to_sticker(a['mercury']['sticker_attachment']) + elif mercury.get('extensible_attachment'): - # TODO: Add more data here for shared stuff (URLs, events and so on) - pass + attachment = graphql_to_extensible_attachment(mercury.get('extensible_attachment', {})) + if attachment: + attachments.append(attachment) + except Exception: log.exception('An exception occured while reading attachments: {}'.format(delta['attachments'])) @@ -2928,6 +2997,7 @@ class Client(object): :param metadata: Extra metadata about the action :param msg: A full set of the data recieved :type plan: models.Plan + :type take_part: bool :type thread_type: models.ThreadType """ if take_part: @@ -2935,6 +3005,79 @@ class Client(object): else: log.info("{} won't take part in {} in {} ({})".format(author_id, plan, thread_id, thread_type.name)) + def onReactionAdded(self, mid=None, reaction=None, add_reaction=None, author_id=None, thread_id=None, thread_type=None, ts=None, msg=None): + """ + Called when the client is listening, and somebody reacts to a message + + :param mid: Message ID, that user reacted to + :param reaction: Reaction + :param add_reaction: Whether user added or removed reaction + :param author_id: The ID of the person who reacted to the message + :param thread_id: Thread ID that the action was sent to. See :ref:`intro_threads` + :param thread_type: Type of thread that the action was sent to. See :ref:`intro_threads` + :param ts: A timestamp of the action + :param msg: A full set of the data recieved + :type reaction: models.MessageReaction + :type thread_type: models.ThreadType + """ + log.info("{} reacted to message {} with {} in {} ({})".format(author_id, mid, reaction.name, thread_id, thread_type.name)) + + def onReactionRemoved(self, mid=None, author_id=None, thread_id=None, thread_type=None, ts=None, msg=None): + """ + Called when the client is listening, and somebody removes reaction from a message + + :param mid: Message ID, that user reacted to + :param author_id: The ID of the person who removed reaction + :param thread_id: Thread ID that the action was sent to. See :ref:`intro_threads` + :param thread_type: Type of thread that the action was sent to. See :ref:`intro_threads` + :param ts: A timestamp of the action + :param msg: A full set of the data recieved + :type thread_type: models.ThreadType + """ + log.info("{} removed reaction from {} message in {} ({})".format(author_id, mid, thread_id, thread_type)) + + def onBlock(self, author_id=None, thread_id=None, thread_type=None, ts=None, msg=None): + """ + Called when the client is listening, and somebody blocks client + + :param author_id: The ID of the person who blocked + :param thread_id: Thread ID that the action was sent to. See :ref:`intro_threads` + :param thread_type: Type of thread that the action was sent to. See :ref:`intro_threads` + :param ts: A timestamp of the action + :param msg: A full set of the data recieved + :type thread_type: models.ThreadType + """ + log.info("{} blocked {} ({}) thread".format(author_id, thread_id, thread_type.name)) + + def onUnblock(self, author_id=None, thread_id=None, thread_type=None, ts=None, msg=None): + """ + Called when the client is listening, and somebody blocks client + + :param author_id: The ID of the person who unblocked + :param thread_id: Thread ID that the action was sent to. See :ref:`intro_threads` + :param thread_type: Type of thread that the action was sent to. See :ref:`intro_threads` + :param ts: A timestamp of the action + :param msg: A full set of the data recieved + :type thread_type: models.ThreadType + """ + log.info("{} unblocked {} ({}) thread".format(author_id, thread_id, thread_type.name)) + + def onLiveLocation(self, mid=None, location=None, author_id=None, thread_id=None, thread_type=None, ts=None, msg=None): + """ + Called when the client is listening and somebody sends live location info + + :param mid: The action ID + :param location: Sent location info + :param author_id: The ID of the person who sent location info + :param thread_id: Thread ID that the action was sent to. See :ref:`intro_threads` + :param thread_type: Type of thread that the action was sent to. See :ref:`intro_threads` + :param ts: A timestamp of the action + :param msg: A full set of the data recieved + :type location: models.LiveLocationAttachment + :type thread_type: models.ThreadType + """ + log.info("{} sent live location info in {} ({}) with latitude {} and longitude {}".format(author_id, thread_id, thread_type, location.latitude, location.longitude)) + """ END EVENTS """ From 90813c959d626cde0bc9ddabedccf5c123ad107e Mon Sep 17 00:00:00 2001 From: Kacper Ziubryniewicz Date: Sat, 15 Sep 2018 11:21:35 +0200 Subject: [PATCH 04/32] Added `get_url_parameters` util method --- fbchat/utils.py | 11 +++++++++-- 1 file changed, 9 insertions(+), 2 deletions(-) diff --git a/fbchat/utils.py b/fbchat/utils.py index 92e8a9d..fd1d1ac 100644 --- a/fbchat/utils.py +++ b/fbchat/utils.py @@ -14,10 +14,10 @@ import requests from .models import * try: - from urllib.parse import urlencode + from urllib.parse import urlencode, parse_qs, urlparse basestring = (str, bytes) except ImportError: - from urllib import urlencode + from urllib import urlencode, parse_qs, urlparse basestring = basestring # Python 2's `input` executes the input, whereas `raw_input` just returns the input @@ -297,3 +297,10 @@ def get_files_from_paths(filenames): yield files for fn, fp, ft in files: fp.close() + +def get_url_parameters(url, *args): + params = parse_qs(urlparse(url).query) + return [params[arg][0] for arg in args if params.get(arg)] + +def get_url_parameter(url, param): + return get_url_parameters(url, param)[0] From 9e8fe7bc1ea4c0fee1a205257ddac426610360ec Mon Sep 17 00:00:00 2001 From: Kacper Ziubryniewicz Date: Sat, 15 Sep 2018 11:34:16 +0200 Subject: [PATCH 05/32] Fix Python 2.7 compability --- fbchat/utils.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/fbchat/utils.py b/fbchat/utils.py index fd1d1ac..7d8003f 100644 --- a/fbchat/utils.py +++ b/fbchat/utils.py @@ -17,7 +17,8 @@ try: from urllib.parse import urlencode, parse_qs, urlparse basestring = (str, bytes) except ImportError: - from urllib import urlencode, parse_qs, urlparse + from urllib import urlencode + from urlparse import parse_qs, urlparse basestring = basestring # Python 2's `input` executes the input, whereas `raw_input` just returns the input From c6dc432d06dcb3bc678ee1e64f24dcf0415fcf4f Mon Sep 17 00:00:00 2001 From: Kacper Ziubryniewicz Date: Sat, 22 Sep 2018 20:39:41 +0200 Subject: [PATCH 06/32] Move `on` methods to the right place --- fbchat/client.py | 146 +++++++++++++++++++++++------------------------ 1 file changed, 73 insertions(+), 73 deletions(-) diff --git a/fbchat/client.py b/fbchat/client.py index dae3960..aaf151e 100644 --- a/fbchat/client.py +++ b/fbchat/client.py @@ -2792,6 +2792,79 @@ class Client(object): """ log.info("{} played \"{}\" in {} ({})".format(author_id, game_name, thread_id, thread_type.name)) + def onReactionAdded(self, mid=None, reaction=None, add_reaction=None, author_id=None, thread_id=None, thread_type=None, ts=None, msg=None): + """ + Called when the client is listening, and somebody reacts to a message + + :param mid: Message ID, that user reacted to + :param reaction: Reaction + :param add_reaction: Whether user added or removed reaction + :param author_id: The ID of the person who reacted to the message + :param thread_id: Thread ID that the action was sent to. See :ref:`intro_threads` + :param thread_type: Type of thread that the action was sent to. See :ref:`intro_threads` + :param ts: A timestamp of the action + :param msg: A full set of the data recieved + :type reaction: models.MessageReaction + :type thread_type: models.ThreadType + """ + log.info("{} reacted to message {} with {} in {} ({})".format(author_id, mid, reaction.name, thread_id, thread_type.name)) + + def onReactionRemoved(self, mid=None, author_id=None, thread_id=None, thread_type=None, ts=None, msg=None): + """ + Called when the client is listening, and somebody removes reaction from a message + + :param mid: Message ID, that user reacted to + :param author_id: The ID of the person who removed reaction + :param thread_id: Thread ID that the action was sent to. See :ref:`intro_threads` + :param thread_type: Type of thread that the action was sent to. See :ref:`intro_threads` + :param ts: A timestamp of the action + :param msg: A full set of the data recieved + :type thread_type: models.ThreadType + """ + log.info("{} removed reaction from {} message in {} ({})".format(author_id, mid, thread_id, thread_type)) + + def onBlock(self, author_id=None, thread_id=None, thread_type=None, ts=None, msg=None): + """ + Called when the client is listening, and somebody blocks client + + :param author_id: The ID of the person who blocked + :param thread_id: Thread ID that the action was sent to. See :ref:`intro_threads` + :param thread_type: Type of thread that the action was sent to. See :ref:`intro_threads` + :param ts: A timestamp of the action + :param msg: A full set of the data recieved + :type thread_type: models.ThreadType + """ + log.info("{} blocked {} ({}) thread".format(author_id, thread_id, thread_type.name)) + + def onUnblock(self, author_id=None, thread_id=None, thread_type=None, ts=None, msg=None): + """ + Called when the client is listening, and somebody blocks client + + :param author_id: The ID of the person who unblocked + :param thread_id: Thread ID that the action was sent to. See :ref:`intro_threads` + :param thread_type: Type of thread that the action was sent to. See :ref:`intro_threads` + :param ts: A timestamp of the action + :param msg: A full set of the data recieved + :type thread_type: models.ThreadType + """ + log.info("{} unblocked {} ({}) thread".format(author_id, thread_id, thread_type.name)) + + def onLiveLocation(self, mid=None, location=None, author_id=None, thread_id=None, thread_type=None, ts=None, msg=None): + """ + Called when the client is listening and somebody sends live location info + + :param mid: The action ID + :param location: Sent location info + :param author_id: The ID of the person who sent location info + :param thread_id: Thread ID that the action was sent to. See :ref:`intro_threads` + :param thread_type: Type of thread that the action was sent to. See :ref:`intro_threads` + :param ts: A timestamp of the action + :param msg: A full set of the data recieved + :type location: models.LiveLocationAttachment + :type thread_type: models.ThreadType + """ + log.info("{} sent live location info in {} ({}) with latitude {} and longitude {}".format(author_id, thread_id, thread_type, location.latitude, location.longitude)) + def onQprimer(self, ts=None, msg=None): """ Called when the client just started listening @@ -3005,79 +3078,6 @@ class Client(object): else: log.info("{} won't take part in {} in {} ({})".format(author_id, plan, thread_id, thread_type.name)) - def onReactionAdded(self, mid=None, reaction=None, add_reaction=None, author_id=None, thread_id=None, thread_type=None, ts=None, msg=None): - """ - Called when the client is listening, and somebody reacts to a message - - :param mid: Message ID, that user reacted to - :param reaction: Reaction - :param add_reaction: Whether user added or removed reaction - :param author_id: The ID of the person who reacted to the message - :param thread_id: Thread ID that the action was sent to. See :ref:`intro_threads` - :param thread_type: Type of thread that the action was sent to. See :ref:`intro_threads` - :param ts: A timestamp of the action - :param msg: A full set of the data recieved - :type reaction: models.MessageReaction - :type thread_type: models.ThreadType - """ - log.info("{} reacted to message {} with {} in {} ({})".format(author_id, mid, reaction.name, thread_id, thread_type.name)) - - def onReactionRemoved(self, mid=None, author_id=None, thread_id=None, thread_type=None, ts=None, msg=None): - """ - Called when the client is listening, and somebody removes reaction from a message - - :param mid: Message ID, that user reacted to - :param author_id: The ID of the person who removed reaction - :param thread_id: Thread ID that the action was sent to. See :ref:`intro_threads` - :param thread_type: Type of thread that the action was sent to. See :ref:`intro_threads` - :param ts: A timestamp of the action - :param msg: A full set of the data recieved - :type thread_type: models.ThreadType - """ - log.info("{} removed reaction from {} message in {} ({})".format(author_id, mid, thread_id, thread_type)) - - def onBlock(self, author_id=None, thread_id=None, thread_type=None, ts=None, msg=None): - """ - Called when the client is listening, and somebody blocks client - - :param author_id: The ID of the person who blocked - :param thread_id: Thread ID that the action was sent to. See :ref:`intro_threads` - :param thread_type: Type of thread that the action was sent to. See :ref:`intro_threads` - :param ts: A timestamp of the action - :param msg: A full set of the data recieved - :type thread_type: models.ThreadType - """ - log.info("{} blocked {} ({}) thread".format(author_id, thread_id, thread_type.name)) - - def onUnblock(self, author_id=None, thread_id=None, thread_type=None, ts=None, msg=None): - """ - Called when the client is listening, and somebody blocks client - - :param author_id: The ID of the person who unblocked - :param thread_id: Thread ID that the action was sent to. See :ref:`intro_threads` - :param thread_type: Type of thread that the action was sent to. See :ref:`intro_threads` - :param ts: A timestamp of the action - :param msg: A full set of the data recieved - :type thread_type: models.ThreadType - """ - log.info("{} unblocked {} ({}) thread".format(author_id, thread_id, thread_type.name)) - - def onLiveLocation(self, mid=None, location=None, author_id=None, thread_id=None, thread_type=None, ts=None, msg=None): - """ - Called when the client is listening and somebody sends live location info - - :param mid: The action ID - :param location: Sent location info - :param author_id: The ID of the person who sent location info - :param thread_id: Thread ID that the action was sent to. See :ref:`intro_threads` - :param thread_type: Type of thread that the action was sent to. See :ref:`intro_threads` - :param ts: A timestamp of the action - :param msg: A full set of the data recieved - :type location: models.LiveLocationAttachment - :type thread_type: models.ThreadType - """ - log.info("{} sent live location info in {} ({}) with latitude {} and longitude {}".format(author_id, thread_id, thread_type, location.latitude, location.longitude)) - """ END EVENTS """ From d807648d2bf31186be2a71254a40dc962182443e Mon Sep 17 00:00:00 2001 From: Kacper Ziubryniewicz Date: Mon, 24 Sep 2018 16:50:15 +0200 Subject: [PATCH 07/32] `fetchThreadList` fix --- fbchat/graphql.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/fbchat/graphql.py b/fbchat/graphql.py index f023a16..8fc8b6e 100644 --- a/fbchat/graphql.py +++ b/fbchat/graphql.py @@ -302,8 +302,8 @@ def graphql_to_group(group): color=c_info.get('color'), emoji=c_info.get('emoji'), admins = set([node.get('id') for node in group.get('thread_admins')]), - approval_mode = bool(group.get('approval_mode')), - approval_requests = set(node["requester"]['id'] for node in group['group_approval_queue']['nodes']), + approval_mode = bool(group.get('approval_mode')) if group.get('approval_mode') is not None else None, + approval_requests = set(node["requester"]['id'] for node in group['group_approval_queue']['nodes']) if group.get('group_approval_queue') else None, join_link = group['joinable_mode'].get('link'), photo=group['image'].get('uri'), name=group.get('name'), From 576e0949e02f24abd1cc4294ce037fa6dfa8a373 Mon Sep 17 00:00:00 2001 From: Kacper Ziubryniewicz Date: Mon, 24 Sep 2018 20:32:04 +0200 Subject: [PATCH 08/32] New `read_by` attribute in `Message` --- fbchat/models.py | 3 +++ 1 file changed, 3 insertions(+) diff --git a/fbchat/models.py b/fbchat/models.py index cb4f678..7cc9d65 100644 --- a/fbchat/models.py +++ b/fbchat/models.py @@ -180,6 +180,8 @@ class Message(object): timestamp = None #: Whether the message is read is_read = None + #: A list of pepole IDs who read the message, works only with :func:`fbchat.Client.fetchThreadMessages` + read_by = None #: A dict with user's IDs as keys, and their :class:`MessageReaction` as values reactions = None #: The actual message @@ -201,6 +203,7 @@ class Message(object): attachments = [] self.attachments = attachments self.reactions = {} + self.read_by = [] def __repr__(self): return self.__unicode__() From bad9c7a4b9fe893713e4080badf228bf84cba2f5 Mon Sep 17 00:00:00 2001 From: Kacper Ziubryniewicz Date: Mon, 24 Sep 2018 20:33:43 +0200 Subject: [PATCH 09/32] `read_by` handling --- fbchat/client.py | 12 ++++++++++-- 1 file changed, 10 insertions(+), 2 deletions(-) diff --git a/fbchat/client.py b/fbchat/client.py index 69c858d..f3e629f 100644 --- a/fbchat/client.py +++ b/fbchat/client.py @@ -850,14 +850,22 @@ class Client(object): 'id': thread_id, 'message_limit': limit, 'load_messages': True, - 'load_read_receipts': False, + 'load_read_receipts': True, 'before': before })) if j.get('message_thread') is None: raise FBchatException('Could not fetch thread {}: {}'.format(thread_id, j)) - return list(reversed([graphql_to_message(message) for message in j['message_thread']['messages']['nodes']])) + messages = list(reversed([graphql_to_message(message) for message in j['message_thread']['messages']['nodes']])) + read_receipts = j['message_thread']['read_receipts']['nodes'] + + for message in messages: + for receipt in read_receipts: + if int(receipt['watermark']) >= int(message.timestamp): + message.read_by.append(receipt['actor']['id']) + + return messages def fetchThreadList(self, offset=None, limit=20, thread_location=ThreadLocation.INBOX, before=None): """Get thread list of your facebook account From 2ea2c89b4a3d914f6de0c5506cd8a06616fe55b9 Mon Sep 17 00:00:00 2001 From: Mads Marquart Date: Thu, 27 Sep 2018 21:44:04 +0200 Subject: [PATCH 10/32] Fixed `markAsRead` and `markAsUnread`, fixes #336 --- fbchat/client.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/fbchat/client.py b/fbchat/client.py index f3e629f..4f314f5 100644 --- a/fbchat/client.py +++ b/fbchat/client.py @@ -1723,7 +1723,7 @@ class Client(object): } for thread_id in thread_ids: - data["ids[{}]".format(thread_id)] = read + data["ids[{}]".format(thread_id)] = 'true' if read else 'false' r = self._post(self.req_url.READ_STATUS, data) return r.ok From b4b8914448038e1c766ea37cc51171bd64a662d0 Mon Sep 17 00:00:00 2001 From: Mads Marquart Date: Thu, 27 Sep 2018 21:53:12 +0200 Subject: [PATCH 11/32] Version up, thanks to @kapi2289 --- fbchat/__init__.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/fbchat/__init__.py b/fbchat/__init__.py index 25dd5d4..4cff815 100644 --- a/fbchat/__init__.py +++ b/fbchat/__init__.py @@ -15,7 +15,7 @@ from __future__ import unicode_literals from .client import * __title__ = 'fbchat' -__version__ = '1.4.0' +__version__ = '1.4.1' __description__ = 'Facebook Chat (Messenger) for Python' __copyright__ = 'Copyright 2015 - 2018 by Taehoon Kim' From 8169a5f776f37625aad4d9fb3204b7d17c61383c Mon Sep 17 00:00:00 2001 From: Kacper Ziubryniewicz Date: Sat, 29 Sep 2018 13:40:38 +0200 Subject: [PATCH 12/32] Changed `LocationAttachment` --- fbchat/models.py | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/fbchat/models.py b/fbchat/models.py index e25ca31..cc06564 100644 --- a/fbchat/models.py +++ b/fbchat/models.py @@ -302,13 +302,11 @@ class LocationAttachment(Attachment): #: URL to Bing maps with the location url = None - def __init__(self, latitude=None, longitude=None, image=None, url=None, **kwargs): + def __init__(self, latitude=None, longitude=None, **kwargs): """Represents a user location""" super(LocationAttachment, self).__init__(**kwargs) self.latitude = latitude self.longitude = longitude - self.image = image - self.url = url class LiveLocationAttachment(LocationAttachment): #: Name of the location From b0bf5ba8e00fe0d180c2b17a1821b8747ef532ee Mon Sep 17 00:00:00 2001 From: Kacper Ziubryniewicz Date: Sat, 29 Sep 2018 13:42:11 +0200 Subject: [PATCH 13/32] Update graphql.py --- fbchat/graphql.py | 24 ++++++++++++++---------- 1 file changed, 14 insertions(+), 10 deletions(-) diff --git a/fbchat/graphql.py b/fbchat/graphql.py index 72cfb13..ac5b859 100644 --- a/fbchat/graphql.py +++ b/fbchat/graphql.py @@ -134,32 +134,38 @@ def graphql_to_extensible_attachment(a): _type = story['target']['__typename'] if _type == 'MessageLocation': latitude, longitude = get_url_parameter(get_url_parameter(story['url'], 'u'), 'where1').split(", ") - return LocationAttachment( + rtn = LocationAttachment( uid=int(story['deduplication_key']), latitude=float(latitude), longitude=float(longitude), - image=story['media']['image']['uri'], - url=story['url'], ) + rtn.image_url = story['media']['image']['uri'] + rtn.image_width = story['media']['image']['width'] + rtn.image_height = story['media']['image']['height'] + rtn.url = story['url'] + return rtn elif _type == 'MessageLiveLocation': - return LiveLocationAttachment( + rtn = LiveLocationAttachment( uid=int(story['target']['live_location_id']), latitude=story['target']['coordinate']['latitude'] if story['target'].get('coordinate') else None, longitude=story['target']['coordinate']['longitude'] if story['target'].get('coordinate') else None, - image=story['media']['image']['uri'] if story.get('media') else None, - url=story['url'], name=story['title_with_entities']['text'], expiration_time=story['target']['expiration_time'] if story['target'].get('expiration_time') else None, is_expired=story['target']['is_expired'], ) + rtn.image_url = story['media']['image']['uri'] + rtn.image_width = story['media']['image']['width'] + rtn.image_height = story['media']['image']['height'] + rtn.url = story['url'] + return rtn elif _type in ['ExternalUrl', 'Story']: return ShareAttachment( uid=a.get('legacy_attachment_id'), author=story['target']['actors'][0]['id'] if story['target'].get('actors') else None, url=story['url'], original_url=get_url_parameter(story['url'], 'u') if "/l.php?u=" in story['url'] else story['url'], - title=story['title_with_entities']['text'], - description=story['description']['text'], + title=story['title_with_entities'].get('text'), + description=story['description'].get('text'), source=story['source']['text'], image_url=story['media']['image']['uri'] if story.get('media') else None, original_image_url=(get_url_parameter(story['media']['image']['uri'], 'url') if "/safe_image.php" in story['media']['image']['uri'] else story['media']['image']['uri']) if story.get('media') else None, @@ -175,9 +181,7 @@ def graphql_to_subattachment(a): return VideoAttachment( duration=a['media'].get('playable_duration_in_ms'), preview_url=a['media'].get('playable_url'), - small_image=a['media'].get('image'), medium_image=a['media'].get('image'), - large_image=a['media'].get('image'), uid=a['target'].get('video_id'), ) From b7ea8e600192e4bd300a0087cb0b2d1fd4be4f33 Mon Sep 17 00:00:00 2001 From: Kacper Ziubryniewicz Date: Sat, 29 Sep 2018 13:48:08 +0200 Subject: [PATCH 14/32] New `sendLocation` method --- fbchat/client.py | 30 +++++++++++++++++++++++++----- 1 file changed, 25 insertions(+), 5 deletions(-) diff --git a/fbchat/client.py b/fbchat/client.py index aaf151e..7693783 100644 --- a/fbchat/client.py +++ b/fbchat/client.py @@ -1116,6 +1116,26 @@ class Client(object): data['specific_to_list[0]'] = "fbid:{}".format(thread_id) return self._doSendRequest(data) + def sendLocation(self, location, thread_id=None, thread_type=None): + """ + Sends a given location to a thread + + :param location: Location to send + :param thread_id: User/Group ID to send to. See :ref:`intro_threads` + :param thread_type: See :ref:`intro_threads` + :type location: models.LocationAttachment + :type thread_type: models.ThreadType + :return: :ref:`Message ID ` of the sent message + :raises: FBchatException if request failed + """ + thread_id, thread_type = self._getThread(thread_id, thread_type) + data = self._getSendData(thread_id=thread_id, thread_type=thread_type) + data['action_type'] = 'ma-type:user-generated-message' + data['location_attachment[coordinates][latitude]'] = location.latitude + data['location_attachment[coordinates][longitude]'] = location.longitude + data['location_attachment[is_current_location]'] = True + return self._doSendRequest(data) + def _upload(self, files): """ Uploads files to Facebook @@ -1485,16 +1505,16 @@ class Client(object): j = self._post(self.req_url.THREAD_EMOJI, data, fix_request=True, as_json=True) - def _react(self, message_id, reaction=None, add_reaction=True): + def _react(self, message_id, reaction=None): data = { "doc_id": 1491398900900362, "variables": json.dumps({ "data": { - "action": "ADD_REACTION" if add_reaction else "REMOVE_REACTION", + "action": "ADD_REACTION" if reaction else "REMOVE_REACTION", "client_mutation_id": "1", "actor_id": self.uid, "message_id": str(message_id), - "reaction": reaction.value if add_reaction else None + "reaction": reaction.value if reaction else None } }) } @@ -1517,7 +1537,7 @@ class Client(object): :type reaction: models.MessageReaction :raises: FBchatException if request failed """ - self._react(message_id=message_id, reaction=reaction, add_reaction=True) + self._react(message_id=message_id, reaction=reaction) def removeReaction(self, message_id): """ @@ -1526,7 +1546,7 @@ class Client(object): :param message_id: :ref:`Message ID ` to remove reaction from :raises: FBchatException if request failed """ - self._react(message_id=message_id, add_reaction=False) + self._react(message_id=message_id) def createPlan(self, plan, thread_id=None): """ From 153dc0bdad5a1db14add712008773af149f1ef4c Mon Sep 17 00:00:00 2001 From: Kacper Ziubryniewicz Date: Sun, 7 Oct 2018 16:27:19 +0200 Subject: [PATCH 15/32] Remove unnecessary code --- fbchat/client.py | 16 ---------------- 1 file changed, 16 deletions(-) diff --git a/fbchat/client.py b/fbchat/client.py index 69c858d..c08506c 100644 --- a/fbchat/client.py +++ b/fbchat/client.py @@ -238,22 +238,6 @@ class Client(object): self.payloadDefault['ttstamp'] = self.ttstamp self.payloadDefault['fb_dtsg'] = self.fb_dtsg - self.form = { - 'channel' : self.user_channel, - 'partition' : '-2', - 'clientid' : self.client_id, - 'viewer_uid' : self.uid, - 'uid' : self.uid, - 'state' : 'active', - 'format' : 'json', - 'idle' : 0, - 'cap' : '8' - } - - self.prev = now() - self.tmp_prev = now() - self.last_sync = now() - def _login(self): if not (self.email and self.password): raise FBchatUserError("Email and password not found.") From d180650c1b2110a81ea8b6802653093f67f78b28 Mon Sep 17 00:00:00 2001 From: ekohilas Date: Thu, 25 Oct 2018 18:18:19 +1100 Subject: [PATCH 16/32] spellchecked docs --- docs/api.rst | 2 +- docs/examples.rst | 2 +- docs/faq.rst | 2 +- docs/intro.rst | 8 ++++---- docs/testing.rst | 2 +- 5 files changed, 8 insertions(+), 8 deletions(-) diff --git a/docs/api.rst b/docs/api.rst index e08f19b..afebc78 100644 --- a/docs/api.rst +++ b/docs/api.rst @@ -13,7 +13,7 @@ If you are looking for information on a specific function, class, or method, thi Client ------ -This is the main class of `fbchat`, which contains all the methods you use to interract with Facebook. +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 events, to provide custom event handling (mainly used while listening) .. autoclass:: Client(email, password, user_agent=None, max_tries=5, session_cookies=None, logging_level=logging.INFO) diff --git a/docs/examples.rst b/docs/examples.rst index 89da9dc..9b4e682 100644 --- a/docs/examples.rst +++ b/docs/examples.rst @@ -18,7 +18,7 @@ This will show basic usage of `fbchat` Interacting with Threads ------------------------ -This will interract with the thread in every way `fbchat` supports +This will interact with the thread in every way `fbchat` supports .. literalinclude:: ../examples/interract.py diff --git a/docs/faq.rst b/docs/faq.rst index 16b8c59..d4bcc1a 100644 --- a/docs/faq.rst +++ b/docs/faq.rst @@ -8,7 +8,7 @@ FAQ Version X broke my installation ------------------------------- -We try to provide backwards compatability where possible, but since we're not part of Facebook, +We try to provide backwards compatibility where possible, but since we're not part of Facebook, most of the things may be broken at any point in time Downgrade to an earlier version of fbchat, run this command diff --git a/docs/intro.rst b/docs/intro.rst index 6f5d58e..8fabddd 100644 --- a/docs/intro.rst +++ b/docs/intro.rst @@ -6,7 +6,7 @@ Introduction ============ `fbchat` uses your email and password to communicate with the Facebook server. -That means that you should always store your password in a seperate file, in case e.g. someone looks over your shoulder while you're writing code. +That means that you should always store your password in a separate file, in case e.g. someone looks over your shoulder while you're writing code. You should also make sure that the file's access control is appropriately restrictive @@ -16,7 +16,7 @@ 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 fasion, overwrite :func:`Client.on2FACode`):: +(If you want to supply the code in another fashion, overwrite :func:`Client.on2FACode`):: from fbchat import Client from fbchat.models import * @@ -50,7 +50,7 @@ A thread can refer to two things: A Messenger group chat or a single Facebook us :class:`models.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 differetiates between these two internally +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.searchForGroups`, and searching for users is possible via. :func:`Client.searchForUsers`. See :ref:`intro_fetching` @@ -141,7 +141,7 @@ Sessions -------- `fbchat` provides functions to retrieve and set the session cookies. -This will enable you to store the session cookies in a seperate file, so that you don't have to login each time you start your script. +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.getSession` to retrieve the cookies:: session_cookies = client.getSession() diff --git a/docs/testing.rst b/docs/testing.rst index a3b2f13..f7d01a0 100644 --- a/docs/testing.rst +++ b/docs/testing.rst @@ -15,7 +15,7 @@ To use the tests, copy ``tests/data.json`` to ``tests/my_data.json`` or type the Please remember to test all supported python versions. If you've made any changes to the 2FA functionality, test it with a 2FA enabled account. -If you only want to execute specific tests, pass the function names in the commandline (not including the `test_` prefix). Example:: +If you only want to execute specific tests, pass the function names in the command line (not including the `test_` prefix). Example:: $ python tests.py sendMessage sessions sendEmoji From 6c829581afb68b41097ba06d4ea70e715c690ee3 Mon Sep 17 00:00:00 2001 From: ekohilas Date: Sat, 27 Oct 2018 01:50:03 +1100 Subject: [PATCH 17/32] completed todo for graphql_requests --- fbchat/client.py | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/fbchat/client.py b/fbchat/client.py index 95effe6..9481e4a 100644 --- a/fbchat/client.py +++ b/fbchat/client.py @@ -168,10 +168,12 @@ class Client(object): def graphql_requests(self, *queries): """ - .. todo:: - Documenting this + :param queries: Zero or more GraphQL objects + :type queries: GraphQL :raises: FBchatException if request failed + :return: A tuple containing json graphql queries + :rtype: tuple """ return tuple(self._graphql({ @@ -441,7 +443,8 @@ class Client(object): return given_thread_id, given_thread_type def setDefaultThread(self, thread_id, thread_type): - """Sets default thread to send messages to + """ + Sets default thread to send messages to :param thread_id: User/Group ID to default to. See :ref:`intro_threads` :param thread_type: See :ref:`intro_threads` From 1ac569badd40361bb3283a5f3bd4e0090686174b Mon Sep 17 00:00:00 2001 From: Kacper Ziubryniewicz Date: Tue, 30 Oct 2018 22:21:05 +0100 Subject: [PATCH 18/32] Sending pinned or current location --- fbchat/client.py | 35 ++++++++++++++++++++++++++--------- 1 file changed, 26 insertions(+), 9 deletions(-) diff --git a/fbchat/client.py b/fbchat/client.py index 7693783..8f588ec 100644 --- a/fbchat/client.py +++ b/fbchat/client.py @@ -1116,9 +1116,18 @@ class Client(object): data['specific_to_list[0]'] = "fbid:{}".format(thread_id) return self._doSendRequest(data) + def _sendLocation(self, location, current=True, thread_id=None, thread_type=None): + thread_id, thread_type = self._getThread(thread_id, thread_type) + data = self._getSendData(thread_id=thread_id, thread_type=thread_type) + data['action_type'] = 'ma-type:user-generated-message' + data['location_attachment[coordinates][latitude]'] = location.latitude + data['location_attachment[coordinates][longitude]'] = location.longitude + data['location_attachment[is_current_location]'] = current + return self._doSendRequest(data) + def sendLocation(self, location, thread_id=None, thread_type=None): """ - Sends a given location to a thread + Sends a given location to a thread as the user's current location :param location: Location to send :param thread_id: User/Group ID to send to. See :ref:`intro_threads` @@ -1128,13 +1137,21 @@ class Client(object): :return: :ref:`Message ID ` of the sent message :raises: FBchatException if request failed """ - thread_id, thread_type = self._getThread(thread_id, thread_type) - data = self._getSendData(thread_id=thread_id, thread_type=thread_type) - data['action_type'] = 'ma-type:user-generated-message' - data['location_attachment[coordinates][latitude]'] = location.latitude - data['location_attachment[coordinates][longitude]'] = location.longitude - data['location_attachment[is_current_location]'] = True - return self._doSendRequest(data) + self._sendLocation(location=location, current=True, thread_id=thread_id, thread_type=thread_type) + + def sendPinnedLocation(self, location, thread_id=None, thread_type=None): + """ + Sends a given location to a thread as a pinned location + + :param location: Location to send + :param thread_id: User/Group ID to send to. See :ref:`intro_threads` + :param thread_type: See :ref:`intro_threads` + :type location: models.LocationAttachment + :type thread_type: models.ThreadType + :return: :ref:`Message ID ` of the sent message + :raises: FBchatException if request failed + """ + self._sendLocation(location=location, current=False, thread_id=thread_id, thread_type=thread_type) def _upload(self, files): """ @@ -2812,7 +2829,7 @@ class Client(object): """ log.info("{} played \"{}\" in {} ({})".format(author_id, game_name, thread_id, thread_type.name)) - def onReactionAdded(self, mid=None, reaction=None, add_reaction=None, author_id=None, thread_id=None, thread_type=None, ts=None, msg=None): + def onReactionAdded(self, mid=None, reaction=None, author_id=None, thread_id=None, thread_type=None, ts=None, msg=None): """ Called when the client is listening, and somebody reacts to a message From 8739318101dd684edd828ec8b7815f3ccb3ebb86 Mon Sep 17 00:00:00 2001 From: Kacper Ziubryniewicz Date: Tue, 30 Oct 2018 22:24:47 +0100 Subject: [PATCH 19/32] Sending voice clips --- fbchat/client.py | 44 +++++++++++++++++++++++++++++++++++++++++--- 1 file changed, 41 insertions(+), 3 deletions(-) diff --git a/fbchat/client.py b/fbchat/client.py index 8f588ec..3b28f2d 100644 --- a/fbchat/client.py +++ b/fbchat/client.py @@ -1153,7 +1153,7 @@ class Client(object): """ self._sendLocation(location=location, current=False, thread_id=thread_id, thread_type=thread_type) - def _upload(self, files): + def _upload(self, files, voice_clip=False): """ Uploads files to Facebook @@ -1163,7 +1163,12 @@ class Client(object): Returns a list of tuples with a file's ID and mimetype """ file_dict = {'upload_{}'.format(i): f for i, f in enumerate(files)} - j = self._postFile(self.req_url.UPLOAD, files=file_dict, fix_request=True, as_json=True) + + data = { + "voice_clip": voice_clip, + } + + j = self._postFile(self.req_url.UPLOAD, files=file_dict, query=data, fix_request=True, as_json=True) if len(j['payload']['metadata']) != len(files): raise FBchatException("Some files could not be uploaded: {}, {}".format(j, files)) @@ -1207,7 +1212,7 @@ class Client(object): """ Sends local files to a thread - :param file_path: Paths of files to upload and send + :param file_paths: Paths of files to upload and send :param message: Additional message :param thread_id: User/Group ID to send to. See :ref:`intro_threads` :param thread_type: See :ref:`intro_threads` @@ -1220,6 +1225,39 @@ class Client(object): files = self._upload(x) return self._sendFiles(files=files, message=message, thread_id=thread_id, thread_type=thread_type) + def sendRemoteVoiceClips(self, clip_urls, message=None, thread_id=None, thread_type=ThreadType.USER): + """ + Sends voice clips from URLs to a thread + + :param clip_urls: URLs of clips to upload and send + :param message: Additional message + :param thread_id: User/Group ID to send to. See :ref:`intro_threads` + :param thread_type: See :ref:`intro_threads` + :type thread_type: models.ThreadType + :return: :ref:`Message ID ` of the sent files + :raises: FBchatException if request failed + """ + clip_urls = require_list(clip_urls) + files = self._upload(get_files_from_urls(clip_urls), voice_clip=True) + return self._sendFiles(files=files, message=message, thread_id=thread_id, thread_type=thread_type) + + def sendLocalVoiceClips(self, clip_paths, message=None, thread_id=None, thread_type=ThreadType.USER): + """ + Sends local voice clips to a thread + + :param clip_paths: Paths of clips to upload and send + :param message: Additional message + :param thread_id: User/Group ID to send to. See :ref:`intro_threads` + :param thread_type: See :ref:`intro_threads` + :type thread_type: models.ThreadType + :return: :ref:`Message ID ` of the sent files + :raises: FBchatException if request failed + """ + clip_paths = require_list(clip_paths) + with get_files_from_paths(clip_paths) as x: + files = self._upload(x, voice_clip=True) + return self._sendFiles(files=files, message=message, thread_id=thread_id, thread_type=thread_type) + def sendImage(self, image_id, message=None, thread_id=None, thread_type=ThreadType.USER, is_gif=False): """ Deprecated. Use :func:`fbchat.Client._sendFiles` instead From 64bdde8f3316cebda333da9624dbbe5e565231b6 Mon Sep 17 00:00:00 2001 From: Mads Marquart Date: Wed, 7 Nov 2018 20:06:10 +0100 Subject: [PATCH 20/32] Sticky and pool parameters can be set after the inital `_fetchSticky` --- fbchat/client.py | 39 +++++++++++++-------------------------- 1 file changed, 13 insertions(+), 26 deletions(-) diff --git a/fbchat/client.py b/fbchat/client.py index 9481e4a..022850b 100644 --- a/fbchat/client.py +++ b/fbchat/client.py @@ -1956,48 +1956,32 @@ class Client(object): LISTEN METHODS """ - def _ping(self, sticky, pool): + def _ping(self): data = { 'channel': self.user_channel, 'clientid': self.client_id, 'partition': -2, 'cap': 0, 'uid': self.uid, - 'sticky_token': sticky, - 'sticky_pool': pool, + 'sticky_token': self.sticky, + 'sticky_pool': self.pool, 'viewer_uid': self.uid, 'state': 'active', } self._get(self.req_url.PING, data, fix_request=True, as_json=False) - def _fetchSticky(self): - """Call pull api to get sticky and pool parameter, newer api needs these parameters to work""" - - data = { - "msgs_recv": 0, - "channel": self.user_channel, - "clientid": self.client_id - } - - j = self._get(self.req_url.STICKY, data, fix_request=True, as_json=True) - - if j.get('lb_info') is None: - raise FBchatException('Missing lb_info: {}'.format(j)) - - return j['lb_info']['sticky'], j['lb_info']['pool'] - - def _pullMessage(self, sticky, pool, markAlive=True): + def _pullMessage(self, markAlive=True): """Call pull api with seq value to get message data.""" data = { "msgs_recv": 0, - "sticky_token": sticky, - "sticky_pool": pool, + "sticky_token": self.sticky, + "sticky_pool": self.pool, "clientid": self.client_id, 'state': 'active' if markAlive else 'offline', } - j = self._get(ReqUrl.STICKY, data, fix_request=True, as_json=True) + j = self._get(self.req_url.STICKY, data, fix_request=True, as_json=True) self.seq = j.get('seq', '0') return j @@ -2005,6 +1989,10 @@ class Client(object): def _parseMessage(self, content): """Get message and author name from content. May contain multiple messages in the content.""" + if 'lb_info' in content: + self.sticky = content['lb_info']['sticky'] + self.pool = content['lb_info']['pool'] + if 'ms' not in content: return for m in content["ms"]: @@ -2350,7 +2338,6 @@ class Client(object): :raises: FBchatException if request failed """ self.listening = True - self.sticky, self.pool = self._fetchSticky() def doOneListen(self, markAlive=True): """ @@ -2364,8 +2351,8 @@ class Client(object): """ try: if markAlive: - self._ping(self.sticky, self.pool) - content = self._pullMessage(self.sticky, self.pool, markAlive) + self._ping() + content = self._pullMessage(markAlive) if content: self._parseMessage(content) except KeyboardInterrupt: From 160386be62599b689a4c01b5bac1a55ccb553e0a Mon Sep 17 00:00:00 2001 From: Mads Marquart Date: Fri, 9 Nov 2018 20:08:26 +0100 Subject: [PATCH 21/32] Added support for `request_batch` parsing in `_parseMessage` --- fbchat/client.py | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/fbchat/client.py b/fbchat/client.py index 022850b..e6e04fc 100644 --- a/fbchat/client.py +++ b/fbchat/client.py @@ -1993,6 +1993,10 @@ class Client(object): self.sticky = content['lb_info']['sticky'] self.pool = content['lb_info']['pool'] + if 'batches' in content: + for batch in content['batches']: + self._parseMessage(batch) + if 'ms' not in content: return for m in content["ms"]: From d32b7b612aa572f28b41e993c989f2355255b852 Mon Sep 17 00:00:00 2001 From: Vivek Kaushal Date: Fri, 7 Dec 2018 21:26:48 +0530 Subject: [PATCH 22/32] Fix for issue #365 --- fbchat/client.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/fbchat/client.py b/fbchat/client.py index 9481e4a..90469c2 100644 --- a/fbchat/client.py +++ b/fbchat/client.py @@ -502,7 +502,7 @@ class Client(object): return users - def searchForUsers(self, name, limit=1): + def searchForUsers(self, name, limit=10): """ Find and get user by his/her name From 41bbe18e3d2f2dceced0c7432d5ad6cb60ff4cca Mon Sep 17 00:00:00 2001 From: Kacper Ziubryniewicz Date: Sun, 9 Dec 2018 14:36:23 +0100 Subject: [PATCH 23/32] Unsending messages --- fbchat/client.py | 12 ++++++++++++ fbchat/utils.py | 1 + 2 files changed, 13 insertions(+) diff --git a/fbchat/client.py b/fbchat/client.py index 3b28f2d..795b280 100644 --- a/fbchat/client.py +++ b/fbchat/client.py @@ -1116,6 +1116,18 @@ class Client(object): data['specific_to_list[0]'] = "fbid:{}".format(thread_id) return self._doSendRequest(data) + def unsend(self, mid): + """ + Unsends a message (removes for everyone) + + :param mid: :ref:`Message ID ` of the message to unsend + """ + data = { + 'message_id': mid, + } + r = self._post(self.req_url.UNSEND, data) + r.raise_for_status() + def _sendLocation(self, location, current=True, thread_id=None, thread_type=None): thread_id, thread_type = self._getThread(thread_id, thread_type) data = self._getSendData(thread_id=thread_id, thread_type=thread_type) diff --git a/fbchat/utils.py b/fbchat/utils.py index 7d8003f..c39e652 100644 --- a/fbchat/utils.py +++ b/fbchat/utils.py @@ -141,6 +141,7 @@ class ReqUrl(object): GET_POLL_OPTIONS = "https://www.facebook.com/ajax/mercury/get_poll_options" SEARCH_MESSAGES = "https://www.facebook.com/ajax/mercury/search_snippets.php?dpr=1" MARK_SPAM = "https://www.facebook.com/ajax/mercury/mark_spam.php?dpr=1" + UNSEND = "https://www.facebook.com/messaging/unsend_message/?dpr=1" pull_channel = 0 From 861f17bc4dac009631580b6f884184426995903e Mon Sep 17 00:00:00 2001 From: Kacper Ziubryniewicz Date: Sun, 9 Dec 2018 14:55:10 +0100 Subject: [PATCH 24/32] Added DeletedMessage attachment --- fbchat/graphql.py | 86 +++++++++++++++++++++++++---------------------- fbchat/models.py | 6 ++++ 2 files changed, 52 insertions(+), 40 deletions(-) diff --git a/fbchat/graphql.py b/fbchat/graphql.py index ac5b859..b6c1ae2 100644 --- a/fbchat/graphql.py +++ b/fbchat/graphql.py @@ -131,47 +131,53 @@ def graphql_to_attachment(a): def graphql_to_extensible_attachment(a): story = a.get('story_attachment') if story: - _type = story['target']['__typename'] - if _type == 'MessageLocation': - latitude, longitude = get_url_parameter(get_url_parameter(story['url'], 'u'), 'where1').split(", ") - rtn = LocationAttachment( - uid=int(story['deduplication_key']), - latitude=float(latitude), - longitude=float(longitude), - ) - rtn.image_url = story['media']['image']['uri'] - rtn.image_width = story['media']['image']['width'] - rtn.image_height = story['media']['image']['height'] - rtn.url = story['url'] - return rtn - elif _type == 'MessageLiveLocation': - rtn = LiveLocationAttachment( - uid=int(story['target']['live_location_id']), - latitude=story['target']['coordinate']['latitude'] if story['target'].get('coordinate') else None, - longitude=story['target']['coordinate']['longitude'] if story['target'].get('coordinate') else None, - name=story['title_with_entities']['text'], - expiration_time=story['target']['expiration_time'] if story['target'].get('expiration_time') else None, - is_expired=story['target']['is_expired'], - ) - rtn.image_url = story['media']['image']['uri'] - rtn.image_width = story['media']['image']['width'] - rtn.image_height = story['media']['image']['height'] - rtn.url = story['url'] - return rtn - elif _type in ['ExternalUrl', 'Story']: - return ShareAttachment( + target = story.get('target') + if target: + _type = target['__typename'] + if _type == 'MessageLocation': + latitude, longitude = get_url_parameter(get_url_parameter(story['url'], 'u'), 'where1').split(", ") + rtn = LocationAttachment( + uid=int(story['deduplication_key']), + latitude=float(latitude), + longitude=float(longitude), + ) + rtn.image_url = story['media']['image']['uri'] + rtn.image_width = story['media']['image']['width'] + rtn.image_height = story['media']['image']['height'] + rtn.url = story['url'] + return rtn + elif _type == 'MessageLiveLocation': + rtn = LiveLocationAttachment( + uid=int(story['target']['live_location_id']), + latitude=story['target']['coordinate']['latitude'] if story['target'].get('coordinate') else None, + longitude=story['target']['coordinate']['longitude'] if story['target'].get('coordinate') else None, + name=story['title_with_entities']['text'], + expiration_time=story['target']['expiration_time'] if story['target'].get('expiration_time') else None, + is_expired=story['target']['is_expired'], + ) + rtn.image_url = story['media']['image']['uri'] + rtn.image_width = story['media']['image']['width'] + rtn.image_height = story['media']['image']['height'] + rtn.url = story['url'] + return rtn + elif _type in ['ExternalUrl', 'Story']: + return ShareAttachment( + uid=a.get('legacy_attachment_id'), + author=story['target']['actors'][0]['id'] if story['target'].get('actors') else None, + url=story['url'], + original_url=get_url_parameter(story['url'], 'u') if "/l.php?u=" in story['url'] else story['url'], + title=story['title_with_entities'].get('text'), + description=story['description'].get('text'), + source=story['source']['text'], + image_url=story['media']['image']['uri'] if story.get('media') else None, + original_image_url=(get_url_parameter(story['media']['image']['uri'], 'url') if "/safe_image.php" in story['media']['image']['uri'] else story['media']['image']['uri']) if story.get('media') else None, + image_width=story['media']['image']['width'] if story.get('media') else None, + image_height=story['media']['image']['height'] if story.get('media') else None, + attachments=[graphql_to_subattachment(attachment) for attachment in story.get('subattachments')], + ) + else: + return DeletedMessage( uid=a.get('legacy_attachment_id'), - author=story['target']['actors'][0]['id'] if story['target'].get('actors') else None, - url=story['url'], - original_url=get_url_parameter(story['url'], 'u') if "/l.php?u=" in story['url'] else story['url'], - title=story['title_with_entities'].get('text'), - description=story['description'].get('text'), - source=story['source']['text'], - image_url=story['media']['image']['uri'] if story.get('media') else None, - original_image_url=(get_url_parameter(story['media']['image']['uri'], 'url') if "/safe_image.php" in story['media']['image']['uri'] else story['media']['image']['uri']) if story.get('media') else None, - image_width=story['media']['image']['width'] if story.get('media') else None, - image_height=story['media']['image']['height'] if story.get('media') else None, - attachments=[graphql_to_subattachment(attachment) for attachment in story.get('subattachments')] ) diff --git a/fbchat/models.py b/fbchat/models.py index cc06564..f038b59 100644 --- a/fbchat/models.py +++ b/fbchat/models.py @@ -216,6 +216,12 @@ class Attachment(object): """Represents a Facebook attachment""" self.uid = uid +class DeletedMessage(Attachment): + + def __init__(self, *args, **kwargs): + """Represents a deleted message""" + super(DeletedMessage, self).__init__(*args, **kwargs) + class Sticker(Attachment): #: The sticker-pack's ID pack = None From 3443a233f4e3ea3bb43272f8b2a4eac398605018 Mon Sep 17 00:00:00 2001 From: Mads Marquart Date: Sun, 9 Dec 2018 15:02:48 +0100 Subject: [PATCH 25/32] Fix pytest "Applying marks directly to parameters" deprecation --- tests/conftest.py | 4 +++- tests/test_plans.py | 10 ++++++++-- tests/test_polls.py | 4 +++- tests/test_thread_interraction.py | 6 +++--- tests/utils.py | 14 +++++++------- 5 files changed, 24 insertions(+), 14 deletions(-) diff --git a/tests/conftest.py b/tests/conftest.py index af40730..014b5a9 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -20,7 +20,9 @@ def group(pytestconfig): return {"id": load_variable("group_id", pytestconfig.cache), "type": ThreadType.GROUP} -@pytest.fixture(scope="session", params=["user", "group", pytest.mark.xfail("none")]) +@pytest.fixture(scope="session", params=[ + "user", "group", pytest.param("none", marks=[pytest.mark.xfail()]) +]) def thread(request, user, group): return { "user": user, diff --git a/tests/test_plans.py b/tests/test_plans.py index d16c153..9365d4a 100644 --- a/tests/test_plans.py +++ b/tests/test_plans.py @@ -11,8 +11,14 @@ from time import time @pytest.fixture(scope="module", params=[ Plan(int(time()) + 100, random_hex()), - pytest.mark.xfail(Plan(int(time()), random_hex()), raises=FBchatFacebookError), - pytest.mark.xfail(Plan(0, None)), + pytest.param( + Plan(int(time()), random_hex()), + marks=[pytest.mark.xfail(raises=FBchatFacebookError)] + ), + pytest.param( + Plan(0, None), + marks=[pytest.mark.xfail()], + ), ]) def plan_data(request, client, user, thread, catch_event, compare): with catch_event("onPlanCreated") as x: diff --git a/tests/test_polls.py b/tests/test_polls.py index 96dab76..96de743 100644 --- a/tests/test_polls.py +++ b/tests/test_polls.py @@ -26,7 +26,9 @@ from utils import random_hex, subset PollOption(random_hex()), PollOption(random_hex()), ]), - pytest.mark.xfail(Poll(title=None, options=[]), raises=ValueError), + pytest.param( + Poll(title=None, options=[]), marks=[pytest.mark.xfail(raises=ValueError)] + ), ]) def poll_data(request, client1, group, catch_event): with catch_event("onPollCreated") as x: diff --git a/tests/test_thread_interraction.py b/tests/test_thread_interraction.py index 16e7e9e..be5b0ff 100644 --- a/tests/test_thread_interraction.py +++ b/tests/test_thread_interraction.py @@ -72,8 +72,8 @@ def test_change_nickname(client, client_all, catch_event, compare): "😂", "😕", "😍", - pytest.mark.xfail("🙃", raises=FBchatFacebookError), - pytest.mark.xfail("not an emoji", raises=FBchatFacebookError) + pytest.param("🙃", marks=[pytest.mark.xfail(raises=FBchatFacebookError)]), + pytest.param("not an emoji", marks=[pytest.mark.xfail(raises=FBchatFacebookError)]), ]) def test_change_emoji(client, catch_event, compare, emoji): with catch_event("onEmojiChange") as x: @@ -101,7 +101,7 @@ def test_change_image_remote(client1, group, catch_event): [ x if x in [ThreadColor.MESSENGER_BLUE, ThreadColor.PUMPKIN] - else pytest.mark.expensive(x) + else pytest.param(x, marks=[pytest.mark.expensive()]) for x in ThreadColor ], ) diff --git a/tests/utils.py b/tests/utils.py index 51364cb..241a6ad 100644 --- a/tests/utils.py +++ b/tests/utils.py @@ -23,15 +23,15 @@ EMOJI_LIST = [ ("😆", EmojiSize.LARGE), # These fail in `catch_event` because the emoji is made into a sticker # This should be fixed - pytest.mark.xfail((None, EmojiSize.SMALL)), - pytest.mark.xfail((None, EmojiSize.MEDIUM)), - pytest.mark.xfail((None, EmojiSize.LARGE)), + pytest.param(None, EmojiSize.SMALL, marks=[pytest.mark.xfail()]), + pytest.param(None, EmojiSize.MEDIUM, marks=[pytest.mark.xfail()]), + pytest.param(None, EmojiSize.LARGE, marks=[pytest.mark.xfail()]), ] STICKER_LIST = [ Sticker("767334476626295"), - pytest.mark.xfail(Sticker("0"), raises=FBchatFacebookError), - pytest.mark.xfail(Sticker(None), raises=FBchatFacebookError), + pytest.param(Sticker("0"), marks=[pytest.mark.xfail(raises=FBchatFacebookError)]), + pytest.param(Sticker(None), marks=[pytest.mark.xfail(raises=FBchatFacebookError)]), ] TEXT_LIST = [ @@ -40,8 +40,8 @@ TEXT_LIST = [ "\\\n\t%?&'\"", "ˁҭʚ¹Ʋջوװ՞ޱɣࠚԹБɑȑңКએ֭ʗыԈٌʼőԈ×௴nચϚࠖణٔє܅Ԇޑط", "a" * 20000, # Maximum amount of characters you can send - pytest.mark.xfail("a" * 20001, raises=FBchatFacebookError), - pytest.mark.xfail(None, raises=FBchatFacebookError), + pytest.param("a" * 20001, marks=[pytest.mark.xfail(raises=FBchatFacebookError)]), + pytest.param(None, marks=[pytest.mark.xfail(raises=FBchatFacebookError)]), ] From d4446280c7361c75365c55f01dc35e1be5f62227 Mon Sep 17 00:00:00 2001 From: Kacper Ziubryniewicz Date: Sun, 9 Dec 2018 15:27:01 +0100 Subject: [PATCH 26/32] Detecting when someone unsends a message --- fbchat/client.py | 25 ++++++++++++++++++++++++- fbchat/graphql.py | 14 ++++++++------ 2 files changed, 32 insertions(+), 7 deletions(-) diff --git a/fbchat/client.py b/fbchat/client.py index 795b280..dfa9160 100644 --- a/fbchat/client.py +++ b/fbchat/client.py @@ -2387,10 +2387,20 @@ class Client(object): thread_id, thread_type = getThreadIdAndThreadType(i) for l in i['messageLiveLocations']: mid = l["messageId"] - author_id = l["senderId"] + author_id = str(l["senderId"]) location = graphql_to_live_location(l) self.onLiveLocation(mid=mid, location=location, author_id=author_id, thread_id=thread_id, thread_type=thread_type, ts=ts, msg=m) + + # Message deletion + elif d.get('deltaRecallMessageData'): + i = d['deltaRecallMessageData'] + thread_id, thread_type = getThreadIdAndThreadType(i) + mid = i['messageID'] + ts = i['deletionTimestamp'] + author_id = str(i['senderID']) + self.onMessageDeleted(mid=mid, author_id=author_id, thread_id=thread_id, thread_type=thread_type, + ts=ts, msg=m) # New message elif delta.get("class") == "NewMessage": @@ -2798,6 +2808,19 @@ class Client(object): """ log.info("Marked messages as seen in threads {} at {}s".format([(x[0], x[1].name) for x in threads], seen_ts/1000)) + def onMessageDeleted(self, mid=None, author_id=None, thread_id=None, thread_type=None, ts=None, msg=None): + """ + Called when the client is listening, and someone unsends (deleted for everyone) a message + + :param mid: ID of the deleted message + :param author_id: The ID of the person who deleted the message + :param thread_id: Thread ID that the action was sent to. See :ref:`intro_threads` + :param thread_type: Type of thread that the action was sent to. See :ref:`intro_threads` + :param ts: A timestamp of the action + :param msg: A full set of the data recieved + :type thread_type: models.ThreadType + """ + log.info("{} unsended the message {} in {} ({}) at {}s".format(author_id, repr(mid), thread_id, thread_type.name, ts/1000)) def onPeopleAdded(self, mid=None, added_ids=None, author_id=None, thread_id=None, ts=None, msg=None): """ diff --git a/fbchat/graphql.py b/fbchat/graphql.py index b6c1ae2..0d6c5d2 100644 --- a/fbchat/graphql.py +++ b/fbchat/graphql.py @@ -141,9 +141,10 @@ def graphql_to_extensible_attachment(a): latitude=float(latitude), longitude=float(longitude), ) - rtn.image_url = story['media']['image']['uri'] - rtn.image_width = story['media']['image']['width'] - rtn.image_height = story['media']['image']['height'] + if story['media']: + rtn.image_url = story['media']['image']['uri'] + rtn.image_width = story['media']['image']['width'] + rtn.image_height = story['media']['image']['height'] rtn.url = story['url'] return rtn elif _type == 'MessageLiveLocation': @@ -155,9 +156,10 @@ def graphql_to_extensible_attachment(a): expiration_time=story['target']['expiration_time'] if story['target'].get('expiration_time') else None, is_expired=story['target']['is_expired'], ) - rtn.image_url = story['media']['image']['uri'] - rtn.image_width = story['media']['image']['width'] - rtn.image_height = story['media']['image']['height'] + if story['media']: + rtn.image_url = story['media']['image']['uri'] + rtn.image_width = story['media']['image']['width'] + rtn.image_height = story['media']['image']['height'] rtn.url = story['url'] return rtn elif _type in ['ExternalUrl', 'Story']: From f8d3b571baea9203ba79e65e21aacb1107345342 Mon Sep 17 00:00:00 2001 From: Mads Marquart Date: Sun, 9 Dec 2018 21:21:00 +0100 Subject: [PATCH 27/32] Version up, thanks to @ekohilas and @kapi2289 --- fbchat/__init__.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/fbchat/__init__.py b/fbchat/__init__.py index 4cff815..b9efe60 100644 --- a/fbchat/__init__.py +++ b/fbchat/__init__.py @@ -15,7 +15,7 @@ from __future__ import unicode_literals from .client import * __title__ = 'fbchat' -__version__ = '1.4.1' +__version__ = '1.4.2' __description__ = 'Facebook Chat (Messenger) for Python' __copyright__ = 'Copyright 2015 - 2018 by Taehoon Kim' From 13bfc5f2f9a0c150189bbd0040ffa57ab01e9d22 Mon Sep 17 00:00:00 2001 From: kaushalvivek Date: Mon, 10 Dec 2018 14:46:04 +0530 Subject: [PATCH 28/32] Fix for search limit --- fbchat/client.py | 6 +++--- fbchat/graphql.py | 8 ++++---- 2 files changed, 7 insertions(+), 7 deletions(-) diff --git a/fbchat/client.py b/fbchat/client.py index 90469c2..e36d1cc 100644 --- a/fbchat/client.py +++ b/fbchat/client.py @@ -517,7 +517,7 @@ class Client(object): return [graphql_to_user(node) for node in j[name]['users']['nodes']] - def searchForPages(self, name, limit=1): + def searchForPages(self, name, limit=10): """ Find and get page by its name @@ -531,7 +531,7 @@ class Client(object): return [graphql_to_page(node) for node in j[name]['pages']['nodes']] - def searchForGroups(self, name, limit=1): + def searchForGroups(self, name, limit=10): """ Find and get group thread by its name @@ -546,7 +546,7 @@ class Client(object): return [graphql_to_group(node) for node in j['viewer']['groups']['nodes']] - def searchForThreads(self, name, limit=1): + def searchForThreads(self, name, limit=10): """ Find and get a thread by its name diff --git a/fbchat/graphql.py b/fbchat/graphql.py index 8fc8b6e..510e93b 100644 --- a/fbchat/graphql.py +++ b/fbchat/graphql.py @@ -477,7 +477,7 @@ class GraphQL(object): """ SEARCH_USER = """ - Query SearchUser( = '', = 1) { + Query SearchUser( = '', = 10) { entities_named() { search_results.of_type(user).first() as users { nodes { @@ -489,7 +489,7 @@ class GraphQL(object): """ + FRAGMENT_USER SEARCH_GROUP = """ - Query SearchGroup( = '', = 1, = 32) { + Query SearchGroup( = '', = 10, = 32) { viewer() { message_threads.with_thread_name().last() as groups { nodes { @@ -501,7 +501,7 @@ class GraphQL(object): """ + FRAGMENT_GROUP SEARCH_PAGE = """ - Query SearchPage( = '', = 1) { + Query SearchPage( = '', = 10) { entities_named() { search_results.of_type(page).first() as pages { nodes { @@ -513,7 +513,7 @@ class GraphQL(object): """ + FRAGMENT_PAGE SEARCH_THREAD = """ - Query SearchThread( = '', = 1) { + Query SearchThread( = '', = 10) { entities_named() { search_results.first() as threads { nodes { From b9d29c0417cf6e00abc97a644b6ceb9fa9674f57 Mon Sep 17 00:00:00 2001 From: Kacper Ziubryniewicz Date: Sun, 23 Dec 2018 14:45:17 +0100 Subject: [PATCH 29/32] Removed `addReaction`, `removeReaction`, `_react` (and undeprecated `reactToMessage`) --- fbchat/client.py | 44 ++++++++++++-------------------------------- 1 file changed, 12 insertions(+), 32 deletions(-) diff --git a/fbchat/client.py b/fbchat/client.py index dfa9160..fbc4686 100644 --- a/fbchat/client.py +++ b/fbchat/client.py @@ -1150,7 +1150,7 @@ class Client(object): :raises: FBchatException if request failed """ self._sendLocation(location=location, current=True, thread_id=thread_id, thread_type=thread_type) - + def sendPinnedLocation(self, location, thread_id=None, thread_type=None): """ Sends a given location to a thread as a pinned location @@ -1572,7 +1572,15 @@ class Client(object): j = self._post(self.req_url.THREAD_EMOJI, data, fix_request=True, as_json=True) - def _react(self, message_id, reaction=None): + def reactToMessage(self, message_id, reaction): + """ + Reacts to a message, or removes reaction + + :param message_id: :ref:`Message ID ` to react to + :param reaction: Reaction emoji to use, if None removes reaction + :type reaction: models.MessageReaction or None + :raises: FBchatException if request failed + """ data = { "doc_id": 1491398900900362, "variables": json.dumps({ @@ -1585,35 +1593,7 @@ class Client(object): } }) } - - r = self._post(self.req_url.MESSAGE_REACTION, data) - r.raise_for_status() - - def reactToMessage(self, message_id, reaction): - """ - Deprecated. Use :func:`fbchat.Client.addReaction` instead - """ - self.addReaction(message_id=message_id, reaction=reaction) - - def addReaction(self, message_id, reaction): - """ - Reacts to a message - - :param message_id: :ref:`Message ID ` to react to - :param reaction: Reaction emoji to use - :type reaction: models.MessageReaction - :raises: FBchatException if request failed - """ - self._react(message_id=message_id, reaction=reaction) - - def removeReaction(self, message_id): - """ - Removes reaction from a message - - :param message_id: :ref:`Message ID ` to remove reaction from - :raises: FBchatException if request failed - """ - self._react(message_id=message_id) + self._post(self.req_url.MESSAGE_REACTION, data, fix_request=True, as_json=True) def createPlan(self, plan, thread_id=None): """ @@ -2391,7 +2371,7 @@ class Client(object): location = graphql_to_live_location(l) self.onLiveLocation(mid=mid, location=location, author_id=author_id, thread_id=thread_id, thread_type=thread_type, ts=ts, msg=m) - + # Message deletion elif d.get('deltaRecallMessageData'): i = d['deltaRecallMessageData'] From 22e57f99a1cf1ec1b9c63d25face89aadecfb9c6 Mon Sep 17 00:00:00 2001 From: Kacper Ziubryniewicz Date: Sun, 23 Dec 2018 14:56:27 +0100 Subject: [PATCH 30/32] `deleted` attribute of `Message` and batter handling of deleted (unsended) messages --- fbchat/client.py | 14 +++++++++----- fbchat/graphql.py | 6 +++++- fbchat/models.py | 3 +++ 3 files changed, 17 insertions(+), 6 deletions(-) diff --git a/fbchat/client.py b/fbchat/client.py index fbc4686..9a17620 100644 --- a/fbchat/client.py +++ b/fbchat/client.py @@ -2393,6 +2393,7 @@ class Client(object): sticker = None attachments = [] + deleted = False if delta.get('attachments'): try: for a in delta['attachments']: @@ -2400,7 +2401,7 @@ class Client(object): if mercury.get('blob_attachment'): image_metadata = a.get('imageMetadata', {}) attach_type = mercury['blob_attachment']['__typename'] - attachment = graphql_to_attachment(mercury.get('blob_attachment', {})) + attachment = graphql_to_attachment(mercury['blob_attachment']) if attach_type in ['MessageFile', 'MessageVideo', 'MessageAudio']: # TODO: Add more data here for audio files @@ -2408,11 +2409,13 @@ class Client(object): attachments.append(attachment) elif mercury.get('sticker_attachment'): - sticker = graphql_to_sticker(a['mercury']['sticker_attachment']) + sticker = graphql_to_sticker(mercury['sticker_attachment']) elif mercury.get('extensible_attachment'): - attachment = graphql_to_extensible_attachment(mercury.get('extensible_attachment', {})) - if attachment: + attachment = graphql_to_extensible_attachment(mercury['extensible_attachment']) + if isinstance(attachment, DeletedMessage): + deleted = True + elif attachment: attachments.append(attachment) except Exception: @@ -2426,12 +2429,13 @@ class Client(object): mentions=mentions, emoji_size=emoji_size, sticker=sticker, - attachments=attachments + attachments=attachments, ) message.uid = mid message.author = author_id message.timestamp = ts #message.reactions = {} + message.deleted = deleted thread_id, thread_type = getThreadIdAndThreadType(metadata) self.onMessage(mid=mid, author_id=author_id, message=delta.get('body', ''), message_object=message, thread_id=thread_id, thread_type=thread_type, ts=ts, metadata=metadata, msg=m) diff --git a/fbchat/graphql.py b/fbchat/graphql.py index 0d6c5d2..362134c 100644 --- a/fbchat/graphql.py +++ b/fbchat/graphql.py @@ -288,7 +288,11 @@ def graphql_to_message(message): if message.get('blob_attachments') is not None: rtn.attachments = [graphql_to_attachment(attachment) for attachment in message['blob_attachments']] if message.get('extensible_attachment') is not None: - rtn.attachments.append(graphql_to_extensible_attachment(message['extensible_attachment'])) + attachment = graphql_to_extensible_attachment(message['extensible_attachment']) + if isinstance(attachment, DeletedMessage): + rtn.deleted = True + elif attachment: + rtn.attachments.append(attachment) return rtn def graphql_to_user(user): diff --git a/fbchat/models.py b/fbchat/models.py index f038b59..55b6407 100644 --- a/fbchat/models.py +++ b/fbchat/models.py @@ -188,6 +188,8 @@ class Message(object): sticker = None #: A list of attachments attachments = None + #: Whether the message is deleted (unsended) + deleted = None def __init__(self, text=None, mentions=None, emoji_size=None, sticker=None, attachments=None): """Represents a Facebook message""" @@ -201,6 +203,7 @@ class Message(object): attachments = [] self.attachments = attachments self.reactions = {} + self.deleted = False def __repr__(self): return self.__unicode__() From 7b8ecf8fe398e0d78f0a5fb77384bab34edb164a Mon Sep 17 00:00:00 2001 From: Kacper Ziubryniewicz Date: Fri, 4 Jan 2019 20:02:00 +0100 Subject: [PATCH 31/32] Changed `deleted` to `unsent` --- fbchat/client.py | 20 ++++++++++---------- fbchat/graphql.py | 7 ++++--- fbchat/models.py | 10 +++++----- 3 files changed, 19 insertions(+), 18 deletions(-) diff --git a/fbchat/client.py b/fbchat/client.py index ade6a8f..2606be9 100644 --- a/fbchat/client.py +++ b/fbchat/client.py @@ -2366,7 +2366,7 @@ class Client(object): mid = i['messageID'] ts = i['deletionTimestamp'] author_id = str(i['senderID']) - self.onMessageDeleted(mid=mid, author_id=author_id, thread_id=thread_id, thread_type=thread_type, + self.onMessageUnsent(mid=mid, author_id=author_id, thread_id=thread_id, thread_type=thread_type, ts=ts, msg=m) # New message @@ -2380,7 +2380,7 @@ class Client(object): sticker = None attachments = [] - deleted = False + unsent = False if delta.get('attachments'): try: for a in delta['attachments']: @@ -2400,8 +2400,8 @@ class Client(object): elif mercury.get('extensible_attachment'): attachment = graphql_to_extensible_attachment(mercury['extensible_attachment']) - if isinstance(attachment, DeletedMessage): - deleted = True + if isinstance(attachment, UnsentMessage): + unsent = True elif attachment: attachments.append(attachment) @@ -2422,7 +2422,7 @@ class Client(object): message.author = author_id message.timestamp = ts #message.reactions = {} - message.deleted = deleted + message.unsent = unsent thread_id, thread_type = getThreadIdAndThreadType(metadata) self.onMessage(mid=mid, author_id=author_id, message=delta.get('body', ''), message_object=message, thread_id=thread_id, thread_type=thread_type, ts=ts, metadata=metadata, msg=m) @@ -2778,19 +2778,19 @@ class Client(object): """ log.info("Marked messages as seen in threads {} at {}s".format([(x[0], x[1].name) for x in threads], seen_ts/1000)) - def onMessageDeleted(self, mid=None, author_id=None, thread_id=None, thread_type=None, ts=None, msg=None): + def onMessageUnsent(self, mid=None, author_id=None, thread_id=None, thread_type=None, ts=None, msg=None): """ - Called when the client is listening, and someone unsends (deleted for everyone) a message + Called when the client is listening, and someone unsends (deletes for everyone) a message - :param mid: ID of the deleted message - :param author_id: The ID of the person who deleted the message + :param mid: ID of the unsent message + :param author_id: The ID of the person who unsent the message :param thread_id: Thread ID that the action was sent to. See :ref:`intro_threads` :param thread_type: Type of thread that the action was sent to. See :ref:`intro_threads` :param ts: A timestamp of the action :param msg: A full set of the data recieved :type thread_type: models.ThreadType """ - log.info("{} unsended the message {} in {} ({}) at {}s".format(author_id, repr(mid), thread_id, thread_type.name, ts/1000)) + log.info("{} unsent the message {} in {} ({}) at {}s".format(author_id, repr(mid), thread_id, thread_type.name, ts/1000)) def onPeopleAdded(self, mid=None, added_ids=None, author_id=None, thread_id=None, ts=None, msg=None): """ diff --git a/fbchat/graphql.py b/fbchat/graphql.py index 641f1f6..1335893 100644 --- a/fbchat/graphql.py +++ b/fbchat/graphql.py @@ -178,7 +178,7 @@ def graphql_to_extensible_attachment(a): attachments=[graphql_to_subattachment(attachment) for attachment in story.get('subattachments')], ) else: - return DeletedMessage( + return UnsentMessage( uid=a.get('legacy_attachment_id'), ) @@ -282,6 +282,7 @@ def graphql_to_message(message): rtn.uid = str(message.get('message_id')) rtn.author = str(message.get('message_sender').get('id')) rtn.timestamp = message.get('timestamp_precise') + rtn.unsent = False if message.get('unread') is not None: rtn.is_read = not message['unread'] rtn.reactions = {str(r['user']['id']):MessageReaction(r['reaction']) for r in message.get('message_reactions')} @@ -289,8 +290,8 @@ def graphql_to_message(message): rtn.attachments = [graphql_to_attachment(attachment) for attachment in message['blob_attachments']] if message.get('extensible_attachment') is not None: attachment = graphql_to_extensible_attachment(message['extensible_attachment']) - if isinstance(attachment, DeletedMessage): - rtn.deleted = True + if isinstance(attachment, UnsentMessage): + rtn.unsent = True elif attachment: rtn.attachments.append(attachment) return rtn diff --git a/fbchat/models.py b/fbchat/models.py index d1d59d8..ac38455 100644 --- a/fbchat/models.py +++ b/fbchat/models.py @@ -190,8 +190,8 @@ class Message(object): sticker = None #: A list of attachments attachments = None - #: Whether the message is deleted (unsended) - deleted = None + #: Whether the message is unsent (deleted for everyone) + unsent = None def __init__(self, text=None, mentions=None, emoji_size=None, sticker=None, attachments=None): """Represents a Facebook message""" @@ -222,11 +222,11 @@ class Attachment(object): """Represents a Facebook attachment""" self.uid = uid -class DeletedMessage(Attachment): +class UnsentMessage(Attachment): def __init__(self, *args, **kwargs): - """Represents a deleted message""" - super(DeletedMessage, self).__init__(*args, **kwargs) + """Represents an unsent message attachment""" + super(UnsentMessage, self).__init__(*args, **kwargs) class Sticker(Attachment): #: The sticker-pack's ID From f4a997c0ef93c6161ec7a0e1df21cad1506c8d13 Mon Sep 17 00:00:00 2001 From: Kacper Ziubryniewicz Date: Sat, 5 Jan 2019 17:55:54 +0100 Subject: [PATCH 32/32] Fixed typos in FBchatException --- fbchat/client.py | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/fbchat/client.py b/fbchat/client.py index 2606be9..3ccf30a 100644 --- a/fbchat/client.py +++ b/fbchat/client.py @@ -927,14 +927,14 @@ class Client(object): :type image_id: str :return: An url where you can download the original image :rtype: str - :raises: FBChatException if request failed + :raises: FBchatException if request failed """ image_id = str(image_id) j = check_request(self._get(ReqUrl.ATTACHMENT_PHOTO, query={'photo_id': str(image_id)})) url = get_jsmods_require(j, 3) if url is None: - raise FBChatException('Could not fetch image url from: {}'.format(j)) + raise FBchatException('Could not fetch image url from: {}'.format(j)) return url def fetchMessageInfo(self, mid, thread_id=None): @@ -945,7 +945,7 @@ class Client(object): :param thread_id: User/Group ID to get message info from. See :ref:`intro_threads` :return: :class:`models.Message` object :rtype: models.Message - :raises: FBChatException if request failed + :raises: FBchatException if request failed """ thread_id, thread_type = self._getThread(thread_id, None) message_info = self._forcedFetch(thread_id, mid).get("message") @@ -958,7 +958,7 @@ class Client(object): :param poll_id: Poll ID to fetch from :rtype: list - :raises: FBChatException if request failed + :raises: FBchatException if request failed """ data = { "question_id": poll_id @@ -975,7 +975,7 @@ class Client(object): :param plan_id: Plan ID to fetch from :return: :class:`models.Plan` object :rtype: models.Plan - :raises: FBChatException if request failed + :raises: FBchatException if request failed """ data = { "event_reminder_id": plan_id