Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat(channels): Add support for Threads #618

Merged
merged 14 commits into from
Nov 5, 2024
Merged
Show file tree
Hide file tree
Changes from 8 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 7 additions & 3 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -47,8 +47,6 @@ SENTRY_REPORT_URI=""
# twitter.py
TWITTER_CONSUMER_KEY=""
TWITTER_CONSUMER_SECRET=""
TWITTER_ACCESS_TOKEN=""
TWITTER_ACCESS_TOKEN_SECRET=""
Comment on lines -50 to -51
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

These were removed because they weren't used anymore, as discussed with @ERosendo

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

You're right. We should definitely clean up the settings module as well.


# django.py
HOSTNAME=""
Expand All @@ -62,5 +60,11 @@ 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=""
THREADS_BUCKET_NAME=""
THREADS_BUCKET_ZONE=""
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_id}"
elisa-a-v marked this conversation as resolved.
Show resolved Hide resolved
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
128 changes: 128 additions & 0 deletions bc/channel/utils/connectors/threads.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,128 @@
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)

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