kopia lustrzana https://github.com/snarfed/bridgy-fed
finish implementing getAuthorFeed
rodzic
e2e42cb2f6
commit
f55ef86524
|
@ -18,6 +18,3 @@ DOMAIN_BLOCKLIST = frozenset((
|
||||||
't.co',
|
't.co',
|
||||||
'twitter.com',
|
'twitter.com',
|
||||||
) + DOMAINS)
|
) + DOMAINS)
|
||||||
|
|
||||||
# alias allows unit tests to mock the function
|
|
||||||
utcnow = datetime.datetime.utcnow
|
|
||||||
|
|
|
@ -21,9 +21,9 @@ class XrpcActorTest(testutil.TestCase):
|
||||||
</body>
|
</body>
|
||||||
""", url='https://foo.com/')
|
""", url='https://foo.com/')
|
||||||
|
|
||||||
got = self.client.get('/xrpc/app.bsky.actor.getProfile',
|
resp = self.client.get('/xrpc/app.bsky.actor.getProfile',
|
||||||
query_string={'actor': 'foo.com'},
|
query_string={'actor': 'foo.com'})
|
||||||
).json
|
self.assertEqual(200, resp.status_code)
|
||||||
self.assertEqual({
|
self.assertEqual({
|
||||||
'$type': 'app.bsky.actor.profile',
|
'$type': 'app.bsky.actor.profile',
|
||||||
'handle': 'foo.com',
|
'handle': 'foo.com',
|
||||||
|
@ -45,7 +45,7 @@ class XrpcActorTest(testutil.TestCase):
|
||||||
'follow': 'TODO',
|
'follow': 'TODO',
|
||||||
'member': 'TODO',
|
'member': 'TODO',
|
||||||
},
|
},
|
||||||
}, got)
|
}, resp.json)
|
||||||
|
|
||||||
def test_getProfile_not_domain(self, _):
|
def test_getProfile_not_domain(self, _):
|
||||||
resp = self.client.get('/xrpc/app.bsky.actor.getProfile',
|
resp = self.client.get('/xrpc/app.bsky.actor.getProfile',
|
||||||
|
@ -53,21 +53,24 @@ class XrpcActorTest(testutil.TestCase):
|
||||||
self.assertEqual(400, resp.status_code)
|
self.assertEqual(400, resp.status_code)
|
||||||
|
|
||||||
def test_getSuggestions(self, _):
|
def test_getSuggestions(self, _):
|
||||||
got = self.client.get('/xrpc/app.bsky.actor.getSuggestions').json
|
resp = self.client.get('/xrpc/app.bsky.actor.getSuggestions')
|
||||||
|
self.assertEqual(200, resp.status_code)
|
||||||
self.assertEqual({
|
self.assertEqual({
|
||||||
'actors': [],
|
'actors': [],
|
||||||
}, got)
|
}, resp.json)
|
||||||
|
|
||||||
def test_search(self, _):
|
def test_search(self, _):
|
||||||
got = self.client.get('/xrpc/app.bsky.actor.search',
|
resp = self.client.get('/xrpc/app.bsky.actor.search',
|
||||||
query_string={'term': 'foo'}).json
|
query_string={'term': 'foo'})
|
||||||
|
self.assertEqual(200, resp.status_code)
|
||||||
self.assertEqual({
|
self.assertEqual({
|
||||||
'users': [],
|
'users': [],
|
||||||
}, got)
|
}, resp.json)
|
||||||
|
|
||||||
def test_searchTypeahead(self, _):
|
def test_searchTypeahead(self, _):
|
||||||
got = self.client.get('/xrpc/app.bsky.actor.searchTypeahead',
|
resp = self.client.get('/xrpc/app.bsky.actor.searchTypeahead',
|
||||||
query_string={'term': 'foo'}).json
|
query_string={'term': 'foo'})
|
||||||
|
self.assertEqual(200, resp.status_code)
|
||||||
self.assertEqual({
|
self.assertEqual({
|
||||||
'users': [],
|
'users': [],
|
||||||
}, got)
|
}, resp.json)
|
||||||
|
|
|
@ -1,94 +1,173 @@
|
||||||
"""Unit tests for feed.py."""
|
"""Unit tests for feed.py."""
|
||||||
|
import copy
|
||||||
from unittest.mock import patch
|
from unittest.mock import patch
|
||||||
|
|
||||||
from oauth_dropins.webutil import util
|
from oauth_dropins.webutil import util
|
||||||
from oauth_dropins.webutil.testutil import requests_response
|
from oauth_dropins.webutil.testutil import NOW, requests_response
|
||||||
import requests
|
import requests
|
||||||
|
|
||||||
from . import testutil
|
from . import testutil
|
||||||
|
|
||||||
|
|
||||||
|
POST_HTML = """
|
||||||
|
<article class="h-entry">
|
||||||
|
<main class="e-content">My post</main>
|
||||||
|
<a class="u-url" href="http://orig/post"></a>
|
||||||
|
<time class="dt-published" datetime="2007-07-07T03:04:05"></time>
|
||||||
|
</article>
|
||||||
|
"""
|
||||||
|
POST = {
|
||||||
|
'$type': 'app.bsky.feed.post#view',
|
||||||
|
'uri': 'http://orig/post',
|
||||||
|
'cid': 'TODO',
|
||||||
|
'record': {
|
||||||
|
'text': 'My post',
|
||||||
|
'createdAt': '2007-07-07T03:04:05',
|
||||||
|
},
|
||||||
|
'replyCount': 0,
|
||||||
|
'repostCount': 0,
|
||||||
|
'upvoteCount': 0,
|
||||||
|
'downvoteCount': 0,
|
||||||
|
'indexedAt': '2022-01-02T03:04:05+00:00',
|
||||||
|
'viewer': {}
|
||||||
|
}
|
||||||
|
|
||||||
|
REPLY_HTML = """
|
||||||
|
<article class="h-entry">
|
||||||
|
<main class="e-content">I hereby reply to this</a></main>
|
||||||
|
<a class="u-in-reply-to" href="http://orig/post"></a>
|
||||||
|
<a class="u-url" href="http://a/reply"></a>
|
||||||
|
<time class="dt-published" datetime="2008-08-08T03:04:05"></time>
|
||||||
|
</article>
|
||||||
|
"""
|
||||||
|
REPLY = copy.deepcopy(POST)
|
||||||
|
REPLY.update({
|
||||||
|
'uri': 'http://a/reply',
|
||||||
|
'record': {
|
||||||
|
'text': 'I hereby reply to this',
|
||||||
|
'createdAt': '2008-08-08T03:04:05',
|
||||||
|
'reply': {
|
||||||
|
'root': {
|
||||||
|
'uri': 'http://orig/post',
|
||||||
|
'cid': 'TODO',
|
||||||
|
},
|
||||||
|
'parent': {
|
||||||
|
'uri': 'http://orig/post',
|
||||||
|
'cid': 'TODO',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
REPOST_HTML = """
|
||||||
|
<article class="h-entry">
|
||||||
|
<main class="e-content">A compelling post</main>
|
||||||
|
<a class="u-repost-of" href="http://orig/post"></a>
|
||||||
|
<time class="dt-published" datetime="2007-07-07T03:04:05"></time>
|
||||||
|
</article>
|
||||||
|
"""
|
||||||
|
REPOST = copy.deepcopy(POST)
|
||||||
|
REPOST['record'].update({
|
||||||
|
'text': '',
|
||||||
|
'createdAt': '',
|
||||||
|
})
|
||||||
|
REPOST_REASON = {
|
||||||
|
'$type': 'app.bsky.feed.feedViewPost#reasonRepost',
|
||||||
|
'by': {
|
||||||
|
'did': 'TODO',
|
||||||
|
'declaration': {
|
||||||
|
'cid': 'TODO',
|
||||||
|
'actorType': 'app.bsky.system.actorUser',
|
||||||
|
},
|
||||||
|
'handle': 'alice.com',
|
||||||
|
'displayName': 'Alice',
|
||||||
|
'avatar': 'https://alice.com/alice.jpg',
|
||||||
|
},
|
||||||
|
'indexedAt': NOW.isoformat(),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
@patch('requests.get')
|
@patch('requests.get')
|
||||||
class XrpcFeedTest(testutil.TestCase):
|
class XrpcFeedTest(testutil.TestCase):
|
||||||
|
|
||||||
def test_getAuthorFeed(self, mock_get):
|
def test_getAuthorFeed(self, mock_get):
|
||||||
mock_get.return_value = requests_response("""
|
mock_get.return_value = requests_response(f"""\
|
||||||
<body>
|
<body class="h-feed">
|
||||||
|
<a href="/" class="u-author h-card">
|
||||||
|
<img src="/alice.jpg"> Alice
|
||||||
|
</a>
|
||||||
|
{POST_HTML}
|
||||||
|
{REPLY_HTML}
|
||||||
|
{REPOST_HTML}
|
||||||
</body>
|
</body>
|
||||||
""", url='https://foo.com/')
|
""", url='https://alice.com/')
|
||||||
|
|
||||||
got = self.client.get('/xrpc/app.bsky.actor.getProfile',
|
resp = self.client.get('/xrpc/app.bsky.feed.getAuthorFeed',
|
||||||
query_string={'actor': 'foo.com'},
|
query_string={'author': 'alice.com'})
|
||||||
).json
|
self.assertEqual(200, resp.status_code, resp.get_data(as_text=True))
|
||||||
self.assertEqual({
|
self.assertEqual({
|
||||||
'feed': [{
|
'feed': [{
|
||||||
'post': {
|
'$type': 'app.bsky.feed.feedViewPost',
|
||||||
},
|
'post': POST,
|
||||||
}, {
|
}, {
|
||||||
'post': {
|
'$type': 'app.bsky.feed.feedViewPost',
|
||||||
},
|
'post': REPLY,
|
||||||
'reply': {
|
|
||||||
},
|
|
||||||
}, {
|
}, {
|
||||||
'post': {
|
'$type': 'app.bsky.feed.feedViewPost',
|
||||||
},
|
'post': REPOST,
|
||||||
'reason': {
|
'reason': REPOST_REASON,
|
||||||
'by': '',
|
|
||||||
'indexedAt': testutil.NOW.isoformat(),
|
|
||||||
}
|
|
||||||
}],
|
}],
|
||||||
}, got)
|
}, resp.json)
|
||||||
|
|
||||||
|
|
||||||
def test_getAuthorFeed_not_domain(self, _):
|
def test_getAuthorFeed_not_domain(self, _):
|
||||||
resp = self.client.get('/xrpc/app.bsky.feed.getAuthorFeed',
|
resp = self.client.get('/xrpc/app.bsky.feed.getAuthorFeed',
|
||||||
query_string={'actor': 'not a domain'})
|
query_string={'author': 'not a domain'})
|
||||||
self.assertEqual(400, resp.status_code)
|
self.assertEqual(400, resp.status_code)
|
||||||
|
|
||||||
def test_getPostThread(self, mock_get):
|
# def test_getPostThread(self, mock_get):
|
||||||
mock_get.return_value = requests_response("""
|
# mock_get.return_value = requests_response("""
|
||||||
<body>
|
# <body>
|
||||||
</body>
|
# </body>
|
||||||
""", url='https://foo.com/')
|
# """, url='https://foo.com/')
|
||||||
|
|
||||||
got = self.client.get('/xrpc/app.bsky.actor.getProfile',
|
# got = self.client.get('/xrpc/app.bsky.actor.getProfile',
|
||||||
query_string={'actor': 'foo.com'},
|
# query_string={'actor': 'foo.com'},
|
||||||
).json
|
# ).json
|
||||||
self.assertEqual({
|
# self.assertEqual({
|
||||||
}, got)
|
# }, got)
|
||||||
|
|
||||||
def test_getRepostedBy(self, mock_get):
|
# def test_getRepostedBy(self, mock_get):
|
||||||
mock_get.return_value = requests_response("""
|
# mock_get.return_value = requests_response("""
|
||||||
<body>
|
# <body>
|
||||||
</body>
|
# </body>
|
||||||
""", url='https://foo.com/')
|
# """, url='https://foo.com/')
|
||||||
|
|
||||||
got = self.client.get('/xrpc/app.bsky.actor.getProfile',
|
# got = self.client.get('/xrpc/app.bsky.actor.getProfile',
|
||||||
query_string={'actor': 'foo.com'},
|
# query_string={'actor': 'foo.com'},
|
||||||
).json
|
# ).json
|
||||||
self.assertEqual({
|
# self.assertEqual({
|
||||||
}, got)
|
# }, got)
|
||||||
|
|
||||||
def test_getTimeline(self, mock_get):
|
# def test_getTimeline(self, mock_get):
|
||||||
mock_get.return_value = requests_response("""
|
# mock_get.return_value = requests_response("""
|
||||||
<body>
|
# <body>
|
||||||
</body>
|
# </body>
|
||||||
""", url='https://foo.com/')
|
# """, url='https://foo.com/')
|
||||||
|
|
||||||
got = self.client.get('/xrpc/app.bsky.actor.getProfile',
|
# got = self.client.get('/xrpc/app.bsky.actor.getProfile',
|
||||||
query_string={'actor': 'foo.com'},
|
# query_string={'actor': 'foo.com'},
|
||||||
).json
|
# ).json
|
||||||
self.assertEqual({
|
# self.assertEqual({
|
||||||
}, got)
|
# }, got)
|
||||||
|
|
||||||
def test_getVotes(self, mock_get):
|
# def test_getVotes(self, mock_get):
|
||||||
mock_get.return_value = requests_response("""
|
# mock_get.return_value = requests_response("""
|
||||||
<body>
|
# <body>
|
||||||
</body>
|
# </body>
|
||||||
""", url='https://foo.com/')
|
# """, url='https://foo.com/')
|
||||||
|
|
||||||
got = self.client.get('/xrpc/app.bsky.actor.getVotes',
|
# got = self.client.get('/xrpc/app.bsky.actor.getVotes',
|
||||||
query_string={'actor': 'foo.com'},
|
# query_string={'actor': 'foo.com'},
|
||||||
).json
|
# ).json
|
||||||
self.assertEqual({
|
# self.assertEqual({
|
||||||
}, got)
|
# }, got)
|
||||||
|
|
|
@ -8,53 +8,53 @@ import requests
|
||||||
from . import testutil
|
from . import testutil
|
||||||
|
|
||||||
|
|
||||||
@patch('requests.get')
|
# @patch('requests.get')
|
||||||
class XrpcGraphTest(testutil.TestCase):
|
# class XrpcGraphTest(testutil.TestCase):
|
||||||
|
|
||||||
def test_getAuthorFeed(self, mock_get):
|
# def test_getAuthorFeed(self, mock_get):
|
||||||
mock_get.return_value = requests_response("""
|
# mock_get.return_value = requests_response("""
|
||||||
<body>
|
# <body>
|
||||||
</body>
|
# </body>
|
||||||
""", url='https://foo.com/')
|
# """, url='https://foo.com/')
|
||||||
|
|
||||||
got = self.client.get('/xrpc/app.bsky.actor.getProfile',
|
# got = self.client.get('/xrpc/app.bsky.actor.getProfile',
|
||||||
query_string={'actor': 'foo.com'},
|
# query_string={'actor': 'foo.com'},
|
||||||
).json
|
# ).json
|
||||||
self.assertEqual({
|
# self.assertEqual({
|
||||||
}, got)
|
# }, got)
|
||||||
|
|
||||||
def test_getPostThread(self, mock_get):
|
# def test_getPostThread(self, mock_get):
|
||||||
mock_get.return_value = requests_response("""
|
# mock_get.return_value = requests_response("""
|
||||||
<body>
|
# <body>
|
||||||
</body>
|
# </body>
|
||||||
""", url='https://foo.com/')
|
# """, url='https://foo.com/')
|
||||||
|
|
||||||
got = self.client.get('/xrpc/app.bsky.actor.getProfile',
|
# got = self.client.get('/xrpc/app.bsky.actor.getProfile',
|
||||||
query_string={'actor': 'foo.com'},
|
# query_string={'actor': 'foo.com'},
|
||||||
).json
|
# ).json
|
||||||
self.assertEqual({
|
# self.assertEqual({
|
||||||
}, got)
|
# }, got)
|
||||||
|
|
||||||
def test_getRepostedBy(self, mock_get):
|
# def test_getRepostedBy(self, mock_get):
|
||||||
mock_get.return_value = requests_response("""
|
# mock_get.return_value = requests_response("""
|
||||||
<body>
|
# <body>
|
||||||
</body>
|
# </body>
|
||||||
""", url='https://foo.com/')
|
# """, url='https://foo.com/')
|
||||||
|
|
||||||
got = self.client.get('/xrpc/app.bsky.actor.getProfile',
|
# got = self.client.get('/xrpc/app.bsky.actor.getProfile',
|
||||||
query_string={'actor': 'foo.com'},
|
# query_string={'actor': 'foo.com'},
|
||||||
).json
|
# ).json
|
||||||
self.assertEqual({
|
# self.assertEqual({
|
||||||
}, got)
|
# }, got)
|
||||||
|
|
||||||
def test_getTimeline(self, mock_get):
|
# def test_getTimeline(self, mock_get):
|
||||||
mock_get.return_value = requests_response("""
|
# mock_get.return_value = requests_response("""
|
||||||
<body>
|
# <body>
|
||||||
</body>
|
# </body>
|
||||||
""", url='https://foo.com/')
|
# """, url='https://foo.com/')
|
||||||
|
|
||||||
got = self.client.get('/xrpc/app.bsky.actor.getProfile',
|
# got = self.client.get('/xrpc/app.bsky.actor.getProfile',
|
||||||
query_string={'actor': 'foo.com'},
|
# query_string={'actor': 'foo.com'},
|
||||||
).json
|
# ).json
|
||||||
self.assertEqual({
|
# self.assertEqual({
|
||||||
}, got)
|
# }, got)
|
||||||
|
|
|
@ -5,14 +5,13 @@ import unittest
|
||||||
import requests
|
import requests
|
||||||
|
|
||||||
from app import app, cache
|
from app import app, cache
|
||||||
import common
|
|
||||||
from oauth_dropins.webutil import testutil, util
|
from oauth_dropins.webutil import testutil, util
|
||||||
from oauth_dropins.webutil.appengine_config import ndb_client
|
from oauth_dropins.webutil.appengine_config import ndb_client
|
||||||
from oauth_dropins.webutil.util import json_dumps, json_loads
|
from oauth_dropins.webutil.util import json_dumps, json_loads
|
||||||
|
|
||||||
NOW = datetime.datetime(2022, 12, 24, 22, 29, 19)
|
|
||||||
|
|
||||||
|
|
||||||
|
# can't use webutil.testutil.TestCase because it mock out requests.* with mox,
|
||||||
|
# which collides with bridgy-at doing the same thing with unittest.mock.
|
||||||
class TestCase(unittest.TestCase, testutil.Asserts):
|
class TestCase(unittest.TestCase, testutil.Asserts):
|
||||||
maxDiff = None
|
maxDiff = None
|
||||||
|
|
||||||
|
@ -21,14 +20,15 @@ class TestCase(unittest.TestCase, testutil.Asserts):
|
||||||
app.testing = True
|
app.testing = True
|
||||||
cache.clear()
|
cache.clear()
|
||||||
self.client = app.test_client()
|
self.client = app.test_client()
|
||||||
self.xrpc = app.test_client()
|
|
||||||
common.utcnow = lambda: NOW
|
|
||||||
|
|
||||||
# clear datastore
|
# clear datastore
|
||||||
requests.post('http://%s/reset' % ndb_client.host)
|
requests.post('http://%s/reset' % ndb_client.host)
|
||||||
|
|
||||||
self.ndb_context = ndb_client.context()
|
self.ndb_context = ndb_client.context()
|
||||||
self.ndb_context.__enter__()
|
self.ndb_context.__enter__()
|
||||||
|
|
||||||
|
util.now = lambda **kwargs: testutil.NOW
|
||||||
|
|
||||||
def tearDown(self):
|
def tearDown(self):
|
||||||
self.ndb_context.__exit__(None, None, None)
|
self.ndb_context.__exit__(None, None, None)
|
||||||
super().tearDown()
|
super().tearDown()
|
||||||
|
|
|
@ -31,10 +31,11 @@ def getProfile(input, actor=None):
|
||||||
logger.info(f'Representative h-card: {json.dumps(hcard, indent=2)}')
|
logger.info(f'Representative h-card: {json.dumps(hcard, indent=2)}')
|
||||||
|
|
||||||
actor = microformats2.json_to_object(hcard)
|
actor = microformats2.json_to_object(hcard)
|
||||||
|
actor.setdefault('url', url)
|
||||||
logger.info(f'AS1 actor: {json.dumps(actor, indent=2)}')
|
logger.info(f'AS1 actor: {json.dumps(actor, indent=2)}')
|
||||||
|
|
||||||
profile = {
|
profile = {
|
||||||
**bluesky.from_as1(actor, from_url=url),
|
**bluesky.from_as1(actor),
|
||||||
'myState': {
|
'myState': {
|
||||||
# ?
|
# ?
|
||||||
'follow': 'TODO',
|
'follow': 'TODO',
|
||||||
|
|
49
xrpc_feed.py
49
xrpc_feed.py
|
@ -1,5 +1,11 @@
|
||||||
"""app.bsky.feed.* XRPC methods."""
|
"""app.bsky.feed.* XRPC methods."""
|
||||||
|
import json
|
||||||
import logging
|
import logging
|
||||||
|
import re
|
||||||
|
|
||||||
|
from granary import microformats2, bluesky
|
||||||
|
import mf2util
|
||||||
|
from oauth_dropins.webutil import util
|
||||||
|
|
||||||
from app import xrpc_server
|
from app import xrpc_server
|
||||||
|
|
||||||
|
@ -12,29 +18,36 @@ def getAuthorFeed(input, author=None, limit=None, before=None):
|
||||||
lexicons/app/bsky/feed/getAuthorFeed.json, feedViewPost.json
|
lexicons/app/bsky/feed/getAuthorFeed.json, feedViewPost.json
|
||||||
"""
|
"""
|
||||||
if not re.match(util.DOMAIN_RE, author):
|
if not re.match(util.DOMAIN_RE, author):
|
||||||
raise ValueError(f'{actor} is not a domain')
|
raise ValueError(f'{author} is not a domain')
|
||||||
|
|
||||||
url = f'https://{actor}/'
|
url = f'https://{author}/'
|
||||||
mf2 = util.fetch_mf2(url, gateway=True)
|
mf2 = util.fetch_mf2(url, gateway=True)
|
||||||
hcard = mf2util.representative_hcard(mf2, mf2['url'])
|
logger.info(f'Got mf2: {json.dumps(mf2, indent=2)}')
|
||||||
if not hcard:
|
|
||||||
raise ValueError(f"Couldn't find a representative h-card (http://microformats.org/wiki/representative-hcard-parsing) on {mf2['url']}")
|
|
||||||
|
|
||||||
logger.info(f'Representative h-card: {json.dumps(hcard, indent=2)}')
|
# hcard = mf2util.representative_hcard(mf2, mf2['url'])
|
||||||
|
feed_author = mf2util.find_author(mf2, source_url=url, fetch_mf2_func=util.fetch_mf2)
|
||||||
|
if feed_author:
|
||||||
|
logger.info(f'Authorship found: {feed_author}')
|
||||||
|
actor = {
|
||||||
|
'url': feed_author.get('url') or url,
|
||||||
|
'displayName': feed_author.get('name'),
|
||||||
|
'image': {'url': feed_author.get('photo')},
|
||||||
|
}
|
||||||
|
else:
|
||||||
|
logger.info(f'No authorship result on {url} ; generated {feed_author}')
|
||||||
|
actor = {
|
||||||
|
'url': url,
|
||||||
|
'displayName': author,
|
||||||
|
}
|
||||||
|
|
||||||
actor = microformats2.json_to_object(hcard)
|
# actor = microformats2.json_to_object(author)
|
||||||
logger.info(f'AS1 actor: {json.dumps(actor, indent=2)}')
|
activities = microformats2.json_to_activities(mf2) #, actor)
|
||||||
|
# default actor to feed author
|
||||||
|
for a in activities:
|
||||||
|
a.setdefault('actor', actor)
|
||||||
|
logger.info(f'AS1 activities: {json.dumps(activities, indent=2)}')
|
||||||
|
|
||||||
profile = {
|
return {'feed': [bluesky.from_as1(a) for a in activities]}
|
||||||
**bluesky.from_as1(actor, from_url=url),
|
|
||||||
'myState': {
|
|
||||||
# ?
|
|
||||||
'follow': 'TODO',
|
|
||||||
'member': 'TODO',
|
|
||||||
},
|
|
||||||
}
|
|
||||||
logger.info(f'Bluesky profile: {json.dumps(profile, indent=2)}')
|
|
||||||
return profile
|
|
||||||
|
|
||||||
|
|
||||||
@xrpc_server.method('app.bsky.feed.getPostThread')
|
@xrpc_server.method('app.bsky.feed.getPostThread')
|
||||||
|
|
Ładowanie…
Reference in New Issue