From a58791048a2f98940bc23430f5315c8687161fde Mon Sep 17 00:00:00 2001 From: Mads Marquart Date: Thu, 21 Sep 2017 22:32:11 +0200 Subject: [PATCH 1/3] Added attachment and mention support in `onMessage` Deprecated `message` in `onMessage` --- docs/intro.rst | 8 +- examples/echobot.py | 10 +- examples/removebot.py | 6 +- fbchat/client.py | 99 ++++++++++++++++++-- fbchat/models.py | 213 +++++++++++++++++++++++++++++++++++------- fbchat/utils.py | 20 ++++ 6 files changed, 301 insertions(+), 55 deletions(-) diff --git a/docs/intro.rst b/docs/intro.rst index 9e3d07f..d71be55 100644 --- a/docs/intro.rst +++ b/docs/intro.rst @@ -175,8 +175,8 @@ meaning it will simply print information to the console when an event happens The event actions can be changed by subclassing the :class:`Client`, and then overwriting the event methods:: class CustomClient(Client): - def onMessage(self, mid, author_id, message, thread_id, thread_type, ts, metadata, msg, **kwargs): - # Do something with the message here + def onMessage(self, mid, author_id, message_object, thread_id, thread_type, ts, metadata, msg, **kwargs): + # Do something with the message_object here pass client = CustomClient('', '') @@ -184,13 +184,13 @@ The event actions can be changed by subclassing the :class:`Client`, and then ov **Notice:** The following snippet is as equally valid as the previous one:: class CustomClient(Client): - def onMessage(self, message, author_id, thread_id, thread_type, **kwargs): + def onMessage(self, message_object, author_id, thread_id, thread_type, **kwargs): # Do something with the message here pass client = CustomClient('', '') -The change was in the parameters that our `onMessage` method took: ``message`` and ``author_id`` got swapped, +The change was in the parameters that our `onMessage` method took: ``message_object`` and ``author_id`` got swapped, and ``mid``, ``ts``, ``metadata`` and ``msg`` got removed, but the function still works, since we included ``**kwargs`` .. note:: diff --git a/examples/echobot.py b/examples/echobot.py index 0b3e9c0..d792f3a 100644 --- a/examples/echobot.py +++ b/examples/echobot.py @@ -4,15 +4,15 @@ from fbchat import log, Client # Subclass fbchat.Client and override required methods class EchoBot(Client): - def onMessage(self, author_id, message, thread_id, thread_type, **kwargs): + def onMessage(self, author_id, message_object, thread_id, thread_type, **kwargs): self.markAsDelivered(author_id, thread_id) self.markAsRead(author_id) - log.info("Message from {} in {} ({}): {}".format(author_id, thread_id, thread_type.name, message)) + log.info("{} from {} in {}".format(message_object, thread_id, thread_type.name)) - # If you're not the author, echo - if author_id != self.uid: - self.sendMessage(message, thread_id=thread_id, thread_type=thread_type) + # If you're not the author, and the message was a message containing text, echo + if author_id != self.uid and message_object.text is not None: + self.sendMessage(message_object.text, thread_id=thread_id, thread_type=thread_type) client = EchoBot("", "") client.listen() diff --git a/examples/removebot.py b/examples/removebot.py index f387d90..b6e7d51 100644 --- a/examples/removebot.py +++ b/examples/removebot.py @@ -4,14 +4,14 @@ from fbchat import log, Client from fbchat.models import * class RemoveBot(Client): - def onMessage(self, author_id, message, thread_id, thread_type, **kwargs): + def onMessage(self, author_id, message_object, thread_id, thread_type, **kwargs): # We can only kick people from group chats, so no need to try if it's a user chat - if message == 'Remove me!' and thread_type == ThreadType.GROUP: + if message_object.text == 'Remove me!' and thread_type == ThreadType.GROUP: log.info('{} will be removed from {}'.format(author_id, thread_id)) self.removeUserFromGroup(author_id, thread_id=thread_id) else: # Sends the data to the inherited onMessage, so that we can still see when a message is recieved - super(type(self), self).onMessage(author_id=author_id, message=message, thread_id=thread_id, thread_type=thread_type, **kwargs) + super(RemoveBot, self).onMessage(author_id=author_id, message_object=message_object, thread_id=thread_id, thread_type=thread_type, **kwargs) client = RemoveBot("", "") client.listen() diff --git a/fbchat/client.py b/fbchat/client.py index 3c40cc7..dc95cba 100644 --- a/fbchat/client.py +++ b/fbchat/client.py @@ -12,6 +12,7 @@ from .utils import * from .models import * from .graphql import * import time +import json @@ -638,7 +639,7 @@ class Client(object): })) j = self.graphql_requests(*queries) - + for i, entry in enumerate(j): if entry.get('message_thread') is None: # If you don't have an existing thread with this person, attempt to retrieve user data anyways @@ -1279,7 +1280,7 @@ class Client(object): delta_type = delta.get("type") metadata = delta.get("messageMetadata") - if metadata is not None: + if metadata: mid = metadata["messageId"] author_id = str(metadata['actorFbId']) ts = int(metadata.get("timestamp")) @@ -1360,9 +1361,91 @@ class Client(object): # New message elif delta.get("class") == "NewMessage": - message = delta.get('body', '') + mentions = [] + if delta.get('data') and delta['data'].get('prng'): + try: + mentions = [Mention(str(mention.get('i')), offset=mention.get('o'), length=mention.get('l')) for mention in json.loads(delta['data']['prng'])] + except Exception: + log.exception('An exception occured while reading attachments') + + attachments = [] + if delta.get('attachments'): + try: + for a in delta['attachments']: + mercury = a['mercury'] + blob = mercury.get('blob_attachment', {}) + image_metadata = a.get('imageMetadata', {}) + attach_type = mercury['attach_type'] + if attach_type in ['photo', 'animated_image']: + attachments.append(ImageAttachment( + original_extension=blob.get('original_extension') or (blob['filename'].split('-')[0] if blob.get('filename') else None), + width=int(image_metadata['width']), + height=int(image_metadata['height']), + is_animated=attach_type=='animated_image', + thumbnail_url=mercury.get('thumbnail_url'), + preview=blob.get('preview') or blob.get('preview_image'), + large_preview=blob.get('large_preview'), + animated_preview=blob.get('animated_image'), + uid=a['id'] + )) + elif attach_type == 'file': + # Add more data here for audio files + attachments.append(FileAttachment( + url=mercury.get('url'), + size=int(a['fileSize']), + name=mercury.get('name'), + is_malicious=blob.get('is_malicious'), + uid=a['id'] + )) + elif attach_type == 'video': + attachments.append(VideoAttachment( + size=int(a['fileSize']), + width=int(image_metadata['width']), + height=int(image_metadata['height']), + duration=blob.get('playable_duration_in_ms'), + preview_url=blob.get('playable_url'), + small_image=blob.get('chat_image'), + medium_image=blob.get('inbox_image'), + large_image=blob.get('large_image'), + uid=a['id'] + )) + elif attach_type == 'sticker': + # Add more data here for stickers + attachments.append(StickerAttachment( + uid=mercury.get('metadata', {}).get('stickerID') + )) + elif attach_type == 'share': + # Add more data here for shared stuff (URLs, events and so on) + attachments.append(ShareAttachment( + uid=a.get('id') + )) + else: + attachments.append(Attachment( + uid=a.get('id') + )) + except Exception: + log.exception('An exception occured while reading attachments: {}'.format(delta['attachments'])) + + emoji_size = None + if metadata and metadata.get('tags'): + for tag in metadata['tags']: + if tag.startswith('hot_emoji_size:'): + emoji_size = LIKES[tag.split(':')[1]] + break + + message = Message( + text=delta.get('body'), + mentions=mentions, + emoji_size=emoji_size + ) + message.uid = mid + message.author = author_id + message.timestamp = ts + message.attachments = attachments + #message.is_read = None + #message.reactions = [] thread_id, thread_type = getThreadIdAndThreadType(metadata) - self.onMessage(mid=mid, author_id=author_id, message=message, + 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) # Unknown message type @@ -1509,21 +1592,23 @@ class Client(object): log.exception('Got exception while listening') - def onMessage(self, mid=None, author_id=None, message=None, thread_id=None, thread_type=ThreadType.USER, ts=None, metadata=None, msg={}): + def onMessage(self, mid=None, author_id=None, message=None, message_object=None, thread_id=None, thread_type=ThreadType.USER, ts=None, metadata=None, msg={}): """ Called when the client is listening, and somebody sends a message :param mid: The message ID :param author_id: The ID of the author - :param message: The message + :param message: (deprecated. Use `message_object.text` instead) + :param message_object: The message (As a `Message` object) :param thread_id: Thread ID that the message was sent to. See :ref:`intro_threads` :param thread_type: Type of thread that the message was sent to. See :ref:`intro_threads` :param ts: The timestamp of the message :param metadata: Extra metadata about the message :param msg: A full set of the data recieved + :type message_object: models.Message :type thread_type: models.ThreadType """ - log.info("Message from {} in {} ({}): {}".format(author_id, thread_id, thread_type.name, message)) + log.info("{} from {} in {}".format(message_object, thread_id, thread_type.name)) def onColorChange(self, mid=None, author_id=None, new_color=None, thread_id=None, thread_type=ThreadType.USER, ts=None, metadata=None, msg={}): """ diff --git a/fbchat/models.py b/fbchat/models.py index 3f9fd77..48d0ca4 100644 --- a/fbchat/models.py +++ b/fbchat/models.py @@ -112,6 +112,10 @@ class Page(Thread): class Message(object): + #: The actual message + text = str + #: A list of :class:`Mention` objects + mentions = list #: The message ID uid = str #: ID of the sender @@ -122,29 +126,185 @@ class Message(object): is_read = bool #: A list of message reactions reactions = list - #: The actual message - text = str - #: A list of :class:`Mention` objects - mentions = list - #: An ID of a sent sticker - sticker = str + #: A :class:`EmojiSize`. Size of a sent emoji + emoji_size = None #: A list of attachments attachments = list - #: An extensible attachment, e.g. share object - extensible_attachment = dict - def __init__(self, uid, author=None, timestamp=None, is_read=None, reactions=[], text=None, mentions=[], sticker=None, attachments=[], extensible_attachment={}): + def __init__(self, text=None, mentions=[], emoji_size=None): """Represents a Facebook message""" - self.uid = uid - self.author = author - self.timestamp = timestamp - self.is_read = is_read - self.reactions = reactions self.text = text self.mentions = mentions - self.sticker = sticker - self.attachments = attachments - self.extensible_attachment = extensible_attachment + self.emoji_size = emoji_size + + def __repr__(self): + return self.__unicode__() + + def __unicode__(self): + return ''.format(self.uid, repr(self.text), self.mentions, self.emoji_size, self.attachments) + +class Attachment(object): + #: The attachment ID + uid = str + + def __init__(self, uid=None, mime_type=None): + """Represents a Facebook attachment""" + self.uid = uid + +class StickerAttachment(Attachment): + def __init__(self, **kwargs): + """Represents a sticker that has been sent as a Facebook attachment - *Currently Incomplete!*""" + super(StickerAttachment, self).__init__(**kwargs) + +class ShareAttachment(Attachment): + def __init__(self, **kwargs): + """Represents a shared item (eg. URL) that has been sent as a Facebook attachment - *Currently Incomplete!*""" + super(ShareAttachment, self).__init__(**kwargs) + +class FileAttachment(Attachment): + #: Url where you can download the file + url = str + #: Size of the file in bytes + size = int + #: Name of the file + name = str + #: Whether Facebook determines that this file may be harmful + is_malicious = bool + + def __init__(self, url=None, size=None, name=None, is_malicious=None, **kwargs): + """Represents a file that has been sent as a Facebook attachment""" + super(FileAttachment, self).__init__(**kwargs) + self.url = url + self.size = size + self.name = name + self.is_malicious = is_malicious + +class AudioAttachment(FileAttachment): + def __init__(self, **kwargs): + """Represents an audio file that has been sent as a Facebook attachment - *Currently Incomplete!*""" + super(StickerAttachment, self).__init__(**kwargs) + +class ImageAttachment(Attachment): + #: The extension of the original image (eg. 'png') + original_extension = str + #: Width of original image + width = int + #: Height of original image + height = int + + #: Whether the image is animated + is_animated = bool + + #: Url to a thumbnail of the image + thumbnail_url = str + + #: URL to a medium preview of the image + preview_url = str + #: Width of the medium preview image + preview_width = int + #: Height of the medium preview image + preview_height = int + + #: URL to a large preview of the image + large_preview_url = str + #: Width of the large preview image + large_preview_width = int + #: Height of the large preview image + large_preview_height = int + + #: URL to an animated preview of the image (eg. for gifs) + animated_preview_url = str + #: Width of the animated preview image + animated_preview_width = int + #: Height of the animated preview image + animated_preview_height = int + + def __init__(self, original_extension=None, width=None, height=None, is_animated=None, thumbnail_url=None, preview=None, large_preview=None, animated_preview=None, **kwargs): + """Represents an image that has been sent as a Facebook attachment""" + super(ImageAttachment, self).__init__(**kwargs) + self.original_extension = original_extension + self.width = width + self.height = height + self.is_animated = is_animated + self.thumbnail_url = thumbnail_url + + if preview is None: + preview = {} + self.preview_url = preview.get('uri') + self.preview_width = preview.get('width') + self.preview_height = preview.get('height') + + if large_preview is None: + large_preview = {} + self.large_preview_url = large_preview.get('uri') + self.large_preview_width = large_preview.get('width') + self.large_preview_height = large_preview.get('height') + + if animated_preview is None: + animated_preview = {} + self.animated_preview_url = animated_preview.get('uri') + self.animated_preview_width = animated_preview.get('width') + self.animated_preview_height = animated_preview.get('height') + +class VideoAttachment(Attachment): + #: Size of the original video in bytes + size = int + #: Width of original video + width = int + #: Height of original video + height = int + #: Length of video in milliseconds + duration = int + #: URL to very compressed preview video + preview_url = str + + #: URL to a small preview image of the video + small_image_url = str + #: Width of the small preview image + small_image_width = int + #: Height of the small preview image + small_image_height = int + + #: URL to a medium preview image of the video + medium_image_url = str + #: Width of the medium preview image + medium_image_width = int + #: Height of the medium preview image + medium_image_height = int + + #: URL to a large preview image of the video + large_image_url = str + #: Width of the large preview image + large_image_width = int + #: Height of the large preview image + large_image_height = int + + def __init__(self, size=None, width=None, height=None, duration=None, preview_url=None, small_image=None, medium_image=None, large_image=None, **kwargs): + """Represents a video that has been sent as a Facebook attachment""" + super(VideoAttachment, self).__init__(**kwargs) + self.size = size + self.width = width + self.height = height + self.duration = duration + self.preview_url = preview_url + + if small_image is None: + small_image = {} + self.small_image_url = small_image.get('uri') + self.small_image_width = small_image.get('width') + self.small_image_height = small_image.get('height') + + if medium_image is None: + medium_image = {} + self.medium_image_url = medium_image.get('uri') + self.medium_image_width = medium_image.get('width') + self.medium_image_height = medium_image.get('height') + + if large_image is None: + large_image = {} + self.large_image_url = large_image.get('uri') + self.large_image_width = large_image.get('width') + self.large_image_height = large_image.get('height') class Mention(object): @@ -211,22 +371,3 @@ class MessageReaction(Enum): ANGRY = '😠' YES = '👍' NO = '👎' - -LIKES = { - 'large': EmojiSize.LARGE, - 'medium': EmojiSize.MEDIUM, - 'small': EmojiSize.SMALL, - 'l': EmojiSize.LARGE, - 'm': EmojiSize.MEDIUM, - 's': EmojiSize.SMALL -} - -MessageReactionFix = { - '😍': ('0001f60d', '%F0%9F%98%8D'), - '😆': ('0001f606', '%F0%9F%98%86'), - '😮': ('0001f62e', '%F0%9F%98%AE'), - '😢': ('0001f622', '%F0%9F%98%A2'), - '😠': ('0001f620', '%F0%9F%98%A0'), - '👍': ('0001f44d', '%F0%9F%91%8D'), - '👎': ('0001f44e', '%F0%9F%91%8E') -} diff --git a/fbchat/utils.py b/fbchat/utils.py index f18baf8..1c0ddad 100644 --- a/fbchat/utils.py +++ b/fbchat/utils.py @@ -32,6 +32,26 @@ USER_AGENTS = [ "Mozilla/5.0 (Windows NT 6.1; WOW64) AppleWebKit/536.6 (KHTML, like Gecko) Chrome/20.0.1092.0 Safari/536.6" ] +LIKES = { + 'large': EmojiSize.LARGE, + 'medium': EmojiSize.MEDIUM, + 'small': EmojiSize.SMALL, + 'l': EmojiSize.LARGE, + 'm': EmojiSize.MEDIUM, + 's': EmojiSize.SMALL +} + +MessageReactionFix = { + '😍': ('0001f60d', '%F0%9F%98%8D'), + '😆': ('0001f606', '%F0%9F%98%86'), + '😮': ('0001f62e', '%F0%9F%98%AE'), + '😢': ('0001f622', '%F0%9F%98%A2'), + '😠': ('0001f620', '%F0%9F%98%A0'), + '👍': ('0001f44d', '%F0%9F%91%8D'), + '👎': ('0001f44e', '%F0%9F%91%8E') +} + + GENDERS = { # For standard requests 0: 'unknown', From 1beb821b2c545ba0427c1eecd80a3ad4078b822a Mon Sep 17 00:00:00 2001 From: Mads Marquart Date: Thu, 21 Sep 2017 23:58:50 +0200 Subject: [PATCH 2/3] Added function to fetch url from image id Fixes #84 --- fbchat/client.py | 27 +++++++++++++++++++++------ fbchat/models.py | 6 +++++- fbchat/utils.py | 9 +++++++++ 3 files changed, 35 insertions(+), 7 deletions(-) diff --git a/fbchat/client.py b/fbchat/client.py index dc95cba..d2fa490 100644 --- a/fbchat/client.py +++ b/fbchat/client.py @@ -771,6 +771,23 @@ class Client(object): "unseen_threads": j['payload']['unseen_thread_ids'] } + def fetchImageUrl(self, image_id): + """Fetches the url to the original image from an image attachment ID + + :param image_id: The image you want to fethc + :type image_id: str + :return: An url where you can download the original image + :rtype: str + :raises: Exception if request failed + """ + image_id = str(image_id) + j = checkRequest(self._get(ReqUrl.ATTACHMENT_PHOTO, query={'photo_id': str(image_id)})) + + url = get_jsmods_require(j, 3) + if url is None: + raise Exception('Could not fetch image url from: {}'.format(j)) + return url + """ END FETCH METHODS """ @@ -833,12 +850,10 @@ class Client(object): except (KeyError, IndexError) as e: raise Exception('Error when sending message: No message IDs could be found: {}'.format(j)) - # update JS token if receive from response - if ('jsmods' in j) and ('require' in j['jsmods']): - try: - self.payloadDefault['fb_dtsg'] = j['jsmods']['require'][0][3][0] - except (KeyError, IndexError) as e: - log.warning("Error when update fb_dtsg. Facebook might have changed protocol.") + # update JS token if received in response + fb_dtsg = get_jsmods_require(j, 2) + if fb_dtsg is not None: + self.payloadDefault['fb_dtsg'] = fb_dtsg return message_id diff --git a/fbchat/models.py b/fbchat/models.py index 48d0ca4..4ac72af 100644 --- a/fbchat/models.py +++ b/fbchat/models.py @@ -220,7 +220,11 @@ class ImageAttachment(Attachment): animated_preview_height = int def __init__(self, original_extension=None, width=None, height=None, is_animated=None, thumbnail_url=None, preview=None, large_preview=None, animated_preview=None, **kwargs): - """Represents an image that has been sent as a Facebook attachment""" + """ + Represents an image that has been sent as a Facebook attachment + To retrieve the full image url, use: :func:`fbchat.Client.fetchImageUrl`, + and pass it the uid of the image attachment + """ super(ImageAttachment, self).__init__(**kwargs) self.original_extension = original_extension self.width = width diff --git a/fbchat/utils.py b/fbchat/utils.py index 1c0ddad..899f0a1 100644 --- a/fbchat/utils.py +++ b/fbchat/utils.py @@ -112,6 +112,7 @@ class ReqUrl(object): MESSAGE_REACTION = "https://www.facebook.com/webgraphql/mutation" TYPING = "https://www.facebook.com/ajax/messaging/typ.php" GRAPHQL = "https://www.facebook.com/api/graphqlbatch/" + ATTACHMENT_PHOTO = "https://www.facebook.com/mercury/attachments/photo/" facebookEncoding = 'UTF-8' @@ -191,3 +192,11 @@ def checkRequest(r, do_json_check=True): return j else: return content + +def get_jsmods_require(j, index): + if j.get('jsmods') and j['jsmods'].get('require'): + try: + return j['jsmods']['require'][0][index][0] + except (KeyError, IndexError) as e: + log.warning('Error when getting jsmods_require: {}. Facebook might have changed protocol'.format(j)) + return None From 34452f9220b85ceee0e477901504c29021eff3f0 Mon Sep 17 00:00:00 2001 From: Mads Marquart Date: Tue, 26 Sep 2017 16:49:10 +0200 Subject: [PATCH 3/3] Changed API description --- fbchat/client.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/fbchat/client.py b/fbchat/client.py index 1a7d61f..594c180 100644 --- a/fbchat/client.py +++ b/fbchat/client.py @@ -824,14 +824,14 @@ class Client(object): :type image_id: str :return: An url where you can download the original image :rtype: str - :raises: Exception if request failed + :raises: FBChatException if request failed """ image_id = str(image_id) j = checkRequest(self._get(ReqUrl.ATTACHMENT_PHOTO, query={'photo_id': str(image_id)})) url = get_jsmods_require(j, 3) if url is None: - raise Exception('Could not fetch image url from: {}'.format(j)) + raise FBChatException('Could not fetch image url from: {}'.format(j)) return url """