Skip to content

Commit f290204

Browse files
Integrate Bluesky as alternative to Twitterbot (#2051)
--------- Co-authored-by: Gresille & Siffle <39056254+GresilleSiffle@users.noreply.github.com>
1 parent e5c4a23 commit f290204

21 files changed

+431
-194
lines changed

backend/requirements.txt

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -60,4 +60,5 @@ requests==2.32.2
6060
tweepy==4.14.0
6161
# dotenv for .env file
6262
python-dotenv==1.0.0
63-
63+
# AT protocol library for Bluesky
64+
atproto==0.0.58

backend/twitterbot/admin.py

Lines changed: 4 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,7 @@
88
from django.contrib import admin
99
from django.utils.html import format_html
1010

11-
from .models.tweeted import TweetInfo
11+
from .models.history import TweetInfo
1212

1313

1414
@admin.register(TweetInfo)
@@ -32,13 +32,12 @@ class TwitterBotAdmin(admin.ModelAdmin):
3232
list_filter = ["bot_name"]
3333

3434
@staticmethod
35-
@admin.display(description="URL of the tweet")
35+
@admin.display(description="Post URL")
3636
def get_twitter_link(obj):
3737
"""Return the URI of the tweet."""
3838
return format_html(
39-
'<a href="https://twitter.com/{}/status/{}" target="_blank">Tweet</a>',
40-
obj.bot_name,
41-
obj.tweet_id,
39+
'<a href="{}" target="_blank">Link</a>',
40+
obj.message_url,
4241
)
4342

4443
@staticmethod

backend/twitterbot/client.py

Lines changed: 113 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,113 @@
1+
from functools import cached_property
2+
from pathlib import Path
3+
from typing import List, Optional
4+
5+
import requests
6+
from django.conf import settings
7+
8+
from tournesol.models import Entity
9+
10+
11+
class TournesolBotClient:
12+
def __init__(self, account):
13+
14+
credentials = settings.TWITTERBOT_CREDENTIALS
15+
if account not in credentials:
16+
raise ValueError(f"No credentials found for {account} account!")
17+
18+
self.account_cred = credentials[account]
19+
self.language = self.account_cred["LANGUAGE"]
20+
21+
@cached_property
22+
def tweepy_api(self):
23+
# Client for Twitter API v1.1
24+
# We need this authentication also because to add media it only works with api v1.1
25+
26+
import tweepy # pylint:disable=import-outside-toplevel
27+
auth = tweepy.OAuth1UserHandler(
28+
consumer_key=self.account_cred["CONSUMER_KEY"],
29+
consumer_secret=self.account_cred["CONSUMER_SECRET"],
30+
access_token=self.account_cred["ACCESS_TOKEN"],
31+
access_token_secret=self.account_cred["ACCESS_TOKEN_SECRET"],
32+
)
33+
return tweepy.API(auth)
34+
35+
@cached_property
36+
def tweepy_client(self):
37+
# Client for Twitter API v2
38+
39+
import tweepy # pylint:disable=import-outside-toplevel
40+
return tweepy.Client(
41+
consumer_key=self.account_cred["CONSUMER_KEY"],
42+
consumer_secret=self.account_cred["CONSUMER_SECRET"],
43+
access_token=self.account_cred["ACCESS_TOKEN"],
44+
access_token_secret=self.account_cred["ACCESS_TOKEN_SECRET"],
45+
)
46+
47+
@property
48+
def bluesky_handle(self):
49+
return self.account_cred["ATPROTO_HANDLE"]
50+
51+
@cached_property
52+
def atproto_client(self):
53+
from atproto import Client # pylint:disable=import-outside-toplevel
54+
client = Client()
55+
client.login(
56+
self.account_cred["ATPROTO_HANDLE"],
57+
self.account_cred["ATPROTO_PASSWORD"],
58+
)
59+
return client
60+
61+
def create_tweet(self, text: str, media_files: Optional[List[Path]] = None):
62+
if media_files is None:
63+
media_ids = []
64+
else:
65+
medias = (self.tweepy_api.media_upload(filepath) for filepath in media_files)
66+
media_ids = [media.media_id for media in medias]
67+
68+
resp = self.tweepy_client.create_tweet(
69+
text=text,
70+
media_ids=media_ids,
71+
)
72+
return resp.data["id"]
73+
74+
def create_bluesky_post(
75+
self,
76+
text,
77+
embed_video: Optional[Entity] = None,
78+
image_files: Optional[List[Path]] = None,
79+
image_alts: Optional[List[str]] = None,
80+
):
81+
from atproto import models # pylint:disable=import-outside-toplevel
82+
if image_files is None:
83+
if embed_video is None:
84+
embed = None
85+
else:
86+
preview_response = requests.get(
87+
f"https://api.tournesol.app/preview/entities/{embed_video.uid}",
88+
timeout=10,
89+
)
90+
preview_response.raise_for_status()
91+
img_data = preview_response.content
92+
thumb_blob = self.atproto_client.upload_blob(img_data).blob
93+
embed = models.AppBskyEmbedExternal.Main(
94+
external=models.AppBskyEmbedExternal.External(
95+
title=embed_video.metadata.get("name", ""),
96+
description=embed_video.metadata.get("uploader", ""),
97+
uri=f"https://tournesol.app/entities/{embed_video.uid}",
98+
thumb=thumb_blob,
99+
)
100+
)
101+
resp = self.atproto_client.send_post(
102+
text=text,
103+
embed=embed,
104+
langs=[self.language],
105+
)
106+
else:
107+
resp = self.atproto_client.send_images(
108+
text=text,
109+
langs=[self.language],
110+
images=[p.read_bytes() for p in image_files],
111+
image_alts=image_alts,
112+
)
113+
return resp.uri

backend/twitterbot/management/commands/load_tweetinfo.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@
44

55
from tournesol.entities.video import YOUTUBE_UID_NAMESPACE
66
from tournesol.models.entity import Entity
7-
from twitterbot.models.tweeted import TweetInfo
7+
from twitterbot.models.history import TweetInfo
88

99

1010
class Command(BaseCommand):

backend/twitterbot/management/commands/run_twitterbot.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -52,4 +52,4 @@ def handle(self, *args, **options):
5252

5353
return
5454

55-
tweet_video_recommendation(bot_name, assumeyes=options["assumeyes"])
55+
tweet_video_recommendation(bot_name, assumeyes=options["assumeyes"], dest=["bluesky"])

backend/twitterbot/management/commands/tweet_top_contributors.py

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -26,5 +26,8 @@ def add_arguments(self, parser):
2626
)
2727

2828
def handle(self, *args, **options):
29-
30-
tweet_top_contributor_graph(options["bot_name"], assumeyes=options["assumeyes"])
29+
tweet_top_contributor_graph(
30+
options["bot_name"],
31+
assumeyes=options["assumeyes"],
32+
dest=["bluesky"],
33+
)
Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,30 @@
1+
# Generated by Django 4.2.11 on 2025-01-27 22:37
2+
3+
from django.db import migrations, models
4+
5+
6+
class Migration(migrations.Migration):
7+
8+
dependencies = [
9+
("twitterbot", "0002_rename_tweetedvideo_tweetinfo"),
10+
]
11+
12+
operations = [
13+
migrations.AddField(
14+
model_name="tweetinfo",
15+
name="atproto_uri",
16+
field=models.CharField(
17+
default=None,
18+
help_text="URI of the post on the AT protocol network",
19+
max_length=255,
20+
null=True,
21+
),
22+
),
23+
migrations.AlterField(
24+
model_name="tweetinfo",
25+
name="tweet_id",
26+
field=models.CharField(
27+
default=None, help_text="Tweet ID from Twitter URL", max_length=22, null=True
28+
),
29+
),
30+
]
Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,46 @@
1+
# Generated by Django 4.2.18 on 2025-02-06 14:55
2+
3+
from django.db import migrations, models
4+
import django.db.models.deletion
5+
6+
7+
class Migration(migrations.Migration):
8+
9+
dependencies = [
10+
("tournesol", "0061_comparisoncriteriascore_score_max_and_more"),
11+
("twitterbot", "0003_tweetinfo_atproto_uri_alter_tweetinfo_tweet_id"),
12+
]
13+
14+
operations = [
15+
migrations.AlterField(
16+
model_name="tweetinfo",
17+
name="bot_name",
18+
field=models.CharField(
19+
blank=True,
20+
choices=[
21+
("@TournesolBot", "@TournesolBot"),
22+
("@TournesolBotFR", "@TournesolBotFR"),
23+
],
24+
help_text="Name of the bot",
25+
max_length=200,
26+
null=True,
27+
),
28+
),
29+
migrations.AlterField(
30+
model_name="tweetinfo",
31+
name="datetime_tweet",
32+
field=models.DateTimeField(
33+
auto_now_add=True, help_text="Time when the video was posted", null=True
34+
),
35+
),
36+
migrations.AlterField(
37+
model_name="tweetinfo",
38+
name="video",
39+
field=models.ForeignKey(
40+
help_text="Posted video",
41+
on_delete=django.db.models.deletion.CASCADE,
42+
related_name="tweets",
43+
to="tournesol.entity",
44+
),
45+
),
46+
]
Lines changed: 81 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,81 @@
1+
"""
2+
Models related to the Twitter bot.
3+
"""
4+
import re
5+
6+
from django.db import models
7+
8+
from tournesol.models import Entity
9+
10+
BOT_NAME = [
11+
("@TournesolBot", "@TournesolBot"),
12+
("@TournesolBotFR", "@TournesolBotFR"),
13+
]
14+
15+
16+
class TweetInfo(models.Model):
17+
"""One tweeted video."""
18+
19+
video = models.ForeignKey(
20+
Entity,
21+
on_delete=models.CASCADE,
22+
related_name="tweets",
23+
help_text="Posted video",
24+
)
25+
26+
tweet_id = models.CharField(
27+
null=True,
28+
default=None,
29+
max_length=22,
30+
help_text="Tweet ID from Twitter URL",
31+
)
32+
33+
atproto_uri = models.CharField(
34+
null=True,
35+
default=None,
36+
max_length=255,
37+
help_text="URI of the post on the AT protocol network",
38+
)
39+
40+
datetime_tweet = models.DateTimeField(
41+
auto_now_add=True,
42+
help_text="Time when the video was posted",
43+
null=True,
44+
blank=True,
45+
)
46+
47+
bot_name = models.CharField(
48+
null=True,
49+
blank=True,
50+
max_length=200,
51+
help_text="Name of the bot",
52+
choices=BOT_NAME,
53+
)
54+
55+
def __str__(self):
56+
return f"{self.video.uid} posted at {self.datetime_tweet}"
57+
58+
@property
59+
def tweet_url(self):
60+
if not self.tweet_id:
61+
return None
62+
return f"https://twitter.com/{self.bot_name}/status/{self.tweet_id}"
63+
64+
@property
65+
def bluesky_url(self):
66+
if not self.atproto_uri:
67+
return None
68+
match = re.match(
69+
r"at://(?P<authority>.+)/(?P<collection>.+)/(?P<key>.+)",
70+
self.atproto_uri,
71+
)
72+
if not match or match.group("collection") != "app.bsky.feed.post":
73+
return None
74+
return f"https://bsky.app/profile/{match.group('authority')}/post/{match.group('key')}"
75+
76+
@property
77+
def message_url(self):
78+
bluesky_url = self.bluesky_url
79+
if bluesky_url:
80+
return self.bluesky_url
81+
return self.tweet_url

backend/twitterbot/models/tweeted.py

Lines changed: 0 additions & 47 deletions
This file was deleted.

0 commit comments

Comments
 (0)