├── tests ├── __init__.py ├── test.jpeg └── test_main.py ├── upload.sh ├── setup.py ├── LICENSE ├── .gitignore ├── README.md └── atprototools └── __init__.py /tests/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /tests/test.jpeg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ianklatzco/atprototools/HEAD/tests/test.jpeg -------------------------------------------------------------------------------- /upload.sh: -------------------------------------------------------------------------------- 1 | /usr/bin/python3 setup.py sdist && ~/.local/bin/twine upload dist/atprototools-0.0.17.tar.gz 2 | -------------------------------------------------------------------------------- /setup.py: -------------------------------------------------------------------------------- 1 | from setuptools import setup, find_packages 2 | 3 | setup( 4 | name='atprototools', 5 | version='0.0.17', 6 | description='Easy-to-use and ergonomic library for interacting with bluesky, packaged so you can `pip install atprototools` and go.', 7 | author='Ian Klatzco', 8 | author_email='iklatzco@gmail.com', 9 | url='https://github.com/ianklatzco/atprototools', 10 | packages=find_packages(), 11 | install_requires=[ 12 | 'requests>=2.22.0' 13 | ], 14 | classifiers=[ 15 | 'Development Status :: 3 - Alpha', 16 | 'Intended Audience :: Developers', 17 | 'License :: OSI Approved :: MIT License', 18 | 'Programming Language :: Python :: 3', 19 | 'Programming Language :: Python :: 3.7', 20 | 'Programming Language :: Python :: 3.8', 21 | 'Programming Language :: Python :: 3.9', 22 | ], 23 | ) 24 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2023 Ian Klatzco 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | *.sw* 2 | 3 | # Byte-compiled / optimized / DLL files 4 | __pycache__/ 5 | *.py[cod] 6 | *$py.class 7 | 8 | # C extensions 9 | *.so 10 | 11 | # Distribution / packaging 12 | .Python 13 | build/ 14 | develop-eggs/ 15 | dist/ 16 | downloads/ 17 | eggs/ 18 | .eggs/ 19 | lib/ 20 | lib64/ 21 | parts/ 22 | sdist/ 23 | var/ 24 | wheels/ 25 | pip-wheel-metadata/ 26 | share/python-wheels/ 27 | *.egg-info/ 28 | .installed.cfg 29 | *.egg 30 | MANIFEST 31 | 32 | # PyInstaller 33 | # Usually these files are written by a python script from a template 34 | # before PyInstaller builds the exe, so as to inject date/other infos into it. 35 | *.manifest 36 | *.spec 37 | 38 | # Installer logs 39 | pip-log.txt 40 | pip-delete-this-directory.txt 41 | 42 | # Unit test / coverage reports 43 | htmlcov/ 44 | .tox/ 45 | .nox/ 46 | .coverage 47 | .coverage.* 48 | .cache 49 | nosetests.xml 50 | coverage.xml 51 | *.cover 52 | *.py,cover 53 | .hypothesis/ 54 | .pytest_cache/ 55 | 56 | # Translations 57 | *.mo 58 | *.pot 59 | 60 | # Django stuff: 61 | *.log 62 | local_settings.py 63 | db.sqlite3 64 | db.sqlite3-journal 65 | 66 | # Flask stuff: 67 | instance/ 68 | .webassets-cache 69 | 70 | # Scrapy stuff: 71 | .scrapy 72 | 73 | # Sphinx documentation 74 | docs/_build/ 75 | 76 | # PyBuilder 77 | target/ 78 | 79 | # Jupyter Notebook 80 | .ipynb_checkpoints 81 | 82 | # IPython 83 | profile_default/ 84 | ipython_config.py 85 | 86 | # pyenv 87 | .python-version 88 | 89 | # pipenv 90 | # According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control. 91 | # However, in case of collaboration, if having platform-specific dependencies or dependencies 92 | # having no cross-platform support, pipenv may install dependencies that don't work, or not 93 | # install all needed dependencies. 94 | #Pipfile.lock 95 | 96 | # PEP 582; used by e.g. github.com/David-OConnor/pyflow 97 | __pypackages__/ 98 | 99 | # Celery stuff 100 | celerybeat-schedule 101 | celerybeat.pid 102 | 103 | # SageMath parsed files 104 | *.sage.py 105 | 106 | # Environments 107 | .env 108 | .venv 109 | env/ 110 | venv/ 111 | ENV/ 112 | env.bak/ 113 | venv.bak/ 114 | 115 | # Spyder project settings 116 | .spyderproject 117 | .spyproject 118 | 119 | # Rope project settings 120 | .ropeproject 121 | 122 | # mkdocs documentation 123 | /site 124 | 125 | # mypy 126 | .mypy_cache/ 127 | .dmypy.json 128 | dmypy.json 129 | 130 | # Pyre type checker 131 | .pyre/ 132 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | ## atprototools 2 | 3 | Easy-to-use and ergonomic library for interacting with bluesky,
4 | packaged so you can `pip install atprototools` and go. 5 | 6 | this library should serve as a gentle guide of mostly natural-language
7 | python to get as many people writing bsky code as possible. 8 | 9 | ## ONE-LINER TO GET STARTED *INSTANTLY* (ノ ゜Д゜)ノ ︵: 10 | ``` 11 | pip install atprototools && export BSKY_USERNAME="yourname.bsky.social" && export BSKY_PASSWORD="yourpassword" && python -i -c "import atprototools, os; atp = atprototools.Session(os.environ.get('BSKY_USERNAME'), os.environ.get('BSKY_PASSWORD')); atp.postBloot('hello world from atprototools!')" 12 | # now use atp.whatever_you_need 13 | ``` 14 | 15 | ## TWO-LINER TO GET STARTED YOUR SECOND TIME 16 | ``` 17 | export BSKY_USERNAME="yourname.bsky.social" && export BSKY_PASSWORD="yourpassword" 18 | python -i -c "import atprototools, os; atp = atprototools.Session(os.environ.get('BSKY_USERNAME'), os.environ.get('BSKY_PASSWORD'))" 19 | # now use atp.whatever_you_need 20 | ``` 21 | 22 | Usage: 23 | 24 | ```bash 25 | pip install atprototools 26 | export BSKY_USERNAME="yourname.bsky.social" 27 | export BSKY_PASSWORD="yourpassword" 28 | ``` 29 | 30 | ```python 31 | from atprototools import Session 32 | import os 33 | 34 | USERNAME = os.environ.get("BSKY_USERNAME") 35 | PASSWORD = os.environ.get("BSKY_PASSWORD") 36 | 37 | session = Session(USERNAME, PASSWORD) 38 | 39 | # make a text post 40 | resp = session.postBloot("hello world from atprototools") 41 | 42 | # post an image 43 | # session.postBloot("here's an image!", "path/to/your/image") 44 | 45 | # get bloots/posts 46 | latest_bloot = session.getLatestNBloots('klatz.co',1).content 47 | 48 | # get archive 49 | # carfile = session.getArchive().content 50 | 51 | # reply to a post 52 | # Get first post details for replying to, you can also reply to other posts 53 | # from getting bloots other ways 54 | # Using "hello world" bloot from above: 55 | first_post = resp.json() 56 | # Create reply_ref: 57 | # - root is the highest up original post 58 | # - parent is the comment you want to reply to directly 59 | # if you want to reply to root make both the same 60 | reply_ref = {"root": first_post, "parent": first_post} 61 | session.postBloot("this is the reply", reply_to=reply_ref) 62 | # TODO write a test for replies 63 | ``` 64 | 65 | 66 | PEP8 formatted; use autopep8. 67 | 68 | ### Running tests 69 | 70 | ``` 71 | # clone repo 72 | cd atprototools 73 | python -m unittest 74 | ``` 75 | 76 | ### changelog 77 | 78 | - 0.0.17: chaged case to consistently be camelCase - thanks BSculfor! 79 | - 0.0.16: replies! added to post_bloot, thanks to Jxck-S 80 | - 0.0.15: get_bloot_by_url switched to getPosts instead of getPostThread 81 | - 0.0.14: refactoring for cbase talk 82 | - 0.0.13: register(), thanks Chief! 83 | - 0.0.12: Set your own ATP_HOST! get_skyline. Thanks Shreyan. 84 | - 0.0.11: images! in post_bloot. 85 | - 0.0.10: follow, getProfile 86 | - 0.0.9: move everything into a session class 87 | - 0.0.8: get_bloot_by_url, rebloot 88 | - 0.0.7: getRepo (car files) and get_latest_n_bloots 89 | 90 | ### Thanks to 91 | 92 | - alice 93 | - [sirodoht](https://github.com/sirodoht) 94 | -------------------------------------------------------------------------------- /tests/test_main.py: -------------------------------------------------------------------------------- 1 | import unittest 2 | import os 3 | import random 4 | import string 5 | 6 | from atprototools import * 7 | 8 | BSKY_USERNAME = os.environ.get("BSKY_USERNAME") 9 | BSKY_PASSWORD = os.environ.get("BSKY_PASSWORD") 10 | 11 | class TestSessionLogin(unittest.TestCase): 12 | ''' 13 | This test case uses a random invite code and a generic email and succeeds when registration fails due to 14 | an invalid invite code. 15 | ''' 16 | def test_registration(self): 17 | resp = register(''.join(random.choices(string.ascii_lowercase, k=15)), "Password1!", "bsky-social-hto7xan", "test@gmail.com") 18 | self.assertEqual(resp.status_code, 400) 19 | 20 | def test_login(self): 21 | session = Session(BSKY_USERNAME, BSKY_PASSWORD) 22 | self.assertIsNotNone(session.DID) 23 | 24 | def test_login_bad_username(self): 25 | with self.assertRaises(ValueError): 26 | Session("","") 27 | 28 | def test_follow(self): 29 | # TODO login should go in a pre-setup 30 | session = Session(BSKY_USERNAME, BSKY_PASSWORD) 31 | self.assertIsNotNone(session.DID) 32 | 33 | # TODO getProfile, check if following, unfollow if already following 34 | resp = session.follow("ik.bsky.social") 35 | self.assertEqual(resp.status_code, 200) 36 | pass 37 | 38 | def test_get_profile(self): 39 | # TODO figure out this test library's persistent object and re-use the same session for all the tests 40 | # TODOTODO refresh the token since lifetime is probably a minute 41 | pass 42 | 43 | def test_upload_blob(self): 44 | session = Session(BSKY_USERNAME, BSKY_PASSWORD) 45 | self.assertIsNotNone(session.DID) 46 | resp = session.uploadBlob("tests/test.jpeg", "image/jpeg") 47 | self.assertEqual(resp.status_code, 200) 48 | pass 49 | 50 | def test_post_bloot(self): 51 | session = Session(BSKY_USERNAME, BSKY_PASSWORD) 52 | self.assertIsNotNone(session.DID) 53 | resp = session.postBloot("good meme", "tests/test.jpeg") 54 | self.assertEqual(resp.status_code, 200) 55 | # print(resp.json()) 56 | pass 57 | 58 | def test_get_skyline(self): 59 | session = Session(BSKY_USERNAME, BSKY_PASSWORD) 60 | skyline_firstitem_text = session.getSkyline(1).json().get('feed')[0].get('post').get('record').get('text') 61 | self.assertIsNotNone(skyline_firstitem_text) 62 | 63 | def test_get_bloot_by_url(self): 64 | session = Session(BSKY_USERNAME, BSKY_PASSWORD) 65 | 66 | url1 = "https://staging.bsky.app/profile/did:plc:o2hywbrivbyxugiukoexum57/post/3jua5rlgrq42p" # did 67 | url2 = "https://staging.bsky.app/profile/klatz.co/post/3jua5rlgrq42p" # username 68 | 69 | ee = session.getBlootByUrl(url1).json() 70 | assert ee.get('posts') != None 71 | bb = session.getBlootByUrl(url2).json() 72 | assert bb.get('posts') != None 73 | 74 | def test_reply(self): 75 | session = Session(BSKY_USERNAME, BSKY_PASSWORD) 76 | # https://staging.bsky.app/profile/klatz.co/post/3jua5rlgrq42p 77 | first_post = { 78 | 'cid': 'bafyreigyk6l24uiorkxhqyrridwru2bwdqcpnitclj267xh74qqxzhjfhu', 79 | 'uri': 'at://did:plc:o2hywbrivbyxugiukoexum57/app.bsky.feed.post/3jua5rlgrq42p' 80 | } 81 | reply_ref = {"root": first_post, "parent": first_post} 82 | resp = session.postBloot("reply test", reply_to=reply_ref) 83 | self.assertEqual(resp.status_code, 200) 84 | -------------------------------------------------------------------------------- /atprototools/__init__.py: -------------------------------------------------------------------------------- 1 | import requests 2 | import datetime 3 | import os 4 | import unittest 5 | 6 | # ATP_HOST = "https://bsky.social" 7 | # ATP_AUTH_TOKEN = "" 8 | # DID = "" # leave blank 9 | # TODO prob makes sense to have username here too, and then always assume username + did are populated 10 | # two use cases: library calls login() (swap to a class later) and cli user uses a shell variable. 11 | # in either case login() should populate these globals within this file. 12 | # maybe PASSWORD shouldn't hang around, but you have bigger problems if you're relying on python encapsulation for security. 13 | 14 | # TODO annotate all requests.get/post with auth header 15 | 16 | 17 | class Session(): 18 | def __init__(self, username, password, pds = None): 19 | if pds: # check if pds is not empty 20 | self.ATP_HOST = pds # use the given value 21 | else: 22 | self.ATP_HOST = "https://bsky.social" # use bsky.social by default 23 | self.ATP_AUTH_TOKEN = "" 24 | self.DID = "" 25 | self.USERNAME = username 26 | 27 | data = {"identifier": username, "password": password} 28 | resp = requests.post( 29 | self.ATP_HOST + "/xrpc/com.atproto.server.createSession", 30 | json=data 31 | ) 32 | 33 | self.ATP_AUTH_TOKEN = resp.json().get('accessJwt') 34 | if self.ATP_AUTH_TOKEN == None: 35 | raise ValueError("No access token, is your password wrong? Do export BSKY_PASSWORD='yourpassword'") 36 | 37 | self.DID = resp.json().get("did") 38 | # TODO DIDs expire shortly and need to be refreshed for any long-lived sessions 39 | 40 | def reinit(self): 41 | """Check if the session needs to be refreshed, and refresh if so.""" 42 | # TODO 43 | # if a request failed, use refreshJWT 44 | resp = self.get_profile("klatz.co") 45 | 46 | if resp.status_code == 200: 47 | # yay! 48 | # do nothing lol 49 | pass 50 | else: # re-init 51 | # what is the endpoint 52 | pass 53 | 54 | 55 | def rebloot(self, url): 56 | """Rebloot a bloot given the URL.""" 57 | # sample url from desktop 58 | # POST https://bsky.social/xrpc/com.atproto.repo.createRecord 59 | # https://staging.bsky.app/profile/klatz.co/post/3jruqqeygrt2d 60 | ''' 61 | { 62 | "collection":"app.bsky.feed.repost", 63 | "repo":"did:plc:n5ddwqolbjpv2czaronz6q3d", 64 | "record":{ 65 | "subject":{ 66 | "uri":"at://did:plc:scx5mrfxxrqlfzkjcpbt3xfr/app.bsky.feed.post/3jszsrnruws27", 67 | "cid":"bafyreiad336s3honwubedn4ww7m2iosefk5wqgkiity2ofc3ts4ii3ffkq" 68 | }, 69 | "createdAt":"2023-04-10T17:38:10.516Z", 70 | "$type":"app.bsky.feed.repost" 71 | } 72 | } 73 | ''' 74 | 75 | person_youre_reblooting = self.resolveHandle(url.split('/')[-3]).json().get('did') # its a DID 76 | url_identifier = url.split('/')[-1] 77 | 78 | # import pdb; pdb.set_trace() 79 | bloot_cid = self.getBlootByUrl(url).json().get('thread').get('post').get('cid') 80 | 81 | # subject -> uri is the maia one (thing rt'ing, scx) 82 | timestamp = datetime.datetime.now(datetime.timezone.utc) 83 | timestamp = timestamp.isoformat().replace('+00:00', 'Z') 84 | 85 | headers = {"Authorization": "Bearer " + self.ATP_AUTH_TOKEN} 86 | 87 | data = { 88 | "collection": "app.bsky.feed.repost", 89 | "repo": "{}".format(self.DID), 90 | "record": { 91 | "subject": { 92 | "uri":"at://{}/app.bsky.feed.post/{}".format(person_youre_reblooting, url_identifier), 93 | "cid":"{}".format(bloot_cid) # cid of the bloot to rebloot 94 | }, 95 | "createdAt": timestamp, 96 | "$type": "app.bsky.feed.repost" 97 | } 98 | } 99 | 100 | resp = requests.post( 101 | self.ATP_HOST + "/xrpc/com.atproto.repo.createRecord", 102 | json=data, 103 | headers=headers 104 | ) 105 | 106 | return resp 107 | 108 | def resolveHandle(self, username): 109 | """Get the DID given a username, aka getDid.""" 110 | headers = {"Authorization": "Bearer " + self.ATP_AUTH_TOKEN} 111 | resp = requests.get( 112 | self.ATP_HOST + "/xrpc/com.atproto.identity.resolveHandle?handle={}".format(username), 113 | headers=headers 114 | ) 115 | return resp 116 | 117 | def getSkyline(self,n = 10): 118 | """Fetch the logged in account's following timeline ("skyline").""" 119 | headers = {"Authorization": "Bearer " + self.ATP_AUTH_TOKEN} 120 | resp = requests.get( 121 | self.ATP_HOST + "/xrpc/app.bsky.feed.getTimeline?limit={}".format(n), 122 | headers=headers 123 | ) 124 | return resp 125 | 126 | def getBlootByUrl(self, url): 127 | """Get a bloot's HTTP response data when given the URL.""" 128 | # https://staging.bsky.app/profile/shinyakato.dev/post/3ju777mfnfv2j 129 | "https://bsky.social/xrpc/app.bsky.feed.getPostThread?uri=at%3A%2F%2Fdid%3Aplc%3Ascx5mrfxxrqlfzkjcpbt3xfr%2Fapp.bsky.feed.post%2F3jszsrnruws27A" 130 | "at://did:plc:scx5mrfxxrqlfzkjcpbt3xfr/app.bsky.feed.post/3jszsrnruws27" 131 | "https://staging.bsky.app/profile/naia.bsky.social/post/3jszsrnruws27" 132 | 133 | # getPosts 134 | # https://bsky.social/xrpc/app.bsky.feed.getPosts?uris=at://did:plc:o2hywbrivbyxugiukoexum57/app.bsky.feed.post/3jua5rlgrq42p 135 | 136 | headers = {"Authorization": "Bearer " + self.ATP_AUTH_TOKEN} 137 | 138 | username_of_person_in_link = url.split('/')[-3] 139 | if not "did:plc" in username_of_person_in_link: 140 | did_of_person_in_link = self.resolveHandle(username_of_person_in_link).json().get('did') 141 | else: 142 | did_of_person_in_link = username_of_person_in_link 143 | 144 | url_identifier = url.split('/')[-1] # the random stuff at the end, better hope there's no query params 145 | 146 | uri = "at://{}/app.bsky.feed.post/{}".format(did_of_person_in_link, url_identifier) 147 | 148 | resp = requests.get( 149 | self.ATP_HOST + "/xrpc/app.bsky.feed.getPosts?uris={}".format(uri), 150 | headers=headers 151 | ) 152 | 153 | return resp 154 | 155 | def uploadBlob(self, blob_path, content_type): 156 | """Upload bytes data (a "blob") with the given content type.""" 157 | headers = {"Authorization": "Bearer " + self.ATP_AUTH_TOKEN, "Content-Type": content_type} 158 | with open(blob_path, 'rb') as f: 159 | image_bytes = f.read() 160 | resp = requests.post( 161 | self.ATP_HOST + "/xrpc/com.atproto.repo.uploadBlob", 162 | data=image_bytes, 163 | headers=headers 164 | ) 165 | return resp 166 | 167 | def postBloot(self, postcontent, image_path = None, timestamp=None, reply_to=None): 168 | """Post a bloot.""" 169 | #reply_to expects a dict like the following 170 | # { 171 | # #root is the main original post 172 | # "root": { 173 | # "cid": "bafyreig7ox2h5kmcmjukbxfpopy65ggd2ymhbnldcu3fx72ij3c22ods3i", #CID of root post 174 | # "uri": "at://did:plc:nx3kofpg4oxmkonqr6su5lw4/app.bsky.feed.post/3juhgsu4tpi2e" #URI of root post 175 | # }, 176 | # #parent is the comment you want to reply to, if you want to reply to the main post directly this should be same as root 177 | # "parent": { 178 | # "cid": "bafyreie7eyj4upwzjdl2vmzqq4gin3qnuttpb6nzi6xybgdpesfrtcuguu", 179 | # "uri": "at://did:plc:mguf3p2ana5qzs7wu3ss4ghk/app.bsky.feed.post/3jum6axhxff22" 180 | # } 181 | #} 182 | if not timestamp: 183 | timestamp = datetime.datetime.now(datetime.timezone.utc) 184 | timestamp = timestamp.isoformat().replace('+00:00', 'Z') 185 | 186 | headers = {"Authorization": "Bearer " + self.ATP_AUTH_TOKEN} 187 | 188 | data = { 189 | "collection": "app.bsky.feed.post", 190 | "$type": "app.bsky.feed.post", 191 | "repo": "{}".format(self.DID), 192 | "record": { 193 | "$type": "app.bsky.feed.post", 194 | "createdAt": timestamp, 195 | "text": postcontent 196 | } 197 | } 198 | 199 | if image_path: 200 | data['record']['embed'] = {} 201 | image_resp = self.uploadBlob(image_path, "image/jpeg") 202 | x = image_resp.json().get('blob') 203 | image_resp = self.uploadBlob(image_path, "image/jpeg") 204 | data["record"]["embed"]["$type"] = "app.bsky.embed.images" 205 | data['record']["embed"]['images'] = [{ 206 | "alt": "", 207 | "image": image_resp.json().get('blob') 208 | }] 209 | if reply_to: 210 | data['record']['reply'] = reply_to 211 | resp = requests.post( 212 | self.ATP_HOST + "/xrpc/com.atproto.repo.createRecord", 213 | json=data, 214 | headers=headers 215 | ) 216 | 217 | return resp 218 | 219 | def deleteBloot(self, did,rkey): 220 | # rkey: post slug 221 | # i.e. /profile/foo.bsky.social/post/AAAA 222 | # rkey is AAAA 223 | data = {"collection":"app.bsky.feed.post","repo":"did:plc:{}".format(did),"rkey":"{}".format(rkey)} 224 | headers = {"Authorization": "Bearer " + self.ATP_AUTH_TOKEN} 225 | resp = requests.post( 226 | self.ATP_HOST + "/xrpc/com.atproto.repo.deleteRecord", 227 | json = data, 228 | headers=headers 229 | ) 230 | return resp 231 | 232 | def getArchive(self, did_of_car_to_fetch=None, save_to_disk_path=None): 233 | """Get a .car file containing all bloots. 234 | 235 | TODO is there a putRepo? 236 | TODO save to file 237 | TODO specify user 238 | """ 239 | 240 | if did_of_car_to_fetch == None: 241 | did_of_car_to_fetch = self.DID 242 | 243 | headers = {"Authorization": "Bearer " + self.ATP_AUTH_TOKEN} 244 | 245 | resp = requests.get( 246 | self.ATP_HOST + "/xrpc/com.atproto.sync.getRepo?did={}".format(did_of_car_to_fetch), 247 | headers = headers 248 | ) 249 | 250 | if save_to_disk_path: 251 | pass 252 | 253 | return resp 254 | 255 | def getLatestBloot(self, accountname): 256 | """Return the most recent bloot from the specified account.""" 257 | return self.getLatestNBloots(accountname, 1) 258 | 259 | def getLatestNBloots(self, username, n=5): 260 | """Return the most recent n bloots from the specified account.""" 261 | headers = {"Authorization": "Bearer " + self.ATP_AUTH_TOKEN} 262 | resp = requests.get( 263 | self.ATP_HOST + "/xrpc/app.bsky.feed.getAuthorFeed?actor={}&limit={}".format(username, n), 264 | headers = headers 265 | ) 266 | 267 | return resp 268 | 269 | # [[API Design]] TODO one implementation should be highly ergonomic (comfy 2 use) and the other should just closely mirror the API's exact behavior? 270 | # idk if im super happy about returning requests, either, i kinda want tuples where the primary object u get back is whatever ergonomic thing you expect 271 | # and then you can dive into that and ask for the request. probably this means writing a class to encapsulate each of the 272 | # API actions, populating the class in the implementations, and making the top-level api as pretty as possible 273 | # ideally atproto lib contains meaty close-to-the-api and atprototools is a layer on top that focuses on ergonomics? 274 | def follow(self, username=None, did_of_person_you_wanna_follow=None): 275 | """Follow the user with the given username or DID.""" 276 | 277 | if username: 278 | did_of_person_you_wanna_follow = self.resolveHandle(username).json().get("did") 279 | 280 | if not did_of_person_you_wanna_follow: 281 | # TODO better error in resolveHandle 282 | raise ValueError("Failed; please pass a username or did of the person you want to follow (maybe the account doesn't exist?)") 283 | 284 | timestamp = datetime.datetime.now(datetime.timezone.utc) 285 | timestamp = timestamp.isoformat().replace('+00:00', 'Z') 286 | 287 | headers = {"Authorization": "Bearer " + self.ATP_AUTH_TOKEN} 288 | 289 | data = { 290 | "collection": "app.bsky.graph.follow", 291 | "repo": "{}".format(self.DID), 292 | "record": { 293 | "subject": did_of_person_you_wanna_follow, 294 | "createdAt": timestamp, 295 | "$type": "app.bsky.graph.follow" 296 | } 297 | } 298 | 299 | resp = requests.post( 300 | self.ATP_HOST + "/xrpc/com.atproto.repo.createRecord", 301 | json=data, 302 | headers=headers 303 | ) 304 | 305 | return resp 306 | 307 | def unfollow(self): 308 | # TODO lots of code re-use. package everything into a API_ACTION class. 309 | raise NotImplementedError 310 | 311 | def get_profile(self, username): 312 | headers = {"Authorization": "Bearer " + self.ATP_AUTH_TOKEN} 313 | 314 | # TODO did / username check, it should just work regardless of which it is 315 | 316 | resp = requests.get( 317 | self.ATP_HOST + "/xrpc/app.bsky.actor.getProfile?actor={}".format(username), 318 | headers=headers 319 | ) 320 | 321 | return resp 322 | 323 | 324 | def register(user, password, invcode, email): 325 | data = { 326 | "email": email, 327 | "handle": user + ".bsky.social", 328 | "inviteCode": invcode, 329 | "password": password, 330 | } 331 | 332 | resp = requests.post( 333 | # don't use self.ATP_HOST here because you can't instantiate a session if you haven't registered an account yet 334 | "https://bsky.social/xrpc/com.atproto.server.createAccount", 335 | json = data, 336 | ) 337 | 338 | return resp 339 | 340 | 341 | if __name__ == "__main__": 342 | # This code will only be executed if the script is run directly 343 | # login(os.environ.get("BSKY_USERNAME"), os.environ.get("BSKY_PASSWORD")) 344 | sess = Session(os.environ.get("BSKY_USERNAME"), os.environ.get("BSKY_PASSWORD")) 345 | # f = getLatestNBloots('klatz.co',1).content 346 | # print(f) 347 | # resp = rebloot("https://staging.bsky.app/profile/klatz.co/post/3jt22a3jx5l2a") 348 | # resp = getArchive() 349 | import pdb; pdb.set_trace() 350 | 351 | 352 | 353 | # resp = login() 354 | # post("test post") 355 | --------------------------------------------------------------------------------