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
  • Loading branch information
mlissner authored Nov 5, 2024
2 parents ab33aae + 74f9933 commit 3f7e3bb
Show file tree
Hide file tree
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
Expand Up @@ -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=""
Expand Down Expand Up @@ -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=""
Expand All @@ -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
Expand Up @@ -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">
Expand Down
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
Expand Up @@ -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


Expand Down Expand Up @@ -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",
Expand Down Expand Up @@ -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}'."
Expand All @@ -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}"
Expand Down
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
Expand Up @@ -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):
Expand All @@ -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.
Expand Down
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

Expand All @@ -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(
Expand Down
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.
Loading

0 comments on commit 3f7e3bb

Please sign in to comment.