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 ebeb10b..e906e20 100644 --- a/fbchat/client.py +++ b/fbchat/client.py @@ -842,6 +842,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: 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 FBChatException('Could not fetch image url from: {}'.format(j)) + return url + """ END FETCH METHODS """ @@ -905,11 +922,9 @@ class Client(object): raise FBchatException('Error when sending message: No message IDs could be found: {}'.format(j)) # update JS token if received in response - if j.get('jsmods') is not None and j['jsmods'].get('require') is not None: - try: - self.payloadDefault['fb_dtsg'] = j['jsmods']['require'][0][3][0] - except (KeyError, IndexError) as e: - log.warning('Error when updating fb_dtsg. Facebook might have changed protocol!') + fb_dtsg = get_jsmods_require(j, 2) + if fb_dtsg is not None: + self.payloadDefault['fb_dtsg'] = fb_dtsg return message_id @@ -1391,7 +1406,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")) @@ -1472,9 +1487,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 parse_json(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 @@ -1632,21 +1729,23 @@ class Client(object): return True - def onMessage(self, mid=None, author_id=None, message=None, thread_id=None, thread_type=ThreadType.USER, ts=None, metadata=None, msg=None): + 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=None): """ 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=None): """ diff --git a/fbchat/models.py b/fbchat/models.py index db7898c..2d99202 100644 --- a/fbchat/models.py +++ b/fbchat/models.py @@ -163,6 +163,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 @@ -179,12 +183,12 @@ class Message(object): 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=None, text=None, mentions=None, sticker=None, attachments=None, extensible_attachment=None): + def __init__(self, uid, author=None, timestamp=None, is_read=None, reactions=None, text=None, mentions=None, sticker=None, attachments=None, extensible_attachment=None, emoji_size=None): """Represents a Facebook message""" self.uid = uid self.author = author @@ -204,6 +208,180 @@ class Message(object): if extensible_attachment is None: extensible_attachment = {} 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 + 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 + 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): @@ -278,22 +456,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 61f0f14..135b60e 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', @@ -92,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/" EVENT_REMINDER = "https://www.facebook.com/ajax/eventreminder/create" pull_channel = 0 @@ -122,6 +143,9 @@ def get_decoded_r(r): def get_decoded(content): return content.decode(facebookEncoding) +def parse_json(content): + return json.loads(content) + def get_json(r): return json.loads(strip_to_json(get_decoded_r(r))) @@ -183,3 +207,11 @@ def check_request(r, as_json=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