├── 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 |
--------------------------------------------------------------------------------