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:

+
+

{{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()