generated from freelawproject/new-project-template
-
-
Notifications
You must be signed in to change notification settings - Fork 11
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
Loading status checks…
Merge pull request #618 from freelawproject/threads-support
feat(channels): Add support for Threads
Showing
23 changed files
with
722 additions
and
13 deletions.
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,25 @@ | ||
# Generated by Django 5.1.1 on 2024-10-28 17:23 | ||
|
||
from django.db import migrations, models | ||
|
||
|
||
class Migration(migrations.Migration): | ||
dependencies = [ | ||
("channel", "0008_alter_channel_service_alter_post_object_id"), | ||
] | ||
|
||
operations = [ | ||
migrations.AlterField( | ||
model_name="channel", | ||
name="service", | ||
field=models.PositiveSmallIntegerField( | ||
choices=[ | ||
(1, "Twitter"), | ||
(2, "Mastodon"), | ||
(3, "Bluesky"), | ||
(4, "Threads"), | ||
], | ||
help_text="Type of the service", | ||
), | ||
), | ||
] |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,13 @@ | ||
{% extends "post.html" %} | ||
{% block title %}Threads Authorization Code{% endblock %} | ||
|
||
{% block post_title %}Threads Onboarding{% endblock %} | ||
|
||
{% block post_content %} | ||
<p class="lead">You're almost done! Use the code below in your CLI to complete the setup for this Threads account.</p> | ||
|
||
<h3>Authorization Code:</h3> | ||
<div class="flex bg-gray-200 rounded-lg p-5"> | ||
<p class="w-full break-all">{{code}}</p> | ||
</div> | ||
{% endblock %} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -1,11 +1,17 @@ | ||
from django.urls import path | ||
|
||
from .api_views import receive_mastodon_push | ||
from .views import threads_callback | ||
|
||
urlpatterns = [ | ||
path( | ||
"webhooks/mastodon/", | ||
receive_mastodon_push, | ||
name="mastodon_push_handler", | ||
), | ||
path( | ||
"threads_callback/", | ||
threads_callback, | ||
name="threads_code_display", | ||
), | ||
] |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,134 @@ | ||
import logging | ||
|
||
from bc.core.utils.images import TextImage | ||
|
||
from .alt_text_utils import text_image_alt_text, thumb_num_alt_text | ||
from .threads_api.client import ThreadsAPI | ||
|
||
logger = logging.getLogger(__name__) | ||
|
||
|
||
class ThreadsConnector: | ||
""" | ||
A connector for interfacing with the Threads API, which complies with | ||
the BaseAPIConnector protocol. | ||
""" | ||
|
||
def __init__( | ||
self, account: str, account_id: str, access_token: str | ||
) -> None: | ||
self.account = account | ||
self.account_id = account_id | ||
self.access_token = access_token | ||
self.api = self.get_api_object() | ||
|
||
def get_api_object(self, _version=None) -> ThreadsAPI: | ||
""" | ||
Returns an instance of the ThreadsAPI class. | ||
""" | ||
api = ThreadsAPI( | ||
self.account_id, | ||
self.access_token, | ||
) | ||
return api | ||
|
||
def upload_media(self, media: bytes, _alt_text=None) -> str: | ||
""" | ||
Uploads media to public storage for Threads API compatibility. | ||
Since Threads API requires media to be accessible via public URL, | ||
this method resizes the image as needed, uploads it to S3, and | ||
returns the public URL. | ||
Args: | ||
media (bytes): The image bytes to be uploaded. | ||
_alt_text (str, optional): Alternative text for accessibility | ||
(not currently used, required by protocol). | ||
Returns: | ||
str: Public URL of the uploaded image. | ||
""" | ||
return self.api.resize_and_upload_to_public_storage(media) | ||
|
||
def add_status( | ||
self, | ||
message: str, | ||
text_image: TextImage | None = None, | ||
thumbnails: list[bytes] | None = None, | ||
) -> str: | ||
""" | ||
Creates and publishes a new status update on Threads. | ||
This method determines the type of post (text-only, single image, | ||
or carousel) based on the provided media. If multiple images are | ||
provided, a carousel post is created. Otherwise, it creates a | ||
single image or text-only post. | ||
Args: | ||
message (str): The text content of the status. | ||
text_image (TextImage | None): An optional main image with text. | ||
thumbnails (list[bytes] | None): Optional list of thumbnails for | ||
a carousel post. | ||
Returns: | ||
str: The ID of the published status. | ||
""" | ||
media: list[str] = [] | ||
|
||
# Count media elements to determine type of post: | ||
multiple_thumbnails = thumbnails is not None and len(thumbnails) > 1 | ||
text_image_and_thumbnail = ( | ||
thumbnails is not None | ||
and len(thumbnails) > 0 | ||
and text_image is not None | ||
) | ||
is_carousel_item = multiple_thumbnails or text_image_and_thumbnail | ||
|
||
if text_image: | ||
image_url = self.upload_media(text_image.to_bytes()) | ||
item_container_id = self.api.create_image_container( | ||
image_url, | ||
message, | ||
text_image_alt_text(text_image.description), | ||
is_carousel_item, | ||
) | ||
if item_container_id: | ||
media.append(item_container_id) | ||
|
||
if thumbnails: | ||
for idx, thumbnail in enumerate(thumbnails): | ||
thumbnail_url = self.upload_media(thumbnail) | ||
item_container_id = self.api.create_image_container( | ||
thumbnail_url, | ||
message, | ||
thumb_num_alt_text(idx), | ||
is_carousel_item, | ||
) | ||
if not item_container_id: | ||
continue | ||
media.append(item_container_id) | ||
|
||
# Determine container id to be published based on media count: | ||
if len(media) > 1: | ||
# Carousel post (multiple images) | ||
container_id = self.api.create_carousel_container(media, message) | ||
elif len(media) == 1: | ||
# Single image post | ||
container_id = media[0] | ||
else: | ||
# Text-only post | ||
container_id = self.api.create_text_only_container(message) | ||
|
||
if container_id is None: | ||
logger.error( | ||
"ThreadsConnector could not get container to publish!" | ||
) | ||
return "" | ||
|
||
return self.api.publish_container(container_id) | ||
|
||
def __repr__(self) -> str: | ||
return ( | ||
f"<{self.__class__.__module__}.{self.__class__.__name__}: " | ||
f"account:'{self.account}'>" | ||
) |
Empty file.
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,205 @@ | ||
import logging | ||
import time | ||
import uuid | ||
|
||
import requests | ||
from django.conf import settings | ||
|
||
from bc.core.utils.images import convert_to_jpeg, resize_image | ||
from bc.core.utils.s3 import put_object_in_bucket | ||
|
||
logger = logging.getLogger(__name__) | ||
|
||
|
||
_BASE_API_URL = "https://graph.threads.net/v1.0" | ||
|
||
|
||
class ThreadsAPI: | ||
""" | ||
A client for interacting with the Threads API to create and publish content. | ||
For single-image and text-only posts, a container with all data is created, | ||
then the same container is published. | ||
For posts with multiple images, a container for each image should be created, | ||
then a container for the carousel with its children, and lastly the | ||
carousel container is published. | ||
Docs: https://developers.facebook.com/docs/threads/posts | ||
""" | ||
|
||
def __init__( | ||
self, | ||
account_id: str, | ||
access_token: str, | ||
timeout: int = 30, | ||
) -> None: | ||
self._account_id = account_id | ||
self._access_token = access_token | ||
self._timeout = timeout | ||
self._base_account_url = f"{_BASE_API_URL}/{self._account_id}" | ||
|
||
def publish_container(self, container_id: str) -> str: | ||
""" | ||
Publishes a media container to Threads. | ||
Args: | ||
container_id (str): The ID of the container to publish. | ||
Returns: | ||
str: The ID of the published post. | ||
""" | ||
base_url = f"{self._base_account_url}/threads_publish" | ||
params = { | ||
"creation_id": container_id, | ||
"access_token": self._access_token, | ||
} | ||
response = self.attempt_post(base_url, params) | ||
return response.json().get("id") if response is not None else "" | ||
|
||
def create_image_container( | ||
self, | ||
image_url: str, | ||
message: str, | ||
alt_text: str, | ||
is_carousel_item: bool = False, | ||
) -> str | None: | ||
""" | ||
Creates a container for an image post on Threads, to be published | ||
using the method publish_container. | ||
Args: | ||
image_url (str): URL of the image to post hosted on a public server. | ||
message (str): Text content to accompany the image. | ||
alt_text (str): Alt text for accessibility. | ||
is_carousel_item (bool, optional): Whether the image is part of a | ||
carousel. Defaults to False. | ||
Returns: | ||
str: The ID of the created image container. | ||
""" | ||
base_url = f"{self._base_account_url}/threads" | ||
params = { | ||
"image_url": image_url, | ||
"access_token": self._access_token, | ||
"media_type": "IMAGE", | ||
"alt_text": alt_text, | ||
} | ||
if is_carousel_item: | ||
params["is_carousel_item"] = "true" | ||
else: | ||
params["text"] = message | ||
response = self.attempt_post(base_url, params) | ||
return response.json().get("id") if response is not None else None | ||
|
||
def create_carousel_container( | ||
self, | ||
children: list[str], | ||
message: str, | ||
) -> str | None: | ||
""" | ||
Creates a carousel container with multiple images on Threads, | ||
to be published using the method publish_container. | ||
Args: | ||
children (list[str]): A list of container IDs for each image in the carousel. | ||
message (str): Text content to accompany the carousel. | ||
Returns: | ||
str: The ID of the created carousel container. | ||
""" | ||
base_url = f"{self._base_account_url}/threads" | ||
params = { | ||
"access_token": self._access_token, | ||
"media_type": "CAROUSEL", | ||
"children": ",".join(children), | ||
"text": message, | ||
} | ||
response = self.attempt_post(base_url, params) | ||
return response.json().get("id") if response is not None else None | ||
|
||
def create_text_only_container(self, message: str) -> str | None: | ||
""" | ||
Creates a container for a text-only post on Threads, | ||
to be published using the method publish_container. | ||
Args: | ||
message (str): The text content for the post. | ||
Returns: | ||
str: The ID of the created text container. | ||
""" | ||
base_url = f"{self._base_account_url}/threads" | ||
params = { | ||
"access_token": self._access_token, | ||
"media_type": "TEXT", | ||
"text": message, | ||
} | ||
response = self.attempt_post(base_url, params) | ||
return response.json().get("id") if response is not None else None | ||
|
||
def attempt_post( | ||
self, | ||
url: str, | ||
params: dict, | ||
) -> requests.Response | None: | ||
""" | ||
Attempts to send a POST request to a specified URL with given parameters. | ||
If the request is successful, the response is returned, otherwise `None` is returned. | ||
""" | ||
try: | ||
response = requests.post(url, params=params, timeout=self._timeout) | ||
response.raise_for_status() | ||
except requests.exceptions.Timeout: | ||
logger.error( | ||
f"Post request to Threads API timed out\n" | ||
f"Request URL: {url}" | ||
) | ||
return None | ||
except requests.exceptions.HTTPError as err: | ||
logger.error( | ||
f"An error occurred when trying to post to Threads API\n" | ||
f"Request URL: {url}\n\n" | ||
f"{err}" | ||
) | ||
return None | ||
return response | ||
|
||
@staticmethod | ||
def resize_and_upload_to_public_storage(media: bytes) -> str: | ||
""" | ||
Processes and uploads an image to S3 to meet Threads API requirements. | ||
Specifically, this method: | ||
- Converts the image to JPEG format to ensure compatibility, as | ||
Threads only accepts JPEG images. | ||
- Resizes the image to fit within specified width and aspect ratio | ||
constraints required by Threads. | ||
- Uploads the processed image to an S3 bucket with a unique filename, | ||
generating a public URL that can be passed to Threads API. | ||
Args: | ||
media (bytes): The original image bytes to be processed. | ||
Returns: | ||
str: The public URL of the uploaded image in S3. | ||
""" | ||
jpeg_image = convert_to_jpeg( | ||
image=media, | ||
quality=85, | ||
) | ||
resized_image = resize_image( | ||
image=jpeg_image, | ||
min_width=320, | ||
max_width=1440, | ||
min_aspect_ratio=4 / 5, | ||
max_aspect_ratio=1.91, | ||
) | ||
timestamp = time.strftime("%H%M%S") | ||
prefix = f"tmp/threads/{time.strftime('%Y/%m/%d')}/" | ||
file_name_in_bucket = f"{prefix}{timestamp}_{uuid.uuid4().hex}.jpeg" | ||
image_s3_url = put_object_in_bucket( | ||
resized_image, | ||
file_name_in_bucket, | ||
) | ||
return image_s3_url |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,8 @@ | ||
from django.http import HttpRequest, HttpResponse | ||
from django.template.response import TemplateResponse | ||
|
||
|
||
def threads_callback(request: HttpRequest) -> HttpResponse: | ||
code = request.GET.get("code", "No code found") | ||
|
||
return TemplateResponse(request, "threads_code.html", {"code": code}) |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,35 @@ | ||
import logging | ||
|
||
import boto3 | ||
|
||
from bc.settings import ( | ||
AWS_ACCESS_KEY_ID, | ||
AWS_S3_CUSTOM_DOMAIN, | ||
AWS_SECRET_ACCESS_KEY, | ||
AWS_SESSION_TOKEN, | ||
AWS_STORAGE_BUCKET_NAME, | ||
) | ||
|
||
logger = logging.getLogger(__name__) | ||
|
||
|
||
def put_object_in_bucket( | ||
media: bytes, | ||
file_name: str, | ||
content_type: str = "image/jpeg", | ||
acl: str = "public-read", | ||
) -> str: | ||
s3 = boto3.client( | ||
"s3", | ||
aws_access_key_id=AWS_ACCESS_KEY_ID, | ||
aws_secret_access_key=AWS_SECRET_ACCESS_KEY, | ||
aws_session_token=AWS_SESSION_TOKEN, | ||
) | ||
s3.put_object( | ||
Body=media, | ||
Bucket=AWS_STORAGE_BUCKET_NAME, | ||
Key=file_name, | ||
ContentType=content_type, | ||
ACL=acl, | ||
) | ||
return f"{AWS_S3_CUSTOM_DOMAIN}/{file_name}" |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,7 @@ | ||
import environ | ||
|
||
env = environ.FileAwareEnv() | ||
|
||
THREADS_APP_ID = env("THREADS_APP_ID", default="") | ||
THREADS_APP_SECRET = env("THREADS_APP_SECRET", default="") | ||
THREADS_CALLBACK_URL = env("THREADS_CALLBACK_URL", default="") |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,98 @@ | ||
import environ | ||
import requests | ||
|
||
env = environ.FileAwareEnv() | ||
|
||
APP_ID = env("THREADS_APP_ID") | ||
APP_SECRET = env("THREADS_APP_SECRET") | ||
THREADS_CALLBACK = env("THREADS_CALLBACK_URL") | ||
|
||
AUTHORIZATION_BASE_URL = "https://threads.net/oauth/authorize" | ||
|
||
SHORT_LIVED_ACCESS_TOKEN_URL = "https://graph.threads.net/oauth/access_token" | ||
LONG_LIVED_ACCESS_TOKEN_URL = "https://graph.threads.net/access_token" | ||
|
||
USER_INFO_BASE_URL = "https://graph.threads.net/v1.0" | ||
|
||
|
||
def main(): | ||
if not APP_ID or not APP_SECRET: | ||
raise Exception( | ||
"Please check your env file and make sure THREADS_APP_ID and THREADS_APP_SECRET are set" | ||
) | ||
|
||
authorization_url = ( | ||
f"{AUTHORIZATION_BASE_URL}" | ||
f"?client_id={APP_ID}" | ||
f"&redirect_uri={THREADS_CALLBACK}" | ||
f"&scope=threads_basic,threads_content_publish" | ||
f"&response_type=code" | ||
) | ||
print(f"\nPlease go here and authorize: {authorization_url}") | ||
threads_code = input("\nPaste the PIN here: ") | ||
|
||
# Get a short-lived token first: | ||
response = requests.post( | ||
SHORT_LIVED_ACCESS_TOKEN_URL, | ||
data={ | ||
"client_id": APP_ID, | ||
"client_secret": APP_SECRET, | ||
"code": threads_code, | ||
"grant_type": "authorization_code", | ||
"redirect_uri": THREADS_CALLBACK, | ||
}, | ||
) | ||
|
||
if response.status_code != 200: | ||
raise Exception( | ||
f"Short-lived token request returned an error: {response.status_code} {response.text}" | ||
) | ||
|
||
# Exchange short-lived token for a long-lived one: | ||
short_lived_data = response.json() | ||
short_lived_access_token = short_lived_data.get("access_token") | ||
user_id = short_lived_data.get("user_id") | ||
|
||
params = { | ||
"grant_type": "th_exchange_token", | ||
"client_secret": APP_SECRET, | ||
"access_token": short_lived_access_token, | ||
} | ||
response = requests.get(LONG_LIVED_ACCESS_TOKEN_URL, params=params) | ||
|
||
if response.status_code != 200: | ||
raise Exception( | ||
f"Long-lived token request returned an error: {response.status_code} {response.text}" | ||
) | ||
|
||
long_lived_data = response.json() | ||
long_access_token = long_lived_data.get("access_token") | ||
expires_in = long_lived_data.get("expires_in") | ||
|
||
user_info_url = f"{USER_INFO_BASE_URL}/{user_id}?fields=username&access_token={long_access_token}" | ||
response = requests.get(user_info_url) | ||
|
||
if response.status_code != 200: | ||
raise Exception( | ||
f"User info request returned an error: {response.status_code} {response.text}" | ||
) | ||
|
||
user_info = response.json() | ||
username = user_info.get("username") | ||
|
||
print( | ||
"\nUse the admin panel to create a new channel with the following data:" | ||
) | ||
print("Service: Threads") | ||
print(f"Account: {username}") | ||
print(f"Account id: {user_id}") | ||
print("Enable: True") | ||
print(f"Access Token: {long_access_token}") | ||
if expires_in is not None: | ||
print( | ||
f"\nNote: Token will expire in {expires_in / 86400:.1f} days unless refreshed." | ||
) | ||
|
||
|
||
if __name__ == "__main__": | ||
main() |