lib.itmens/mastodon/models/threads.py

274 lines
10 KiB
Python
Raw Normal View History

2024-07-03 00:07:07 -04:00
import functools
2024-07-05 16:26:26 -04:00
import typing
2024-07-03 00:07:07 -04:00
from datetime import timedelta
2024-07-03 16:42:20 -04:00
from urllib.parse import quote
2024-07-03 00:07:07 -04:00
import requests
from django.conf import settings
2024-07-03 16:42:20 -04:00
from django.core.exceptions import RequestAborted
2024-07-03 00:07:07 -04:00
from django.http import HttpRequest
from django.urls import reverse
from django.utils import timezone
from loguru import logger
from catalog.common import jsondata
2024-07-05 18:15:10 -04:00
from takahe.utils import Takahe
2024-07-03 00:07:07 -04:00
2024-07-01 17:29:38 -04:00
from .common import SocialAccount
2024-07-05 16:26:26 -04:00
if typing.TYPE_CHECKING:
from catalog.common.models import Item
from journal.models.common import Content, VisibilityType
2024-07-03 00:07:07 -04:00
get = functools.partial(
requests.get,
2024-07-03 16:42:20 -04:00
timeout=settings.THREADS_TIMEOUT,
2024-07-03 00:07:07 -04:00
headers={"User-Agent": settings.NEODB_USER_AGENT},
)
put = functools.partial(
requests.put,
2024-07-03 16:42:20 -04:00
timeout=settings.THREADS_TIMEOUT,
2024-07-03 00:07:07 -04:00
headers={"User-Agent": settings.NEODB_USER_AGENT},
)
post = functools.partial(
requests.post,
2024-07-03 16:42:20 -04:00
timeout=settings.THREADS_TIMEOUT,
2024-07-03 00:07:07 -04:00
headers={"User-Agent": settings.NEODB_USER_AGENT},
)
delete = functools.partial(
requests.post,
2024-07-03 16:42:20 -04:00
timeout=settings.THREADS_TIMEOUT,
2024-07-03 00:07:07 -04:00
headers={"User-Agent": settings.NEODB_USER_AGENT},
)
2024-07-01 17:29:38 -04:00
class Threads:
2024-07-03 00:07:07 -04:00
SCOPE = "threads_basic,threads_content_publish"
DOMAIN = "threads.net"
@staticmethod
def generate_auth_url(request: HttpRequest):
redirect_url = request.build_absolute_uri(reverse("mastodon:threads_oauth"))
url = f"https://threads.net/oauth/authorize?client_id={settings.THREADS_APP_ID}&redirect_uri={redirect_url}&scope={Threads.SCOPE}&response_type=code"
return url
@staticmethod
def obtain_token(
request: HttpRequest, code: str
) -> tuple[str, int, str] | tuple[None, None, None]:
redirect_url = request.build_absolute_uri(reverse("mastodon:threads_oauth"))
payload = {
"client_id": settings.THREADS_APP_ID,
"client_secret": settings.THREADS_APP_SECRET,
"redirect_uri": redirect_url,
"grant_type": "authorization_code",
"code": code,
}
url = "https://graph.threads.net/oauth/access_token"
try:
response = post(url, data=payload)
except Exception as e:
logger.warning(f"Error {url} {e}")
return None, None, None
if response.status_code != 200:
logger.warning(f"Error {url} {response.status_code}")
return None, None, None
data = response.json()
if data.get("error_type"):
logger.warning(f"Error {url} {data}")
return None, None, None
short_token = data.get("access_token")
user_id = data.get("user_id")
# exchange for a 60-days token
url = f"https://graph.threads.net/access_token?grant_type=th_exchange_token&client_secret={settings.THREADS_APP_SECRET}&access_token={short_token}"
try:
response = get(url)
except Exception as e:
logger.warning(f"Error {url} {e}")
return None, None, None
if response.status_code != 200:
logger.warning(f"Error {url} {response.status_code}")
return None, None, None
data = response.json()
if data.get("error_type"):
logger.warning(f"Error {url} {data}")
return None, None, None
return data.get("access_token"), data.get("expires_in"), str(user_id)
@staticmethod
def refresh_token(token: str) -> tuple[str, int] | tuple[None, None]:
url = f"https://graph.threads.net/refresh_access_token?grant_type=th_refresh_token&access_token={token}"
try:
response = get(url)
except Exception as e:
logger.warning(f"Error {url} {e}")
return None, None
if response.status_code != 200:
logger.warning(f"Error {url} {response.status_code}")
return None, None
data = response.json()
if data.get("error_type"):
logger.warning(f"Error {url} {data}")
return None, None
return data.get("access_token"), data.get("expires_in")
@staticmethod
def get_profile(
token: str, user_id: str | None = None
) -> dict[str, str | int] | None:
url = f"https://graph.threads.net/v1.0/{user_id or 'me'}?fields=id,username,threads_profile_picture_url,threads_biography&access_token={token}"
2024-07-03 00:07:07 -04:00
try:
response = get(url)
except Exception as e:
logger.warning(f"Error {url} {e}")
return None
if response.status_code != 200:
logger.warning(f"Error {url} {response.status_code}")
return None
data = response.json()
if data.get("error_type"):
logger.warning(f"Error {url} {data}")
return None
return data
2024-07-03 16:42:20 -04:00
@staticmethod
2024-07-04 12:43:45 -04:00
def post_single(token: str, user_id: str, text: str, reply_to_id=None):
2024-07-03 16:42:20 -04:00
url = f"https://graph.threads.net/v1.0/{user_id}/threads?media_type=TEXT&access_token={token}&text={quote(text)}"
2024-07-04 12:43:45 -04:00
# TODO waiting for Meta to confirm it's bug or permission issue
# if reply_to_id:
# url += "&reply_to_id=" + reply_to_id
2024-07-03 16:42:20 -04:00
response = post(url)
if response.status_code != 200:
2024-07-04 12:43:45 -04:00
logger.debug(f"Error {url} {response.status_code} {response.content}")
2024-07-03 16:42:20 -04:00
return None
media_container_id = (response.json() or {}).get("id")
if not media_container_id:
return None
url = f"https://graph.threads.net/v1.0/{user_id}/threads_publish?creation_id={media_container_id}&access_token={token}"
response = post(url)
if response.status_code != 200:
2024-07-04 12:43:45 -04:00
logger.debug(f"Error {url} {response.status_code} {response.content}")
2024-07-03 16:42:20 -04:00
return None
2024-07-04 12:43:45 -04:00
media_id = (response.json() or {}).get("id")
return media_id
2024-07-03 16:42:20 -04:00
@staticmethod
def get_single(token: str, media_id: str) -> dict | None:
# url = f"https://graph.threads.net/v1.0/{media_id}?fields=id,media_product_type,media_type,media_url,permalink,owner,username,text,timestamp,shortcode,thumbnail_url,children,is_quote_post&access_token={token}"
url = f"https://graph.threads.net/v1.0/{media_id}?fields=id,permalink,is_quote_post&access_token={token}"
response = post(url)
if response.status_code != 200:
return None
return response.json()
2024-07-03 00:07:07 -04:00
@staticmethod
def authenticate(request: HttpRequest, code: str) -> "ThreadsAccount | None":
token, expire, uid = Threads.obtain_token(request, code)
if not token or not expire:
return None
expires_at = timezone.now() + timedelta(seconds=expire)
existing_account = ThreadsAccount.objects.filter(
uid=uid, domain=Threads.DOMAIN
).first()
if existing_account:
existing_account.access_token = token
existing_account.token_expires_at = expires_at
existing_account.last_reachable = timezone.now()
existing_account.save(update_fields=["access_data", "last_reachable"])
existing_account.refresh()
return existing_account
account = ThreadsAccount()
account.uid = uid
account.access_token = token
account.domain = Threads.DOMAIN
account.token_expires_at = expires_at
account.refresh(save=False)
2024-07-05 18:15:10 -04:00
# for fresh account, ping them for convenience
Takahe.fetch_remote_identity(account.handle + "@" + Threads.DOMAIN)
2024-07-03 00:07:07 -04:00
return account
2024-07-01 17:29:38 -04:00
class ThreadsAccount(SocialAccount):
2024-07-03 00:07:07 -04:00
access_token = jsondata.EncryptedTextField(
json_field_name="access_data", default=""
)
token_expires_at = jsondata.DateTimeField(json_field_name="access_data", null=True)
username = jsondata.CharField(json_field_name="account_data", default="")
threads_profile_picture_url = jsondata.CharField(
json_field_name="account_data", default=""
)
threads_biography = jsondata.CharField(json_field_name="account_data", default="")
@property
def url(self):
return f"https://threads.net/@{self.handle}"
def check_alive(self, save=True) -> bool:
# refresh token
if not self.access_token:
logger.warning(f"{self} token missing")
return False
if self.token_expires_at and timezone.now() > self.token_expires_at:
logger.warning(f"{self} token expired")
return False
if self.last_reachable and timezone.now() < self.last_reachable + timedelta(
hours=1
):
return True
token, expire = Threads.refresh_token(self.access_token)
if not token or not expire:
return False
self.access_token = token
self.last_reachable = timezone.now()
self.token_expires_at = self.last_reachable + timedelta(seconds=expire)
if save:
self.save(update_fields=["access_data", "last_reachable"])
return True
def refresh(self, save=True) -> bool:
if not self.access_token:
logger.warning(f"{self} token missing")
return False
if self.token_expires_at and timezone.now() > self.token_expires_at:
logger.warning(f"{self} token expired")
return False
data = Threads.get_profile(self.access_token)
if not data:
2024-07-03 16:42:20 -04:00
logger.warning(f"{self} unable to get profile")
2024-07-03 00:07:07 -04:00
return False
if self.handle != data["username"]:
if self.handle:
logger.info(f"{self} handle changed to {data['username']}")
2024-07-05 18:15:10 -04:00
self.handle = str(data["username"])
2024-07-03 00:07:07 -04:00
self.account_data = data
self.last_refresh = timezone.now()
if save:
self.save(update_fields=["account_data", "handle", "last_refresh"])
return True
2024-07-03 16:42:20 -04:00
2024-07-05 16:26:26 -04:00
def post(
self,
content: str,
visibility: "VisibilityType",
reply_to_id=None,
obj: "Item | Content | None" = None,
rating=None,
**kwargs,
):
from journal.models.renderers import render_rating
2024-07-03 16:42:20 -04:00
text = (
2024-07-05 16:26:26 -04:00
content.replace("##rating##", render_rating(rating))
.replace("##obj_link_if_plain##", obj.absolute_url + "\n" if obj else "")
.replace("##obj##", obj.display_title if obj else "")
2024-07-03 16:42:20 -04:00
)
2024-07-04 12:43:45 -04:00
media_id = Threads.post_single(self.access_token, self.uid, text, reply_to_id)
if not media_id:
raise RequestAborted()
return {"id": media_id}
# if media_id:
# d = Threads.get_single(self.access_token, media_id)
# if d:
# return {"id": media_id, "url": d["permalink"]}