Skip to content

Commit b997bec

Browse files
authored
Merge pull request #270 from MerleLiuKun/feat-threads
Feat threads
2 parents ef7e479 + 485f73e commit b997bec

File tree

8 files changed

+237
-4
lines changed

8 files changed

+237
-4
lines changed

docs/docs/usage/threads-graph-api.md

+43
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,43 @@
1+
## Introduction
2+
3+
You may use the Threads API to enable people to create and publish content on a person’s behalf on Threads, and to
4+
display those posts within your app solely to the person who created it.
5+
6+
## How to use
7+
8+
Just like the base `Graph API`.
9+
10+
The following code snippet shows how to perform an OAuth flow with the Threads API:
11+
12+
```python
13+
from pyfacebook import ThreadsGraphAPI
14+
15+
api = ThreadsGraphAPI(
16+
app_id="Your app id",
17+
app_secret="Your app secret",
18+
oauth_flow=True,
19+
redirect_uri="Your callback domain",
20+
scope=["threads_basic", "threads_content_publish", "threads_read_replies", "threads_manage_replies",
21+
"threads_manage_insights"]
22+
)
23+
24+
# Got authorization url
25+
api.get_authorization_url()
26+
# https://threads.net/oauth/authorize?response_type=code&client_id=app_id&redirect_uri=https%3A%2F%2Fexample.com%2Fcallback&scope=threads_basic%2Cthreads_content_publish%2Cthreads_read_replies%2Cthreads_manage_replies%2Cthreads_manage_insights&state=PyFacebook
27+
28+
# Once the user has authorized your app, you will get the redirected URL.
29+
# like `https://example.com/callback?code=AQBZzYhLZB&state=PyFacebook#_`
30+
token = api.exchange_user_access_token(response="Your response url")
31+
print(token)
32+
# {'access_token': 'access_token', 'user_id': 12342412}
33+
```
34+
35+
After those steps, you can use the `api` object to call the Threads API.
36+
37+
For example:
38+
39+
```python
40+
api.get_object(object_id="me", fields=["id"])
41+
42+
# {'id': '12342412'}
43+
```

docs/mkdocs.yml

+1
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,7 @@ nav:
3333
- Usage:
3434
- Graph API: usage/graph-api.md
3535
- Server-Sent Events: usage/server-send-events.md
36+
- Threads Graph API: usage/threads-graph-api.md
3637
- Changelog: CHANGELOG.md
3738
- Previous Version Docs:
3839
- README: previous-version/readme.md

pyfacebook/__init__.py

+6-1
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,12 @@
11
from pyfacebook.models import *
22
from pyfacebook.ratelimit import RateLimitHeader, RateLimit, PercentSecond
33
from pyfacebook.exceptions import PyFacebookException, FacebookError, LibraryError
4-
from pyfacebook.api import GraphAPI, BasicDisplayAPI, ServerSentEventAPI
4+
from pyfacebook.api import (
5+
GraphAPI,
6+
BasicDisplayAPI,
7+
ThreadsGraphAPI,
8+
ServerSentEventAPI,
9+
)
510
from pyfacebook.api.facebook.client import FacebookApi
611
from pyfacebook.api.instagram_business.client import IGBusinessApi
712
from pyfacebook.api.instagram_basic.client import IGBasicDisplayApi

pyfacebook/api/__init__.py

+1-1
Original file line numberDiff line numberDiff line change
@@ -1 +1 @@
1-
from .graph import GraphAPI, BasicDisplayAPI, ServerSentEventAPI
1+
from .graph import GraphAPI, BasicDisplayAPI, ThreadsGraphAPI, ServerSentEventAPI

pyfacebook/api/graph.py

+125-2
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,7 @@
88
import re
99
import time
1010
from urllib.parse import parse_qsl, urlparse
11-
from typing import Any, Dict, List, Optional, Tuple
11+
from typing import Any, Dict, List, Optional, Tuple, Union
1212
from warnings import warn
1313

1414
import requests
@@ -505,7 +505,7 @@ def delete_object(
505505
def _get_oauth_session(
506506
self,
507507
redirect_uri: Optional[str] = None,
508-
scope: Optional[List[str]] = None,
508+
scope: Optional[Union[List[str], str]] = None,
509509
state: Optional[str] = None,
510510
**kwargs,
511511
) -> OAuth2Session:
@@ -869,6 +869,129 @@ def debug_token(self, input_token: str, access_token: Optional[str] = None) -> d
869869
raise LibraryError({"message": "Method not support"})
870870

871871

872+
class ThreadsGraphAPI(GraphAPI):
873+
GRAPH_URL = "https://graph.threads.net/"
874+
DEFAULT_SCOPE = ["threads_basic"]
875+
AUTHORIZATION_URL = "https://threads.net/oauth/authorize"
876+
EXCHANGE_ACCESS_TOKEN_URL = "https://graph.threads.net/oauth/access_token"
877+
878+
VALID_API_VERSIONS = ["v1.0"]
879+
880+
@staticmethod
881+
def fix_scope(scope: Optional[List[str]] = None):
882+
"""
883+
Note: After tests, the api for threads only support for comma-separated list.
884+
885+
:param scope: A list of permission string to request from the person using your app.
886+
:return: comma-separated scope string
887+
"""
888+
return ",".join(scope) if scope else scope
889+
890+
def get_authorization_url(
891+
self,
892+
redirect_uri: Optional[str] = None,
893+
scope: Optional[List[str]] = None,
894+
state: Optional[str] = None,
895+
url_kwargs: Optional[Dict[str, Any]] = None,
896+
**kwargs,
897+
) -> Tuple[str, str]:
898+
"""
899+
Build authorization url to do oauth.
900+
Refer: https://developers.facebook.com/docs/facebook-login/manually-build-a-login-flow
901+
902+
:param redirect_uri: The URL that you want to redirect the person logging in back to.
903+
Note: Your redirect uri need be set to `Valid OAuth redirect URIs` items in App Dashboard.
904+
:param scope: A list of permission string to request from the person using your app.
905+
:param state: A CSRF token that will be passed to the redirect URL.
906+
:param url_kwargs: Additional parameters for generate authorization url. like config_id.
907+
:param kwargs: Additional parameters for oauth.
908+
:return: URL to do oauth and state
909+
"""
910+
if scope:
911+
self.scope = scope
912+
scope = self.fix_scope(self.scope)
913+
914+
session = self._get_oauth_session(
915+
redirect_uri=redirect_uri, scope=scope, state=state, **kwargs
916+
)
917+
url_kwargs = {} if url_kwargs is None else url_kwargs
918+
authorization_url, state = session.authorization_url(
919+
url=self.authorization_url, **url_kwargs
920+
)
921+
return authorization_url, state
922+
923+
def exchange_user_access_token(
924+
self,
925+
response: str,
926+
redirect_uri: Optional[str] = None,
927+
scope: Optional[List[str]] = None,
928+
state: Optional[str] = None,
929+
**kwargs,
930+
) -> dict:
931+
"""
932+
:param response: The redirect response url for authorize redirect
933+
:param redirect_uri: Url for your redirect.
934+
:param scope: A list of permission string to request from the person using your app.
935+
:param state: A CSRF token that will be passed to the redirect URL.
936+
:param kwargs: Additional parameters for oauth.
937+
:return:
938+
"""
939+
if scope:
940+
self.scope = scope
941+
scope = self.fix_scope(self.scope)
942+
943+
session = self._get_oauth_session(
944+
redirect_uri=redirect_uri, scope=scope, state=state, **kwargs
945+
)
946+
947+
session.fetch_token(
948+
self.access_token_url,
949+
client_secret=self.app_secret,
950+
authorization_response=response,
951+
include_client_id=True,
952+
)
953+
self.access_token = session.access_token
954+
955+
return session.token
956+
957+
def exchange_long_lived_user_access_token(self, access_token=None) -> dict:
958+
"""
959+
Generate long-lived token by short-lived token, Long-lived token generally lasts about 60 days.
960+
961+
:param access_token: Short-lived user access token
962+
:return: Long-lived user access token info.
963+
"""
964+
if access_token is None:
965+
access_token = self.access_token
966+
args = {
967+
"grant_type": "th_exchange_token",
968+
"client_id": self.app_id,
969+
"client_secret": self.app_secret,
970+
"access_token": access_token,
971+
}
972+
973+
resp = self._request(
974+
url=self.access_token_url,
975+
args=args,
976+
auth_need=False,
977+
)
978+
data = self._parse_response(resp)
979+
return data
980+
981+
def refresh_access_token(self, access_token: str):
982+
"""
983+
:param access_token: The valid (unexpired) long-lived Instagram User Access Token that you want to refresh.
984+
:return: New access token.
985+
"""
986+
args = {"grant_type": "th_refresh_token", "access_token": access_token}
987+
resp = self._request(
988+
url="refresh_access_token",
989+
args=args,
990+
)
991+
data = self._parse_response(resp)
992+
return data
993+
994+
872995
class ServerSentEventAPI:
873996
"""
874997
Notice: Server-Sent Events are deprecated and will be removed December 31, 2023.
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
{"access_token": "THQVJ...","token_type": "bearer", "expires_in": 5183944}

testdata/base/threads_user_token.json

+1
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
{"access_token": "THQVJ...","user_id": 12345678}

tests/test_threads_graph_api.py

+59
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,59 @@
1+
"""
2+
tests for threads graph api
3+
"""
4+
5+
import responses
6+
7+
from pyfacebook import ThreadsGraphAPI
8+
9+
10+
def test_threads_get_authorization_url():
11+
api = ThreadsGraphAPI(app_id="id", app_secret="secret", oauth_flow=True)
12+
13+
url, state = api.get_authorization_url(scope=["threads_basic"])
14+
assert (
15+
url
16+
== "https://threads.net/oauth/authorize?response_type=code&client_id=id&redirect_uri=https%3A%2F%2Flocalhost%2F&scope=threads_basic&state=PyFacebook"
17+
)
18+
19+
20+
def test_threads_exchange_user_access_token(helpers):
21+
api = ThreadsGraphAPI(app_id="id", app_secret="secret", oauth_flow=True)
22+
23+
resp = "https://localhost/?code=code&state=PyFacebook#_"
24+
25+
with responses.RequestsMock() as m:
26+
m.add(
27+
method=responses.POST,
28+
url=api.EXCHANGE_ACCESS_TOKEN_URL,
29+
json=helpers.load_json("testdata/base/threads_user_token.json"),
30+
)
31+
32+
r = api.exchange_user_access_token(response=resp, scope=["threads_basic"])
33+
assert r["access_token"] == "THQVJ..."
34+
35+
36+
def test_threads_exchange_long_lived_user_access_token(helpers):
37+
api = ThreadsGraphAPI(app_id="id", app_secret="secret", access_token="token")
38+
with responses.RequestsMock() as m:
39+
m.add(
40+
method=responses.GET,
41+
url=f"https://graph.threads.net/oauth/access_token",
42+
json=helpers.load_json("testdata/base/threads_user_long_lived_token.json"),
43+
)
44+
45+
r = api.exchange_long_lived_user_access_token()
46+
assert r["access_token"] == "THQVJ..."
47+
48+
49+
def test_threads_refresh_access_token(helpers):
50+
api = ThreadsGraphAPI(app_id="id", app_secret="secret", access_token="token")
51+
with responses.RequestsMock() as m:
52+
m.add(
53+
method=responses.GET,
54+
url=f"https://graph.threads.net/refresh_access_token",
55+
json=helpers.load_json("testdata/base/threads_user_long_lived_token.json"),
56+
)
57+
58+
r = api.refresh_access_token(access_token=api.access_token)
59+
assert r["access_token"] == "THQVJ..."

0 commit comments

Comments
 (0)