diff --git a/README.md b/README.md index de65332..194404f 100644 --- a/README.md +++ b/README.md @@ -1,4 +1,4 @@ -![LapsePy](./icon.png) +![LapsePy](https://github.com/quintindunn/lapsepy/blob/main/icon.png?raw=true)
diff --git a/examples/add_reaction/add_reaction_01.py b/examples/add_reaction/add_reaction_01.py new file mode 100644 index 0000000..08065e9 --- /dev/null +++ b/examples/add_reaction/add_reaction_01.py @@ -0,0 +1,10 @@ +import os +from lapsepy.lapse import Lapse + +if __name__ == '__main__': + lapse = Lapse(refresh_token=os.getenv("REFRESH_TOKEN")) + + msg_id = input("MSG ID: ") + reaction = input("Reaction: ") + + lapse.add_reaction(msg_id=msg_id, reaction=reaction) diff --git a/examples/add_reaction/add_reaction_02.py b/examples/add_reaction/add_reaction_02.py new file mode 100644 index 0000000..d5ff5e2 --- /dev/null +++ b/examples/add_reaction/add_reaction_02.py @@ -0,0 +1,12 @@ +import os +from lapsepy.lapse import Lapse + +if __name__ == '__main__': + lapse = Lapse(refresh_token=os.getenv("REFRESH_TOKEN")) + + count = int(input("How many: ")) + msg_id = input("MSG ID: ") + reaction = input("Reaction: ") + + for _ in range(count): + lapse.add_reaction(msg_id=msg_id, reaction=reaction) diff --git a/examples/create_status_update/create_status_update_01.py b/examples/create_status_update/create_status_update_01.py new file mode 100644 index 0000000..a98accc --- /dev/null +++ b/examples/create_status_update/create_status_update_01.py @@ -0,0 +1,9 @@ +import os + +from lapsepy.lapse import Lapse + +if __name__ == '__main__': + lapse = Lapse(os.getenv("REFRESH_TOKEN")) + + msg = input("Message: ") + lapse.create_status_update(text=msg) diff --git a/examples/delete_comment/delete_comment.py b/examples/delete_comment/delete_comment.py new file mode 100644 index 0000000..7055536 --- /dev/null +++ b/examples/delete_comment/delete_comment.py @@ -0,0 +1,10 @@ +import os +from lapsepy.lapse import Lapse + +if __name__ == '__main__': + lapse = Lapse(refresh_token=os.getenv("REFRESH_TOKEN")) + + msg_id = input("Message ID: ") + comment = input("Comment: ") + + lapse.delete_comment(msg_id=msg_id, comment_id=comment) diff --git a/examples/get_friends_feed/get_friends_feed_1.py b/examples/get_friends_feed/get_friends_feed_1.py index 3639ae4..667c5fa 100644 --- a/examples/get_friends_feed/get_friends_feed_1.py +++ b/examples/get_friends_feed/get_friends_feed_1.py @@ -1,5 +1,7 @@ import os +import requests.exceptions + from lapsepy.lapse import Lapse if __name__ == '__main__': @@ -11,10 +13,33 @@ if not os.path.isdir("./out"): os.mkdir("./out") - for profile in friends_feed: - profile.load_profile_picture(quality=100, height=None) - profile.profile_picture.save(f"./out/{profile.username}.jpg") - for snap in profile.media: - snap.load_snap(quality=100, fl_keep_iptc=True) - save_path = f"./out/{snap.filtered_id.replace('/', '_')}.jpg" - snap.filtered.save(save_path) + for friend_node in friends_feed: + profile = friend_node.profile + + # Get profile picture + try: + profile.load_profile_picture(quality=100, height=None) + profile.profile_picture.save(f"./out/{profile.username}.jpg") + except requests.exceptions.HTTPError: + print(f"Failed getting profile picture for {profile.username}") + + # Get all images from collections + for entry in friend_node.entries: + entry.load_snap(quality=100, fl_keep_iptc=True) + save_path = f"./out/{entry.filtered_id.replace('/', '_')}.jpg" + entry.filtered.save(save_path) + + # Get profile music if user has profile music. + if profile.profile_music is not None: + profile.profile_music.load() + profile_music = profile.profile_music + + # Save artwork if exists + if profile_music.artwork: + save_path = f"./out/{profile.username}_music.png" + profile_music.artwork.save(save_path) + + # Save song + save_path = f"./out/{profile.username}_music.mp3" + with open(save_path, 'wb') as f: + f.write(profile_music.song) diff --git a/examples/remove_reaction/remove_reaction_01.py b/examples/remove_reaction/remove_reaction_01.py new file mode 100644 index 0000000..7287f8a --- /dev/null +++ b/examples/remove_reaction/remove_reaction_01.py @@ -0,0 +1,10 @@ +import os +from lapsepy.lapse import Lapse + +if __name__ == '__main__': + lapse = Lapse(refresh_token=os.getenv("REFRESH_TOKEN")) + + msg_id = input("MSG ID: ") + reaction = input("Reaction: ") + + lapse.remove_reaction(msg_id=msg_id, reaction=reaction) diff --git a/examples/remove_status_update/remove_status_update_01.py b/examples/remove_status_update/remove_status_update_01.py new file mode 100644 index 0000000..cd379e6 --- /dev/null +++ b/examples/remove_status_update/remove_status_update_01.py @@ -0,0 +1,9 @@ +import os + +from lapsepy import Lapse + +if __name__ == '__main__': + lapse = Lapse(os.getenv("REFRESH_TOKEN")) + + msg_to_remove = input("MSG ID: ") + lapse.remove_status_update(msg_to_remove) diff --git a/examples/send_comment/send_comment.py b/examples/send_comment/send_comment.py new file mode 100644 index 0000000..26f9209 --- /dev/null +++ b/examples/send_comment/send_comment.py @@ -0,0 +1,10 @@ +import os +from lapsepy.lapse import Lapse + +if __name__ == '__main__': + lapse = Lapse(refresh_token=os.getenv("REFRESH_TOKEN")) + + msg_id = input("Message ID: ") + comment = input("Comment: ") + + lapse.send_comment(msg_id=msg_id, text=comment) diff --git a/examples/send_kudos/send_kudos_1.py b/examples/send_kudos/send_kudos_1.py new file mode 100644 index 0000000..578320d --- /dev/null +++ b/examples/send_kudos/send_kudos_1.py @@ -0,0 +1,14 @@ +import os +from PIL import Image +from lapsepy.lapse import Lapse + +if __name__ == '__main__': + lapse = Lapse(refresh_token=os.getenv("REFRESH_TOKEN")) + + # Develop in 15 seconds + friend_id = input("Friend UUID: ") + + # Get friend object + friend = lapse.get_profile_by_id(friend_id) + + lapse.send_kudos(user=friend) diff --git a/lapsepy/__init__.py b/lapsepy/__init__.py index 51bd283..8af1e1d 100644 --- a/lapsepy/__init__.py +++ b/lapsepy/__init__.py @@ -3,7 +3,7 @@ Date: 10/22/23 """ -__version__ = '0.1.1' +__version__ = '0.2.1' from .journal import Journal from .auth.refresher import refresh diff --git a/lapsepy/journal/factory/friends_factory.py b/lapsepy/journal/factory/friends_factory.py index 6120cff..5d88831 100644 --- a/lapsepy/journal/factory/friends_factory.py +++ b/lapsepy/journal/factory/friends_factory.py @@ -11,7 +11,7 @@ class FriendsFeedItemsGQL(BaseGQL): Gets items from friends feed. """ - def __init__(self, start_cursor: str | None = None): + def __init__(self, last: int = 10): super().__init__( operation_name="FriendsFeedItemsGraphQLQuery", query="query FriendsFeedItemsGraphQLQuery($first: Int, $after: String, $last: Int, $before: String) { " @@ -31,60 +31,72 @@ def __init__(self, start_cursor: str | None = None): "FriendsFeedItemProfileCompletedV1 { ...FriendsFeedItemProfileCompletedDetails } ... on " "FriendsFeedItemProfilePhotoUpdatedV1 { ...FriendsFeedItemProfilePhotoUpdatedDetails } ... on " "FriendsFeedItemSelectsUpdatedV1 { ...FriendsFeedItemSelectsUpdatedDetails } ... on " + "FriendsFeedItemStatusUpdatedV1 { ...FriendsFeedItemStatusUpdatedDetails } ... on " "FriendsFeedItemTaggedMediaSharedV2 { ...FriendsFeedItemTaggedMediaSharedDetails } } comments(" - "first: 3) { __typename edges { __typename node { __typename ...MediaCommentDetails } } totalCount " - "} reactions { __typename ...MediaReactionDetails } user { __typename ...ProfileDetails } timestamp " - "{ __typename isoString } }\nfragment FriendsFeedItemAlbumUpdatedDetails on " - "FriendsFeedItemAlbumUpdatedV1 { __typename id title mediaIds totalCount }\nfragment " - "FriendsFeedItemBioUpdatedDetails on FriendsFeedItemBioUpdatedV1 { __typename bio }\nfragment " - "FriendsFeedItemEmojisUpdatedDetails on FriendsFeedItemEmojisUpdatedV1 { __typename emojis " - "}\nfragment FriendsFeedItemFriendSuggestionsDetails on FriendsFeedItemFriendSuggestionsV1 { " - "__typename suggestions { __typename ...FriendSuggestionDetails } }\nfragment " - "FriendSuggestionDetails on FriendSuggestion { __typename profile { __typename ...ProfileDetails " - "invitedBy(last: 3) { __typename edges { __typename node { __typename ...ProfileDetails } } } } " - "reason }\nfragment ProfileDetails on Profile { __typename id displayName profilePhotoName username " - "bio emojis { __typename emojis } friendStatus isBlocked blockedMe hashedPhoneNumber joinedAt { " - "__typename isoString } kudos { __typename emoji totalCount lastSentAt { __typename isoString } } " - "selectsVideo { __typename ...RecapVideoDetails } music { __typename ...ProfileMusicDetails } tags " - "{ __typename type text } }\nfragment RecapVideoDetails on RecapVideo { __typename id videoFilename " - "totalDuration interval media { __typename imageFilename } }\nfragment ProfileMusicDetails on " - "ProfileMusic { __typename artist artworkUrl duration songTitle songUrl }\nfragment " + "first: 3) { __typename edges { __typename cursor node { __typename ...MediaCommentDetails } } " + "totalCount } reactions { __typename ...MediaReactionDetails } user { __typename " + "...CoreProfileFragment } timestamp { __typename isoString } }\nfragment " + "FriendsFeedItemAlbumUpdatedDetails on FriendsFeedItemAlbumUpdatedV1 { __typename id title mediaIds " + "totalCount }\nfragment FriendsFeedItemBioUpdatedDetails on FriendsFeedItemBioUpdatedV1 { " + "__typename bio }\nfragment FriendsFeedItemEmojisUpdatedDetails on FriendsFeedItemEmojisUpdatedV1 { " + "__typename emojis }\nfragment FriendsFeedItemFriendSuggestionsDetails on " + "FriendsFeedItemFriendSuggestionsV1 { __typename suggestions { __typename " + "...FriendSuggestionDetails } }\nfragment FriendSuggestionDetails on FriendSuggestion { __typename " + "profile { __typename ...ViewProfileSummaryFragment invitedBy(last: 3) { __typename edges { " + "__typename cursor node { __typename ...CoreProfileFragment } } } } reason }\nfragment " + "ViewProfileSummaryFragment on Profile { __typename ...CoreProfileFragment " + "...ViewProfileSelectsFragment ...ViewProfileMusicFragment bio emojis { __typename emojis } kudos { " + "__typename emoji totalCount lastSentAt { __typename isoString } } tags { __typename type text } " + "}\nfragment CoreProfileFragment on Profile { __typename id displayName profilePhotoName username " + "friendStatus isBlocked blockedMe hashedPhoneNumber joinedAt { __typename isoString } }\nfragment " + "ViewProfileSelectsFragment on Profile { __typename selectsVideo { __typename " + "...CoreRecapVideoFragment } }\nfragment CoreRecapVideoFragment on RecapVideo { __typename id " + "videoFilename totalDuration interval }\nfragment ViewProfileMusicFragment on Profile { __typename " + "music { __typename ...ProfileMusicDetails } }\nfragment ProfileMusicDetails on ProfileMusic { " + "__typename artist artworkUrl duration songTitle songUrl }\nfragment " "FriendsFeedItemFriendRequestsDetails on FriendsFeedItemFriendRequestsV1 { __typename requests { " "__typename ...FriendRequestDetails } }\nfragment FriendRequestDetails on FriendRequest { " - "__typename profile { __typename ...ProfileDetails invitedBy(last: 3) { __typename edges { " - "__typename cursor node { __typename ...ProfileDetails } } } } }\nfragment " + "__typename profile { __typename ...ViewProfileSummaryFragment invitedBy(last: 3) { __typename " + "edges { __typename cursor node { __typename ...CoreProfileFragment } } } } }\nfragment " "FriendsFeedItemKudosUpdatedDetails on FriendsFeedItemKudosUpdatedV1 { __typename empty }\nfragment " "FriendsFeedItemMediaFeaturedDetails on FriendsFeedItemMediaFeaturedV1 { __typename media { " - "__typename ...MediaDetails } }\nfragment MediaDetails on Media { __typename id takenAt { " - "__typename isoString } takenBy { __typename ...ProfileDetails } commentsCount developsAt { " - "__typename isoString } destroyedAt { __typename isoString } deletedAt { __typename isoString } " - "partyId timeCapsuleId timezone content { __typename filtered original } submittedToTeam reactions " - "{ __typename ...MediaReactionDetails } comments(first: 3) { __typename edges { __typename node { " - "__typename ...MediaCommentDetails } } } tags(first: 3) { __typename edges { __typename node { " - "__typename ...MediaTagDetails } } totalCount } featured faceFrames { __typename xPos yPos width " - "height } }\nfragment MediaReactionDetails on MediaReaction { __typename emoji hasReacted count " - "}\nfragment MediaCommentDetails on MediaComment { __typename id author { __typename " - "...ProfileDetails } media { __typename id } createdAt { __typename isoString } deletedAt { " - "__typename isoString } text isLiked likeCount }\nfragment MediaTagDetails on MediaTag { __typename " - "frame { __typename position { __typename xPos yPos } size { __typename width height } } taggedAt { " - "__typename isoString } taggedBy { __typename id } ... on MediaContactTag { hashedPhoneNumber } ... " - "on MediaProfileTag { profile { __typename id displayName username profilePhotoName friendStatus } " - "shared } }\nfragment FriendsFeedItemMediaSharedDetails on FriendsFeedItemMediaSharedV1 { " - "__typename entries { __typename ...FriendsFeedItemMediaSharedEntryDetails } }\nfragment " - "FriendsFeedItemMediaSharedEntryDetails on FriendsFeedItemMediaSharedEntryV1 { __typename id seen " - "media { __typename ...MediaDetails } }\nfragment FriendsFeedItemMusicUpdatedDetails on " + "__typename ...CoreMediaFragment ...MediaWithReactionsFragment } }\nfragment CoreMediaFragment on " + "Media { __typename id takenAt { __typename isoString } takenBy { __typename ...CoreProfileFragment " + "} deletedAt { __typename isoString } }\nfragment MediaWithReactionsFragment on Media { __typename " + "reactions { __typename ...MediaReactionDetails } }\nfragment MediaReactionDetails on MediaReaction " + "{ __typename emoji hasReacted count }\nfragment FriendsFeedItemMediaSharedDetails on " + "FriendsFeedItemMediaSharedV1 { __typename entries { __typename " + "...FriendsFeedItemMediaSharedEntryDetails } }\nfragment FriendsFeedItemMediaSharedEntryDetails on " + "FriendsFeedItemMediaSharedEntryV1 { __typename id seen media { __typename " + "...FullMediaPreviewFragment } }\nfragment FullMediaPreviewFragment on Media { __typename " + "...CoreMediaFragment ...MediaWithMetadataFragment ...MediaWithCommentsPreviewFragment " + "...MediaWithReactionsFragment ...MediaWithTagsPreviewFragment }\nfragment " + "MediaWithMetadataFragment on Media { __typename developsAt { __typename isoString } timezone " + "content { __typename filtered original } submittedToTeam featured faceFrames { __typename xPos " + "yPos width height } }\nfragment MediaWithCommentsPreviewFragment on Media { __typename " + "commentsCount comments(first: 3) { __typename edges { __typename cursor node { __typename " + "...MediaCommentDetails } } } }\nfragment MediaCommentDetails on MediaComment { __typename id " + "author { __typename ...CoreProfileFragment } media { __typename id } createdAt { __typename " + "isoString } deletedAt { __typename isoString } text isLiked likeCount }\nfragment " + "MediaWithTagsPreviewFragment on Media { __typename tags(first: 3) { __typename edges { __typename " + "cursor node { __typename ...MediaTagDetails } } totalCount } }\nfragment MediaTagDetails on " + "MediaTag { __typename frame { __typename position { __typename xPos yPos } size { __typename width " + "height } } taggedAt { __typename isoString } taggedBy { __typename id } ... on MediaContactTag { " + "hashedPhoneNumber } ... on MediaProfileTag { profile { __typename id displayName username " + "profilePhotoName friendStatus } shared } }\nfragment FriendsFeedItemMusicUpdatedDetails on " "FriendsFeedItemMusicUpdatedV1 { __typename artist artworkUrl songTitle }\nfragment " "FriendsFeedItemProfileCompletedDetails on FriendsFeedItemProfileCompletedV1 { __typename photoName " - "selectsVideo { __typename ...RecapVideoDetails } }\nfragment " + "selectsVideo { __typename ...CoreRecapVideoFragment } }\nfragment " "FriendsFeedItemProfilePhotoUpdatedDetails on FriendsFeedItemProfilePhotoUpdatedV1 { __typename " "profilePhotoName }\nfragment FriendsFeedItemSelectsUpdatedDetails on " "FriendsFeedItemSelectsUpdatedV1 { __typename imageFilename selectsVideo { __typename " - "...RecapVideoDetails } }\nfragment FriendsFeedItemTaggedMediaSharedDetails on " - "FriendsFeedItemTaggedMediaSharedV2 { __typename sharedMedia { __typename " - "...FriendsFeedItemMediaSharedDetails } }") + "...CoreRecapVideoFragment } }\nfragment FriendsFeedItemStatusUpdatedDetails on " + "FriendsFeedItemStatusUpdatedV1 { __typename body { __typename text } }\nfragment " + "FriendsFeedItemTaggedMediaSharedDetails on FriendsFeedItemTaggedMediaSharedV2 { __typename " + "sharedMedia { __typename ...FriendsFeedItemMediaSharedDetails } }") - self.last = 10 - self.before = start_cursor + self.last = last + self.before = None self.variables = {} @@ -156,3 +168,20 @@ def _render_variables(self): "mutualLimit": self.mutual_limit, "popularLimit": self.popular_limit } + + +class SendKudosGQL(BaseGQL): + def __init__(self, user_id: str): + super().__init__("SendKudosGraphQLMutation", "mutation SendKudosGraphQLMutation($input: SendKudosInput!) " + "{ sendKudos(input: $input) { __typename success } }") + + self.user_id = user_id + + self.variables = {} + + self._render_variables() + + def _render_variables(self): + self.variables['input'] = { + "id": self.user_id + } diff --git a/lapsepy/journal/factory/media_factory.py b/lapsepy/journal/factory/media_factory.py index 20a8f1a..2ee7bbd 100644 --- a/lapsepy/journal/factory/media_factory.py +++ b/lapsepy/journal/factory/media_factory.py @@ -125,3 +125,123 @@ def _render_variables(self): self.variables["filename"] = f"{self.file_uuid}/filtered_0.heic" else: self.variables["filename"] = f"instant/{self.file_uuid}.heic" + + +class StatusUpdateGQL(BaseGQL): + def __init__(self, text: str, msg_id: str): + super().__init__("CreateStatusUpdateGraphQLMutation", + "mutation CreateStatusUpdateGraphQLMutation($input: CreateStatusUpdateInput!) " + "{ createStatusUpdate(input: $input) { __typename success } }") + self.text = text + self.msg_id = msg_id + + self.variables = {} + + self._render_variables() + + def _render_variables(self): + self.variables['input'] = { + "body": { + "text": self.text + }, + "id": self.msg_id + } + + +class RemoveFriendsFeedItemGQL(BaseGQL): + def __init__(self, msg_id: str, iso_string: str): + super().__init__("RemoveFriendsFeedItem", + "mutation RemoveFriendsFeedItem($input: RemoveFriendsFeedItemInput!) " + "{ removeFriendsFeedItem(input: $input) { __typename success } }") + self.msg_id = msg_id + self.iso_string = iso_string + + self.variables = {} + + self._render_variables() + + def _render_variables(self): + self.variables['input'] = { + "id": self.msg_id, + "removedAt": { + "isoString": self.iso_string + } + } + + +class AddReactionGQL(BaseGQL): + def __init__(self, msg_id: str, reaction: str): + super().__init__("AddReactionGraphQLMutation", + "mutation AddReactionGraphQLMutation($input: AddMediaReactionInput!) " + "{ addMediaReaction(input: $input) { __typename success } }") + self.msg_id = msg_id + self.reaction = reaction + + self.variables = {} + + self._render_variables() + + def _render_variables(self): + self.variables['input'] = { + "id": self.msg_id, + "reaction": self.reaction + } + + +class RemoveReactionGQL(BaseGQL): + def __init__(self, msg_id: str, reaction: str): + super().__init__("RemoveReactionGraphQLMutation", + "mutation RemoveReactionGraphQLMutation($input: RemoveMediaReactionInput!) " + "{ removeMediaReaction(input: $input) { __typename success } }") + self.msg_id = msg_id + self.reaction = reaction + + self.variables = {} + + self._render_variables() + + def _render_variables(self): + self.variables['input'] = { + "id": self.msg_id, + "reaction": self.reaction + } + + +class SendCommentGQL(BaseGQL): + def __init__(self, comment_id: str, msg_id: str, text: str): + super().__init__("SendCommentGraphQLMutation", + "mutation SendCommentGraphQLMutation($input: SendMediaCommentInput!) " + "{ sendMediaComment(input: $input) { __typename success } }") + self.comment_id = comment_id + self.msg_id = msg_id + self.text = text + + self.variables = {} + + self._render_variables() + + def _render_variables(self): + self.variables['input'] = { + "id": self.comment_id, + "mediaId": self.msg_id, + "text": self.text + } + + +class DeleteCommentGQL(BaseGQL): + def __init__(self, comment_id: str, msg_id: str): + super().__init__("DeleteCommentGraphQLMutation", + "mutation DeleteCommentGraphQLMutation($input: DeleteMediaCommentInput!) " + "{ deleteMediaComment(input: $input) { __typename success } }") + self.comment_id = comment_id + self.msg_id = msg_id + + self.variables = {} + + self._render_variables() + + def _render_variables(self): + self.variables['input'] = { + "id": self.comment_id, + "mediaId": self.msg_id, + } diff --git a/lapsepy/journal/journal.py b/lapsepy/journal/journal.py index f9668d7..8aae69d 100644 --- a/lapsepy/journal/journal.py +++ b/lapsepy/journal/journal.py @@ -4,6 +4,7 @@ """ import io +import uuid from .common.exceptions import sync_journal_exception_router, SyncJournalException @@ -13,12 +14,13 @@ import requests -from .factory.friends_factory import FriendsFeedItemsGQL, ProfileDetailsGQL -from .factory.media_factory import ImageUploadURLGQL, CreateMediaGQL, SendInstantsGQL +from .factory.friends_factory import FriendsFeedItemsGQL, ProfileDetailsGQL, SendKudosGQL +from .factory.media_factory import ImageUploadURLGQL, CreateMediaGQL, SendInstantsGQL, StatusUpdateGQL, \ + RemoveFriendsFeedItemGQL, AddReactionGQL, RemoveReactionGQL, SendCommentGQL, DeleteCommentGQL from lapsepy.journal.factory.profile_factory import SaveBioGQL, SaveDisplayNameGQL, SaveUsernameGQL, SaveEmojisGQL, \ SaveDOBGQL -from .structures import Profile, Snap +from .structures import Snap, Profile, ProfileMusic, FriendsFeed, FriendNode import logging @@ -164,7 +166,6 @@ def upload_instant(self, im: Image.Image, user_id: str, file_uuid: str | None = if file_uuid is None: # UUID in testing always started with "01HDCWT" with a total length of 26 chars. file_uuid = "01HDCWT" + str(uuid4()).upper().replace("-", "")[:19] - print(file_uuid) if im_id is None: # UUID in testing always started with "01HDCWT" with a total length of 26 chars. @@ -178,57 +179,82 @@ def upload_instant(self, im: Image.Image, user_id: str, file_uuid: str | None = time_limit=time_limit).to_dict() self._sync_journal_call(query) - def get_friends_feed(self, count: int = 10) -> list[Profile]: + def create_status_update(self, text: str, msg_id: str | None): + """ + Creates a status update on your Journal + :param text: Msg of the text to send + :param msg_id: Leave None if you don't know what you're doing. FORMAT: STATUS_UPDATE:<(str(uuid.uuid4))> + :return: + """ + if msg_id is None: + msg_id = f"STATUS_UPDATE:{uuid.uuid4()}" + query = StatusUpdateGQL(text=text, msg_id=msg_id).to_dict() + response = self._sync_journal_call(query) + + if not response.get("data", {}).get("createStatusUpdate", {}).get("success"): + raise SyncJournalException("Error create new status.") + + def remove_status_update(self, msg_id: str, removed_at: datetime | None): + """ + Removes a status update + :param msg_id: ID of the status update + :param removed_at: datetime object of when it was removed + :return: + """ + if removed_at is None: + removed_at = datetime.now() + removed_at = format_iso_time(removed_at) + + query = RemoveFriendsFeedItemGQL(msg_id=msg_id, iso_string=removed_at).to_dict() + response = self._sync_journal_call(query) + + if not response.get("data", {}).get("removeFriendsFeedItem", {}).get("success"): + raise SyncJournalException("Failed removing status.") + + def send_kudos(self, user_id: str): + """ + Sends kudos (vibes) to a given user + :param user_id: id of the user to send kudos to. + :return: + """ + query = SendKudosGQL(user_id=user_id).to_dict() + response = self._sync_journal_call(query) + + if not response.get("data", {}).get("sendKudos", {}).get("success"): + raise SyncJournalException("Error sending kudos, could you already have reached your daily limit?") + + def get_friends_feed(self, count: int = 10) -> FriendsFeed: """ Gets your friend upload feed. :param count: How many collection to grab. :return: A list of profiles """ - cursor = None + # Get all the user's friends in the range. + query = FriendsFeedItemsGQL(last=count).to_dict() + response = self._sync_journal_call(query) - profiles = {} - entry_ids = [] + nodes: list[dict] = [i['node'] for i in response['data']['friendsFeedItems']['edges']] - # If it started to repeat itself. - maxed = False - for _ in range(1, count, 10): - logger.debug(f"Getting friends feed starting from cursor: {cursor or 'INITIAL'}") - query = FriendsFeedItemsGQL(cursor).to_dict() - response = self._sync_journal_call(query) + friend_nodes = [] - # Where to query the new data from - cursor = response['data']['friendsFeedItems']['pageInfo']['endCursor'] - if cursor is None: - logger.debug("Reached max cursor depth.") - break + for node in nodes: + profile_data = node.get("user") + profile = Profile.from_dict(profile_data) - # Trim useless data from response - feed_data = [i['node'] for i in response['data']['friendsFeedItems']['edges']] + timestamp = node.get("timestamp", {}).get("isoString") - # Create Profile objects which hold the media data in Profile.media - for node in feed_data: - username = node.get('user').get('username') - if username in profiles.keys(): - profile = profiles[username] - else: - profile = Profile.from_dict(node.get("user")) - profiles[username] = profile + entries = node.get("content").get("entries") - for entry in node['content']['entries']: - eid = entry['id'] - if eid in entry_ids: - logger.warn("Found duplicate of media, must've reached the end already...") - maxed = True - break - entry_ids.append(eid) - snap = Snap.from_dict(entry) - profile.media.append(snap) + node_entry_objs = [] + for entry in entries: + snap = Snap.from_dict(entry) + node_entry_objs.append(snap) + profile.media.append(snap) - if maxed: - break + friend_nodes.append(FriendNode(profile=profile, iso_string=timestamp, entries=node_entry_objs)) - return list(profiles.values()) + return FriendsFeed(friend_nodes) def get_profile_by_id(self, user_id: str, album_limit: int = 6, friends_limit: int = 10) -> Profile: """ @@ -249,6 +275,18 @@ def get_profile_by_id(self, user_id: str, album_limit: int = 6, friends_limit: i pd = response.get("data", {}).get("profile", {}) def generate_profile_object(profile_data: dict) -> Profile: + music = profile_data.get("music") + if music is not None: + profile_music = ProfileMusic( + artist=music.get("artist"), + artwork_url=music.get("artworkUrl"), + duration=music.get("duration"), + song_title=music.get("songTitle"), + song_url=music.get("songUrl") + ) + else: + profile_music = None + return Profile( bio=profile_data.get('bio'), blocked_me=profile_data.get('blockedMe'), @@ -261,6 +299,7 @@ def generate_profile_object(profile_data: dict) -> Profile: tags=profile_data.get("tags"), user_id=profile_data.get('id'), username=profile_data.get('username'), + profile_music=profile_music ) profile = generate_profile_object(pd) @@ -341,3 +380,58 @@ def modify_dob(self, dob: str): if not response.get('data', {}).get("saveDateOfBirth", {}).get("success"): raise SyncJournalException("Error saving date of birth.") + + def add_reaction(self, msg_id: str, reaction: str): + """ + Adds a reaction to a message + :param msg_id: ID of msg to send reaction to. + :param reaction: Reaction to send. + :return: + """ + query = AddReactionGQL(msg_id=msg_id, reaction=reaction).to_dict() + response = self._sync_journal_call(query) + + if not response.get('data', {}).get("addMediaReaction", {}).get("success"): + raise SyncJournalException("Error adding reaction.") + + def remove_reaction(self, msg_id: str, reaction: str): + """ + removes a reaction from a message + :param msg_id: ID of msg to remove reaction from. + :param reaction: Reaction to remove. + :return: + """ + query = RemoveReactionGQL(msg_id=msg_id, reaction=reaction).to_dict() + response = self._sync_journal_call(query) + + if not response.get('data', {}).get("removeMediaReaction", {}).get("success"): + raise SyncJournalException("Error removing reaction.") + + def create_comment(self, msg_id: str, text: str, comment_id: str | None = None): + """ + Adds a comment to a post + :param comment_id: id of the comment, leave as None unless you know what you're doing + :param msg_id: id of the message + :param text: text to send in the comment + :return: + """ + if comment_id is None: + comment_id = "01HEH" + str(uuid4()).upper().replace("-", "")[:20] + query = SendCommentGQL(comment_id=comment_id, msg_id=msg_id, text=text).to_dict() + response = self._sync_journal_call(query) + + if not response.get('data', {}).get("sendMediaComment", {}).get("success"): + raise SyncJournalException("Error sending comment.") + + def delete_comment(self, msg_id: str, comment_id: str): + """ + Deletes a comment from a lapsepy post + :param msg_id: ID of the post + :param comment_id: ID of the comment + :return: + """ + query = DeleteCommentGQL(msg_id=msg_id, comment_id=comment_id).to_dict() + response = self._sync_journal_call(query) + + if not response.get('data', {}).get("deleteMediaComment", {}).get("success"): + raise SyncJournalException("Error deleting comment.") diff --git a/lapsepy/journal/structures/__init__.py b/lapsepy/journal/structures/__init__.py new file mode 100644 index 0000000..c54c325 --- /dev/null +++ b/lapsepy/journal/structures/__init__.py @@ -0,0 +1,8 @@ +""" +Author: Quintin Dunn +Date: 10/27/23 +""" + +from .profile import Profile, ProfileMusic +from .snap import Snap +from .friendsfeed import FriendsFeed, FriendNode diff --git a/lapsepy/journal/structures/comment.py b/lapsepy/journal/structures/comment.py new file mode 100644 index 0000000..5965a41 --- /dev/null +++ b/lapsepy/journal/structures/comment.py @@ -0,0 +1,17 @@ +from datetime import datetime + +from .core import Media + +from .profile import Profile + + +class Comment: + def __init__(self, author: Profile, create_at: datetime, comment_id: str, likes: int, is_liked: bool, media: + Media, text: str): + self.author: Profile = author + self.created_at: create_at = create_at + self.comment_id: comment_id = comment_id + self.likes: int = likes + self.is_liked: bool = is_liked + self.media: Media = media + self.text: str = text diff --git a/lapsepy/journal/structures/core.py b/lapsepy/journal/structures/core.py new file mode 100644 index 0000000..da67200 --- /dev/null +++ b/lapsepy/journal/structures/core.py @@ -0,0 +1,2 @@ +class Media: + pass diff --git a/lapsepy/journal/structures/friendsfeed.py b/lapsepy/journal/structures/friendsfeed.py new file mode 100644 index 0000000..34429f6 --- /dev/null +++ b/lapsepy/journal/structures/friendsfeed.py @@ -0,0 +1,23 @@ +from datetime import datetime + +from .profile import Profile +from .snap import Snap + + +def _dt_from_iso(dt_str: str): + return datetime.fromisoformat(dt_str) + + +class FriendsFeed: + def __init__(self, nodes: list["FriendNode"]): + self.nodes: list[FriendNode] = nodes + + def __iter__(self): + return iter(self.nodes) + + +class FriendNode: + def __init__(self, profile: Profile, iso_string: str, entries: list[Snap]): + self.profile = profile + self.timestamp: datetime = _dt_from_iso(iso_string) + self.entries: list[Snap] = entries diff --git a/lapsepy/journal/structures/profile.py b/lapsepy/journal/structures/profile.py new file mode 100644 index 0000000..46a4a09 --- /dev/null +++ b/lapsepy/journal/structures/profile.py @@ -0,0 +1,143 @@ +import io +import logging +import requests + +from datetime import datetime + +from PIL import Image +import io + +from .snap import Snap + +logger = logging.getLogger("lapsepy.journal.structures.py") + + +def _dt_from_iso(dt_str: str): + return datetime.fromisoformat(dt_str) + + +class Profile: + def __init__(self, user_id: str, username: str, display_name: str, profile_photo_name: str, bio: str | None, + emojis: list[str], is_friends: bool, blocked_me: bool, kudos: int, tags: list[dict], + is_blocked: bool = False, friends: list["Profile"] = None, profile_music: "ProfileMusic" = None): + if friends is None: + friends = [] + + self.bio: str = bio + self.blocked_me: bool = blocked_me + self.user_display_name: str = display_name + self.emojis: list[str] = emojis + self.is_friends: bool = is_friends + self.kudos = kudos + self.profile_photo_name: str = profile_photo_name + self.tags = tags + self.user_id: str = user_id + self.username: str = username + self.media: list[Snap] = [] + self.is_blocked = is_blocked + + self.friends: list["Profile"] = friends + self.profile_music = profile_music + + self.profile_picture: Image.Image | None = None + + @staticmethod + def from_dict(profile_data: dict) -> "Profile": + """ + Generates a Profile object from a dictionary with the necessary profile data + :param profile_data: Dictionary containing the necessary data. + :return: Profile object prefilled with the data. + """ + logger.debug("Creating new Profile object from dictionary.") + + pd = profile_data + + music = pd.get("music") + if music is not None: + profile_music = ProfileMusic( + artist=music.get("artist"), + artwork_url=music.get("artworkUrl"), + duration=music.get("duration"), + song_title=music.get("songTitle"), + song_url=music.get("songUrl") + ) + else: + profile_music = None + + return Profile( + bio=pd.get('bio'), + blocked_me=pd.get('blockedMe'), + display_name=pd.get('displayName'), + emojis=pd.get("emojis", {}).get("emojis"), + is_friends=pd.get("friendStatus") == "FRIENDS", + kudos=pd.get("kudos", {}).get("totalCount", -1), + profile_photo_name=pd.get('profilePhotoName'), + tags=pd.get("tags"), + user_id=pd.get('id'), + username=pd.get('username'), + profile_music=profile_music + ) + + def load_profile_picture(self, quality: int = 100, height: int | None = None) -> Image.Image: + """ + Loads the Profile's profile picture into memory by making an HTTP request to Lapse's servers. + :param quality: Quality of the image (1-100) + seek https://cloudinary.com/documentation/transformation_reference#q_quality for more information. + :param height: Height of the image in pixels, width is determined by image aspect ratio. Leave as None to get + original height. + + :return: Pillow image. + """ + url = f"https://image.production.journal-api.lapse.app/image/upload/q_{quality}" + url += f",h_{height}" if height is not None else "" + url += f"//{self.profile_photo_name}.jpg" + + logger.debug(f"Getting profile image from \"{url}\"") + + request = requests.get(url) + request.raise_for_status() + + bytes_io = io.BytesIO(request.content) + image = Image.open(bytes_io) + + self.profile_picture = image + + return image + + def send_instant(self, ctx, im: Image, file_uuid: str | None = None, im_id: str | None = None, + caption: str | None = None, time_limit: int = 10): + return ctx.upload_instant(im=im, user=self, file_uuid=file_uuid, im_id=im_id, caption=caption, + time_limit=time_limit) + + def __str__(self): + return f"" + + +class ProfileMusic: + def __init__(self, artist: str, artwork_url: str, duration: int, song_title: str, song_url: str): + self.artist = artist + self.artwork_url = artwork_url + self.duration = duration + self.song_title = song_title + self.song_url = song_url + + self.song: None | bytes = None + self.artwork: None | Image.Image = None + + def load(self): + """ + Loads the song, and artwork into memory + :return: None + """ + # Get song + request = requests.get(self.song_url) + request.raise_for_status() + self.song = request.content + + # Get artwork + if self.artwork_url: + request = requests.get(self.artwork_url) + request.raise_for_status() + + bytes_io = io.BytesIO(request.content) + self.artwork = Image.open(bytes_io) \ No newline at end of file diff --git a/lapsepy/journal/structures.py b/lapsepy/journal/structures/snap.py similarity index 57% rename from lapsepy/journal/structures.py rename to lapsepy/journal/structures/snap.py index 3868726..ff7a748 100644 --- a/lapsepy/journal/structures.py +++ b/lapsepy/journal/structures/snap.py @@ -17,86 +17,6 @@ def _dt_from_iso(dt_str: str): return datetime.fromisoformat(dt_str) -class Profile: - def __init__(self, user_id: str, username: str, display_name: str, profile_photo_name: str, bio: str | None, - emojis: list[str], is_friends: bool, blocked_me: bool, kudos: int, tags: list[dict], - is_blocked: bool = False, friends: list["Profile"] = None): - if friends is None: - friends = [] - - self.bio: str = bio - self.blocked_me: bool = blocked_me - self.user_display_name: str = display_name - self.emojis: list[str] = emojis - self.is_friends: bool = is_friends - self.kudos = kudos - self.profile_photo_name: str = profile_photo_name - self.tags = tags - self.user_id: str = user_id - self.username: str = username - self.media: list[Snap] = [] - self.is_blocked = is_blocked - - self.friends: list["Profile"] = friends - - self.profile_picture: Image.Image | None = None - - @staticmethod - def from_dict(profile_data: dict) -> "Profile": - """ - Generates a Profile object from a dictionary with the necessary profile data - :param profile_data: Dictionary containing the necessary data. - :return: Profile object prefilled with the data. - """ - logger.debug("Creating new Profile object from dictionary.") - - pd = profile_data - return Profile( - bio=pd.get('bio'), - blocked_me=pd.get('blockedMe'), - display_name=pd.get('displayName'), - emojis=pd.get("emojis", {}).get("emojis"), - is_friends=pd.get("friendStatus") == "FRIENDS", - kudos=pd.get("kudos", {}).get("totalCount", -1), - profile_photo_name=pd.get('profilePhotoName'), - tags=pd.get("tags"), - user_id=pd.get('id'), - username=pd.get('username'), - ) - - def load_profile_picture(self, quality: int = 100, height: int | None = None) -> Image.Image: - """ - Loads the Profile's profile picture into memory by making an HTTP request to Lapse's servers. - :param quality: Quality of the image (1-100) - seek https://cloudinary.com/documentation/transformation_reference#q_quality for more information. - :param height: Height of the image in pixels, width is determined by image aspect ratio. Leave as None to get - original height. - - :return: Pillow image. - """ - url = f"https://image.production.journal-api.lapse.app/image/upload/q_{quality}" - url += f",h_{height}" if height is not None else "" - url += f"//{self.profile_photo_name}.jpg" - - logger.debug(f"Getting profile image from \"{url}\"") - - request = requests.get(url) - bytes_io = io.BytesIO(request.content) - image = Image.open(bytes_io) - - self.profile_picture = image - - return image - - def send_instant(self, ctx, im: Image, file_uuid: str | None = None, im_id: str | None = None, - caption: str | None = None, time_limit: int = 10): - return ctx.upload_instant(im=im, user=self, file_uuid=file_uuid, im_id=im_id, caption=caption, - time_limit=time_limit) - - def __str__(self): - return f"" - - class Snap: BASE_URL = "https://image.production.journal-api.lapse.app/image/upload/" diff --git a/lapsepy/lapse/lapse.py b/lapsepy/lapse/lapse.py index c48fba1..11f8c7d 100644 --- a/lapsepy/lapse/lapse.py +++ b/lapsepy/lapse/lapse.py @@ -8,7 +8,7 @@ from lapsepy.auth.refresher import refresh from lapsepy.journal.journal import Journal from lapsepy.journal.common.exceptions import AuthTokenExpired -from lapsepy.journal.structures import Profile +from lapsepy.journal.structures.profile import Profile import logging @@ -86,6 +86,35 @@ def upload_instant(self, im: Image, user: str | Profile, file_uuid: str | None = return self.journal.upload_instant(im=im, user_id=user, file_uuid=file_uuid, im_id=im_id, caption=caption, time_limit=time_limit) + def create_status_update(self, text: str, msg_id: str | None = None): + """ + Creates a status update on your Journal + :param text: Msg of the text to send + :param msg_id: Leave None if you don't know what you're doing. FORMAT: STATUS_UPDATE:<(str(uuid.uuid4))> + :return: + """ + return self.journal.create_status_update(text=text, msg_id=msg_id) + + def remove_status_update(self, msg_id: str, removed_at: datetime | None = None): + """ + Removes a status update + :param msg_id: ID of the status update + :param removed_at: datetime object of when it was removed + :return: + """ + return self.journal.remove_status_update(msg_id=msg_id, removed_at=removed_at) + + def send_kudos(self, user: str | Profile): + """ + Sends kudos (vibes) to a user. + :param user: ID / Object of user to send it to. + :return: + """ + if isinstance(user, Profile): + user = user.user_id + + self.journal.send_kudos(user) + def get_friends_feed(self, count: int = 10): """ Gets your friend upload feed. @@ -147,3 +176,40 @@ def update_dob(self, dob: str): :return: None """ return self.journal.modify_dob(dob=dob) + + def add_reaction(self, msg_id: str, reaction: str): + """ + Adds a reaction to a message + :param msg_id: ID of msg to send reaction to. + :param reaction: Reaction to send. + :return: + """ + return self.journal.add_reaction(msg_id=msg_id, reaction=reaction) + + def remove_reaction(self, msg_id: str, reaction: str): + """ + removes a reaction from a message + :param msg_id: ID of msg to remove reaction from. + :param reaction: Reaction to remove. + :return: + """ + return self.journal.remove_reaction(msg_id=msg_id, reaction=reaction) + + def send_comment(self, msg_id: str, text: str, comment_id: str | None = None): + """ + Adds a comment to a post + :param comment_id: id of the comment, leave as None unless you know what you're doing + :param msg_id: id of the message + :param text: text to send in the comment + :return: + """ + return self.journal.create_comment(msg_id=msg_id, text=text, comment_id=comment_id) + + def delete_comment(self, msg_id: str, comment_id: str): + """ + Deletes a comment from a lapsepy post + :param msg_id: ID of the post + :param comment_id: ID of the comment + :return: + """ + return self.journal.delete_comment(msg_id=msg_id, comment_id=comment_id) diff --git a/setup.py b/setup.py index c33d54b..27430d5 100644 --- a/setup.py +++ b/setup.py @@ -1,26 +1,31 @@ from setuptools import setup, find_packages -VERSION = "0.1.1" +VERSION = "0.2.1" DESCRIPTION = "A Python API wrapper for the social media app Lapse." -LONG_DESCRIPTION = "An unofficial API wrapper for the social media app Lapse." + +with open("README.md", 'r') as f: + LONG_DESCRIPTION = f.read() with open("requirements.txt", 'r', encoding="utf-16") as f: requirements = [i.strip() for i in f.readlines()] +LICENSE = "MIT" + setup(name='lapsepy', version=VERSION, description=DESCRIPTION, long_description_content_type="text/markdown", long_description=LONG_DESCRIPTION, + license=LICENSE, author="Quintin Dunn", author_email="dunnquintin07@gmail.com", url="https://github.com/quintindunn/lapsepy", packages=find_packages(), keywords=['social media', 'lapsepy', 'api', 'api wrapper'], classifiers=[ - 'Development Status :: 5 - Production/Stable', - 'Operating System :: Microsoft :: Windows :: Windows 10', - 'Programming Language :: Python :: 3', + 'Development Status :: 5 - Production/Stable', + 'Operating System :: Microsoft :: Windows :: Windows 10', + 'Programming Language :: Python :: 3', ], install_requires=requirements )