diff --git a/.env.example b/.env.example
index 4b5b54a1..c68d3544 100644
--- a/.env.example
+++ b/.env.example
@@ -5,6 +5,7 @@ SECRET_KEY="PUT-A-VALUE-HERE"
# aws.py
AWS_DEV_ACCESS_KEY_ID=""
AWS_DEV_SECRET_ACCESS_KEY=""
+AWS_DEV_SESSION_TOKEN="" # <-- Required with temporary credentials
AWS_ACCESS_KEY_ID=""
AWS_SECRET_ACCESS_KEY=""
AWS_CLOUDFRONT_DISTRIBUTION_ID=""
@@ -47,8 +48,6 @@ SENTRY_REPORT_URI=""
# twitter.py
TWITTER_CONSUMER_KEY=""
TWITTER_CONSUMER_SECRET=""
-TWITTER_ACCESS_TOKEN=""
-TWITTER_ACCESS_TOKEN_SECRET=""
# django.py
HOSTNAME=""
@@ -62,5 +61,9 @@ DEBUG="on"
# misc.py
WEBHOOK_DELAY_TIME=120
-DOCTOR_HOST=""
+DOCTOR_HOST="http://bc2-doctor:5050"
+# threads.py
+THREADS_APP_ID=""
+THREADS_APP_SECRET=""
+THREADS_CALLBACK_URL=""
diff --git a/bc/assets/templates/includes/follow-button.html b/bc/assets/templates/includes/follow-button.html
index 368f22a1..b4a765b0 100644
--- a/bc/assets/templates/includes/follow-button.html
+++ b/bc/assets/templates/includes/follow-button.html
@@ -7,6 +7,8 @@
{% include 'includes/inlines/mastodon.svg' %}
{% elif service_name == 'Bluesky' %}
{% include 'includes/inlines/bluesky.svg' %}
+ {% elif service_name == 'Threads' %}
+ {% include 'includes/inlines/threads.svg' %}
{% endif %}
diff --git a/bc/assets/templates/includes/inlines/threads.svg b/bc/assets/templates/includes/inlines/threads.svg
new file mode 100644
index 00000000..ae0ec86c
--- /dev/null
+++ b/bc/assets/templates/includes/inlines/threads.svg
@@ -0,0 +1 @@
+
diff --git a/bc/channel/migrations/0009_alter_channel_service.py b/bc/channel/migrations/0009_alter_channel_service.py
new file mode 100644
index 00000000..00c492a4
--- /dev/null
+++ b/bc/channel/migrations/0009_alter_channel_service.py
@@ -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",
+ ),
+ ),
+ ]
diff --git a/bc/channel/models.py b/bc/channel/models.py
index fcfb2eee..1a645afb 100644
--- a/bc/channel/models.py
+++ b/bc/channel/models.py
@@ -9,6 +9,7 @@
from .utils.connectors.base import BaseAPIConnector
from .utils.connectors.bluesky import BlueskyConnector
from .utils.connectors.masto import MastodonConnector, get_handle_parts
+from .utils.connectors.threads import ThreadsConnector
from .utils.connectors.twitter import TwitterConnector
@@ -62,10 +63,12 @@ class Channel(AbstractDateTimeModel):
TWITTER = 1
MASTODON = 2
BLUESKY = 3
+ THREADS = 4
CHANNELS = (
(TWITTER, "Twitter"),
(MASTODON, "Mastodon"),
(BLUESKY, "Bluesky"),
+ (THREADS, "Threads"),
)
service = models.PositiveSmallIntegerField(
help_text="Type of the service",
@@ -117,6 +120,10 @@ def get_api_wrapper(self) -> BaseAPIConnector:
)
case self.BLUESKY:
return BlueskyConnector(self.account_id, self.access_token)
+ case self.THREADS:
+ return ThreadsConnector(
+ self.account, self.account_id, self.access_token
+ )
case _:
raise NotImplementedError(
f"No wrapper implemented for service: '{self.service}'."
@@ -131,6 +138,8 @@ def self_url(self):
return f"{instance_part}@{account_part}"
case self.BLUESKY:
return f"https://bsky.app/profile/{self.account_id}"
+ case self.THREADS:
+ return f"https://www.threads.net/@{self.account}"
case _:
raise NotImplementedError(
f"Channel.self_url() not yet implemented for service {self.service}"
diff --git a/bc/channel/templates/threads_code.html b/bc/channel/templates/threads_code.html
new file mode 100644
index 00000000..831b9ebf
--- /dev/null
+++ b/bc/channel/templates/threads_code.html
@@ -0,0 +1,13 @@
+{% extends "post.html" %}
+{% block title %}Threads Authorization Code{% endblock %}
+
+{% block post_title %}Threads Onboarding{% endblock %}
+
+{% block post_content %}
+
You're almost done! Use the code below in your CLI to complete the setup for this Threads account.
+
+
Authorization Code:
+
+{% endblock %}
diff --git a/bc/channel/urls.py b/bc/channel/urls.py
index f4fbd15b..8238c2cc 100644
--- a/bc/channel/urls.py
+++ b/bc/channel/urls.py
@@ -1,6 +1,7 @@
from django.urls import path
from .api_views import receive_mastodon_push
+from .views import threads_callback
urlpatterns = [
path(
@@ -8,4 +9,9 @@
receive_mastodon_push,
name="mastodon_push_handler",
),
+ path(
+ "threads_callback/",
+ threads_callback,
+ name="threads_code_display",
+ ),
]
diff --git a/bc/channel/utils/connectors/base.py b/bc/channel/utils/connectors/base.py
index 9afd9d69..3a209516 100644
--- a/bc/channel/utils/connectors/base.py
+++ b/bc/channel/utils/connectors/base.py
@@ -4,11 +4,12 @@
from TwitterAPI import TwitterAPI
from bc.channel.utils.connectors.bluesky_api.client import BlueskyAPI
+from bc.channel.utils.connectors.threads_api.client import ThreadsAPI
from bc.core.utils.images import TextImage
from .bluesky_api.types import ImageBlob
-ApiWrapper = Union[Mastodon, TwitterAPI, BlueskyAPI]
+ApiWrapper = Union[Mastodon, TwitterAPI, BlueskyAPI, ThreadsAPI]
class BaseAPIConnector(Protocol):
@@ -30,7 +31,7 @@ def get_api_object(self, version: str | None = None) -> ApiWrapper:
def upload_media(
self, media: bytes, alt_text: str
- ) -> int | ImageBlob | None:
+ ) -> int | str | ImageBlob | None:
"""
creates a media attachment to be used with a new status.
diff --git a/bc/channel/utils/connectors/bluesky.py b/bc/channel/utils/connectors/bluesky.py
index 0e75d3d8..d3315f3e 100644
--- a/bc/channel/utils/connectors/bluesky.py
+++ b/bc/channel/utils/connectors/bluesky.py
@@ -1,7 +1,6 @@
from bc.core.utils.images import TextImage
from .alt_text_utils import text_image_alt_text, thumb_num_alt_text
-from .base import ApiWrapper
from .bluesky_api.client import BlueskyAPI
from .bluesky_api.types import ImageBlob, Thumbnail
@@ -10,9 +9,9 @@ class BlueskyConnector:
def __init__(self, identifier: str, password: str) -> None:
self.identifier = identifier
self.password = password
- self.api: BlueskyAPI = self.get_api_object()
+ self.api = self.get_api_object()
- def get_api_object(self, _version=None) -> ApiWrapper:
+ def get_api_object(self, _version=None) -> BlueskyAPI:
return BlueskyAPI(self.identifier, self.password)
def upload_media(
diff --git a/bc/channel/utils/connectors/threads.py b/bc/channel/utils/connectors/threads.py
new file mode 100644
index 00000000..87c0033b
--- /dev/null
+++ b/bc/channel/utils/connectors/threads.py
@@ -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}'>"
+ )
diff --git a/bc/channel/utils/connectors/threads_api/__init__.py b/bc/channel/utils/connectors/threads_api/__init__.py
new file mode 100644
index 00000000..e69de29b
diff --git a/bc/channel/utils/connectors/threads_api/client.py b/bc/channel/utils/connectors/threads_api/client.py
new file mode 100644
index 00000000..e7267fce
--- /dev/null
+++ b/bc/channel/utils/connectors/threads_api/client.py
@@ -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
diff --git a/bc/channel/views.py b/bc/channel/views.py
new file mode 100644
index 00000000..e961ca97
--- /dev/null
+++ b/bc/channel/views.py
@@ -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})
diff --git a/bc/core/utils/images.py b/bc/core/utils/images.py
index cedb7575..d9ed21a7 100644
--- a/bc/core/utils/images.py
+++ b/bc/core/utils/images.py
@@ -1,4 +1,5 @@
import io
+import logging
from dataclasses import dataclass, field
from math import ceil, sqrt
from textwrap import fill, wrap
@@ -8,6 +9,8 @@
from PIL.Image import Image as ImageCls
from PIL.ImageDraw import Draw, ImageDraw
+logger = logging.getLogger(__name__)
+
@dataclass
class TextImage:
@@ -447,3 +450,97 @@ def add_sponsored_text_to_thumbnails(
thumbnail.add_sponsored_text()
watermarked_thumbnails.append(thumbnail.to_bytes())
return watermarked_thumbnails
+
+
+def convert_to_jpeg(
+ image: bytes,
+ quality: int = 85,
+) -> bytes:
+ # Load the image from bytes
+ with Image.open(io.BytesIO(image)) as img:
+
+ # Ensure image is in RGB mode (JPEG requirement, no transparency)
+ if img.mode != "RGB":
+ img = img.convert("RGB")
+
+ # Save the converted image to JPEG format
+ jpeg_image = io.BytesIO()
+ img.save(jpeg_image, format="JPEG", quality=quality)
+ jpeg_image.seek(0) # Move to the beginning of the byte stream
+
+ return jpeg_image.getvalue()
+
+
+def resize_image(
+ image: bytes,
+ min_width: int | None = None,
+ max_width: int | None = None,
+ min_aspect_ratio: float | None = None,
+ max_aspect_ratio: float | None = None,
+):
+ """
+ Resizes and crops an image to meet specified width and aspect ratio constraints.
+
+ Args:
+ image (bytes): Original image bytes.
+ min_width (int | None): Minimum width for the resized image.
+ max_width (int | None): Maximum width for the resized image.
+ min_aspect_ratio (float | None): Minimum aspect ratio (width/height).
+ max_aspect_ratio (float | None): Maximum aspect ratio (width/height).
+
+ Returns:
+ bytes: Resized and cropped image bytes in the original format.
+ """
+ with Image.open(io.BytesIO(image)) as img:
+ original_format = img.format
+ width, height = img.size
+ aspect_ratio = width / height
+ logger.info(f"Initial img size: {img.size}, asp.ratio: {aspect_ratio}")
+
+ # Step 1: Adjust width to fit within min and max constraints
+ if min_width is not None and width < min_width:
+ new_width = min_width
+ new_height = int(new_width / aspect_ratio)
+ img = img.resize((new_width, new_height), Image.Resampling.LANCZOS)
+ logger.info(f"Resized to min width {min_width}: {img.size}")
+ elif max_width is not None and width > max_width:
+ new_width = max_width
+ new_height = int(new_width / aspect_ratio)
+ img = img.resize((new_width, new_height), Image.Resampling.LANCZOS)
+ logger.info(f"Resized to max width {max_width}: {img.size}")
+
+ # Step 2: Crop to fit aspect ratio constraints if necessary
+ width, height = img.size # Updated dimensions
+ aspect_ratio = width / height
+ if min_aspect_ratio is not None and aspect_ratio < min_aspect_ratio:
+ # Crop height if too tall
+ target_height = round(width / min_aspect_ratio)
+ crop_margin = round((height - target_height) / 2)
+ img = img.crop((0, crop_margin, width, height - crop_margin))
+ logger.info(
+ f"Cropped height to fit min aspect ratio "
+ f"{min_aspect_ratio}: {img.size}"
+ )
+ elif max_aspect_ratio is not None and aspect_ratio > max_aspect_ratio:
+ # Crop width if too wide
+ target_width = round(height * max_aspect_ratio)
+ crop_margin = round((width - target_width) / 2)
+ img = img.crop((crop_margin, 0, width - crop_margin, height))
+ logger.info(
+ f"Cropped width to fit max aspect ratio "
+ f"{max_aspect_ratio}: {img.size}"
+ )
+
+ # Save the modified image in the original format
+ resized_image = io.BytesIO()
+ if original_format:
+ img.save(resized_image, format=original_format)
+ else:
+ img.save(resized_image)
+
+ resized_image.seek(0)
+
+ logger.info(
+ f"Final image size: {img.size}, aspect ratio: {width / height}"
+ )
+ return resized_image.getvalue()
diff --git a/bc/core/utils/s3.py b/bc/core/utils/s3.py
new file mode 100644
index 00000000..324f76a4
--- /dev/null
+++ b/bc/core/utils/s3.py
@@ -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}"
diff --git a/bc/core/utils/status/base.py b/bc/core/utils/status/base.py
index bacce0e9..843067e9 100644
--- a/bc/core/utils/status/base.py
+++ b/bc/core/utils/status/base.py
@@ -222,6 +222,19 @@ def _check_output_validity(self, text: str) -> bool:
)
+@dataclass
+class ThreadsTemplate(BaseTemplate):
+ max_characters: int = 300
+
+ def __len__(self) -> int:
+ """This method overrides `Template.__len__`.
+
+ All links (URLs) longer than 24 chars long (without `https://www.`)
+ posted in Threads are truncated, so they can take up to 23 characters.
+ """
+ return 24 * len(self.link_placeholders) + self.count_fixed_characters()
+
+
def _get_node_list_fields(nodelist: NodeList) -> list[str]:
fields: list[str] = []
for node in nodelist:
diff --git a/bc/core/utils/status/selectors.py b/bc/core/utils/status/selectors.py
index df72e09f..a2392af3 100644
--- a/bc/core/utils/status/selectors.py
+++ b/bc/core/utils/status/selectors.py
@@ -8,18 +8,22 @@
MASTODON_FOLLOW_A_NEW_CASE,
MASTODON_MINUTE_TEMPLATE,
MASTODON_POST_TEMPLATE,
+ THREADS_FOLLOW_A_NEW_CASE,
+ THREADS_MINUTE_TEMPLATE,
+ THREADS_POST_TEMPLATE,
TWITTER_FOLLOW_A_NEW_CASE,
TWITTER_MINUTE_TEMPLATE,
TWITTER_POST_TEMPLATE,
BlueskyTemplate,
MastodonTemplate,
+ ThreadsTemplate,
TwitterTemplate,
)
def get_template_for_channel(
service: int, document_number: int | None
-) -> TwitterTemplate | MastodonTemplate | BlueskyTemplate:
+) -> TwitterTemplate | MastodonTemplate | BlueskyTemplate | ThreadsTemplate:
"""Returns a template object that uses the data of a webhook to
create a new status update in the given service. This method
checks the document number to pick one of the templates available.
@@ -30,8 +34,8 @@ def get_template_for_channel(
event.
Returns:
- TwitterTemplate | MastodonTemplate | BlueskyTemplate: template object to create
- a new post.
+ TwitterTemplate | MastodonTemplate | BlueskyTemplate | ThreadsTemplate:
+ template object to create a new post.
"""
match service:
case Channel.TWITTER:
@@ -52,6 +56,12 @@ def get_template_for_channel(
if document_number
else BLUESKY_MINUTE_TEMPLATE
)
+ case Channel.THREADS:
+ return (
+ THREADS_POST_TEMPLATE
+ if document_number
+ else THREADS_MINUTE_TEMPLATE
+ )
case _:
raise NotImplementedError(
f"No wrapper implemented for service: '{service}'."
@@ -74,6 +84,7 @@ def get_new_case_template(service: int) -> BaseTemplate:
Channel.BLUESKY: BLUESKY_FOLLOW_A_NEW_CASE,
Channel.MASTODON: MASTODON_FOLLOW_A_NEW_CASE,
Channel.TWITTER: TWITTER_FOLLOW_A_NEW_CASE,
+ Channel.THREADS: THREADS_FOLLOW_A_NEW_CASE,
}
if service in new_case_templates:
diff --git a/bc/core/utils/status/templates.py b/bc/core/utils/status/templates.py
index 63d170d7..f02f772b 100644
--- a/bc/core/utils/status/templates.py
+++ b/bc/core/utils/status/templates.py
@@ -1,6 +1,11 @@
import re
-from .base import BlueskyTemplate, MastodonTemplate, TwitterTemplate
+from .base import (
+ BlueskyTemplate,
+ MastodonTemplate,
+ ThreadsTemplate,
+ TwitterTemplate,
+)
DO_NOT_POST = re.compile(
r"""(
@@ -124,3 +129,38 @@
#CL{docket_id}""",
)
+
+THREADS_POST_TEMPLATE = ThreadsTemplate(
+ link_placeholders=["pdf_link", "docket_link"],
+ str_template="""New filing: "{docket}"
+Doc #{doc_num}: {description}
+
+PDF: {pdf_link}
+Docket: {docket_link}
+
+#CL{docket_id}""",
+)
+
+THREADS_MINUTE_TEMPLATE = ThreadsTemplate(
+ link_placeholders=["docket_link"],
+ str_template="""New minute entry in {docket}: {description}
+
+Docket: {docket_link}
+
+#CL{docket_id}""",
+)
+
+THREADS_FOLLOW_A_NEW_CASE = ThreadsTemplate(
+ link_placeholders=["docket_link", "initial_complaint_link", "article_url"],
+ str_template="""I'm now following {{docket}}:{% if date_filed %}
+
+Filed: {{date_filed}}{% endif %}
+
+Docket: {{docket_link}}{% if initial_complaint_type and initial_complaint_link %}
+
+{{initial_complaint_type}}: {{initial_complaint_link}}{% endif %}{% if article_url %}
+
+Context: {{article_url}}{% endif %}
+
+#CL{{docket_id}}""",
+)
diff --git a/bc/settings/__init__.py b/bc/settings/__init__.py
index 63942a0d..469ceee1 100644
--- a/bc/settings/__init__.py
+++ b/bc/settings/__init__.py
@@ -11,4 +11,5 @@
from .third_party.redis import *
from .third_party.rq import *
from .third_party.sentry import *
+from .third_party.threads import *
from .third_party.twitter import *
diff --git a/bc/settings/misc.py b/bc/settings/misc.py
index 6fb318a9..fbfa2734 100644
--- a/bc/settings/misc.py
+++ b/bc/settings/misc.py
@@ -5,4 +5,4 @@
# Numbers of seconds the app should wait to process a webhook
WEBHOOK_DELAY_TIME = env.int("WEBHOOK_DELAY_TIME", default=120)
-DOCTOR_HOST = env("DOCTOR_HOST", default="http://bc-doctor:5050")
+DOCTOR_HOST = env("DOCTOR_HOST", default="http://bc2-doctor:5050")
diff --git a/bc/settings/third_party/aws.py b/bc/settings/third_party/aws.py
index 26d86d21..f2144da3 100644
--- a/bc/settings/third_party/aws.py
+++ b/bc/settings/third_party/aws.py
@@ -7,6 +7,7 @@
if DEVELOPMENT:
AWS_ACCESS_KEY_ID = env("AWS_DEV_ACCESS_KEY_ID", default="")
AWS_SECRET_ACCESS_KEY = env("AWS_DEV_SECRET_ACCESS_KEY", default="")
+ AWS_SESSION_TOKEN = env("AWS_DEV_SESSION_TOKEN", default="")
else:
AWS_ACCESS_KEY_ID = env("AWS_ACCESS_KEY_ID", default="")
AWS_SECRET_ACCESS_KEY = env("AWS_SECRET_ACCESS_KEY", default="")
diff --git a/bc/settings/third_party/threads.py b/bc/settings/third_party/threads.py
new file mode 100644
index 00000000..8e9eabb5
--- /dev/null
+++ b/bc/settings/third_party/threads.py
@@ -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="")
diff --git a/scripts/get-threads-keys.py b/scripts/get-threads-keys.py
new file mode 100644
index 00000000..d8587856
--- /dev/null
+++ b/scripts/get-threads-keys.py
@@ -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()