Skip to content

Commit

Permalink
Merge pull request #618 from freelawproject/threads-support
Browse files Browse the repository at this point in the history
feat(channels): Add support for Threads
mlissner authored Nov 5, 2024

Verified

This commit was created on GitHub.com and signed with GitHub’s verified signature.
2 parents ab33aae + 74f9933 commit 3f7e3bb
Showing 23 changed files with 722 additions and 13 deletions.
9 changes: 6 additions & 3 deletions .env.example
Original file line number Diff line number Diff line change
@@ -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=""
2 changes: 2 additions & 0 deletions bc/assets/templates/includes/follow-button.html
Original file line number Diff line number Diff line change
@@ -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 %}
</div>
<div class="ml-3">
1 change: 1 addition & 0 deletions bc/assets/templates/includes/inlines/threads.svg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
25 changes: 25 additions & 0 deletions bc/channel/migrations/0009_alter_channel_service.py
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",
),
),
]
9 changes: 9 additions & 0 deletions bc/channel/models.py
Original file line number Diff line number Diff line change
@@ -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}"
13 changes: 13 additions & 0 deletions bc/channel/templates/threads_code.html
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 %}
6 changes: 6 additions & 0 deletions bc/channel/urls.py
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",
),
]
5 changes: 3 additions & 2 deletions bc/channel/utils/connectors/base.py
Original file line number Diff line number Diff line change
@@ -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.
5 changes: 2 additions & 3 deletions bc/channel/utils/connectors/bluesky.py
Original file line number Diff line number Diff line change
@@ -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(
134 changes: 134 additions & 0 deletions bc/channel/utils/connectors/threads.py
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.
205 changes: 205 additions & 0 deletions bc/channel/utils/connectors/threads_api/client.py
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
8 changes: 8 additions & 0 deletions bc/channel/views.py
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})
97 changes: 97 additions & 0 deletions bc/core/utils/images.py
Original file line number Diff line number Diff line change
@@ -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()
35 changes: 35 additions & 0 deletions bc/core/utils/s3.py
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}"
13 changes: 13 additions & 0 deletions bc/core/utils/status/base.py
Original file line number Diff line number Diff line change
@@ -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:
17 changes: 14 additions & 3 deletions bc/core/utils/status/selectors.py
Original file line number Diff line number Diff line change
@@ -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:
42 changes: 41 additions & 1 deletion bc/core/utils/status/templates.py
Original file line number Diff line number Diff line change
@@ -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}}""",
)
1 change: 1 addition & 0 deletions bc/settings/__init__.py
Original file line number Diff line number Diff line change
@@ -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 *
2 changes: 1 addition & 1 deletion bc/settings/misc.py
Original file line number Diff line number Diff line change
@@ -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")
1 change: 1 addition & 0 deletions bc/settings/third_party/aws.py
Original file line number Diff line number Diff line change
@@ -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="")
7 changes: 7 additions & 0 deletions bc/settings/third_party/threads.py
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="")
98 changes: 98 additions & 0 deletions scripts/get-threads-keys.py
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()

0 comments on commit 3f7e3bb

Please sign in to comment.