lib.itmens/mastodon/models/threads.py

273 lines
10 KiB
Python

import functools
import typing
from datetime import timedelta
from urllib.parse import quote
import requests
from django.conf import settings
from django.core.exceptions import RequestAborted
from django.http import HttpRequest
from django.urls import reverse
from django.utils import timezone
from loguru import logger
from catalog.common import jsondata
from takahe.utils import Takahe
from .common import SocialAccount
if typing.TYPE_CHECKING:
from catalog.common.models import Item
from journal.models.common import Content, VisibilityType
get = functools.partial(
requests.get,
timeout=settings.THREADS_TIMEOUT,
headers={"User-Agent": settings.NEODB_USER_AGENT},
)
put = functools.partial(
requests.put,
timeout=settings.THREADS_TIMEOUT,
headers={"User-Agent": settings.NEODB_USER_AGENT},
)
post = functools.partial(
requests.post,
timeout=settings.THREADS_TIMEOUT,
headers={"User-Agent": settings.NEODB_USER_AGENT},
)
delete = functools.partial(
requests.post,
timeout=settings.THREADS_TIMEOUT,
headers={"User-Agent": settings.NEODB_USER_AGENT},
)
class Threads:
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}"
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
@staticmethod
def post_single(token: str, user_id: str, text: str, reply_to_id=None):
url = f"https://graph.threads.net/v1.0/{user_id}/threads?media_type=TEXT&access_token={token}&text={quote(text)}"
# TODO waiting for Meta to confirm it's bug or permission issue
# if reply_to_id:
# url += "&reply_to_id=" + reply_to_id
response = post(url)
if response.status_code != 200:
logger.debug(f"Error {url} {response.status_code} {response.content}")
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:
logger.debug(f"Error {url} {response.status_code} {response.content}")
return None
media_id = (response.json() or {}).get("id")
return media_id
@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()
@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)
# for fresh account, ping them for convenience
Takahe.fetch_remote_identity(account.handle + "@" + Threads.DOMAIN)
return account
class ThreadsAccount(SocialAccount):
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:
logger.warning(f"{self} unable to get profile")
return False
if self.handle != data["username"]:
if self.handle:
logger.info(f"{self} handle changed to {data['username']}")
self.handle = str(data["username"])
self.account_data = data
self.last_refresh = timezone.now()
if save:
self.save(update_fields=["account_data", "handle", "last_refresh"])
return True
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
text = (
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 "")
)
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"]}