Skip to content

Commit

Permalink
Bluesky integration (#32)
Browse files Browse the repository at this point in the history
* bluesky wip

* Add bluesky download video support

* Update readme

* lint

* pylint
  • Loading branch information
amadejkastelic authored Dec 8, 2024
1 parent 7246aa9 commit a19d18b
Show file tree
Hide file tree
Showing 12 changed files with 481 additions and 75 deletions.
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ A Discord bot that automatically embeds media and metadata of messages containin
- 24ur.com ✅
- 4chan ✅
- Linkedin ✅
- Bluesky ✅

## How to run
- Build the docker image: `docker build . -t video-embed-bot` or simply pull it from ghcr:
Expand Down
2 changes: 1 addition & 1 deletion bot/adapters/discord/client.py
Original file line number Diff line number Diff line change
Expand Up @@ -125,7 +125,7 @@ async def _handle_message(self, message: discord.Message, user: discord.User):

if service.should_handle_url(url) is False:
logger.debug(
'Handling for URL not implemented',
'Handling for URL not enabled',
url=url,
server_uid=str(message.guild.id),
server_vendor=self.VENDOR.value,
Expand Down
43 changes: 43 additions & 0 deletions bot/common/m3u8.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
import io
import logging
import sys
import time
import typing

from api_24ur import downloader
from wells import utils as wells_utils

get_url_content = downloader.m3u8.get_url_content
wells_utils.logger.addHandler(logging.NullHandler())
wells_utils.logger.propagate = False


async def download_stream( # pylint: disable=too-many-positional-arguments
stream_url: str,
prefix: typing.Optional[str] = None,
tmp_dir: str = '/tmp',
pool_size: int = 5,
max_bitrate: int = sys.maxsize,
sleep: float = 0.1,
) -> io.BytesIO:
if prefix:
# monkeypatch
def _get_url_content(url):
if not url.startswith('http'):
url = prefix + url
time.sleep(sleep)
return get_url_content(url)

downloader.m3u8.get_url_content = _get_url_content

result = await downloader.Downloader(
url=stream_url,
download_path=tmp_dir,
tmp_dir=tmp_dir,
pool_size=pool_size,
max_bitrate=max_bitrate,
).download_bytes()

downloader.m3u8.get_url_content = get_url_content

return result
1 change: 1 addition & 0 deletions bot/constants.py
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,7 @@ class ServerStatus(enum.Enum):


class Integration(enum.Enum):
BLUESKY = 'bluesky'
INSTAGRAM = 'instagram'
FACEBOOK = 'facebook'
LINKEDIN = 'linkedin'
Expand Down
8 changes: 8 additions & 0 deletions bot/domain/post_format.py
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,13 @@
📕 Description: {description}\n
"""

BLUESKY_POST_FORMAT = """🔗 URL: {url}
🧑🏻‍🎨 Author: {author}
📅 Created: {created}
👍🏻 Likes: {likes}
📕 Description: {description}\n
"""

FOUR_CHAN_POST_FORMAT = """🔗 URL: {url}
🧑🏻‍🎨 Author: {author}
📅 Created: {created}
Expand Down Expand Up @@ -81,6 +88,7 @@


DEFAULT_INTEGRATION_POST_FMT_MAPPING = {
constants.Integration.BLUESKY: BLUESKY_POST_FORMAT,
constants.Integration.FACEBOOK: DEFAULT_POST_FORMAT,
constants.Integration.FOUR_CHAN: FOUR_CHAN_POST_FORMAT,
constants.Integration.INSTAGRAM: INSTAGRAM_POST_FORMAT,
Expand Down
Empty file.
121 changes: 121 additions & 0 deletions bot/integrations/bluesky/client.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,121 @@
import datetime
import typing
from urllib.parse import urlparse

import atproto
from atproto import models as atproto_models
from django.conf import settings

from bot import constants
from bot import domain
from bot import exceptions
from bot import logger
from bot.common import m3u8
from bot.common import utils
from bot.integrations import base
from bot.integrations.bluesky import config


class BlueskyClientSingleton(base.BaseClientSingleton):
DOMAINS = ['bsky.app']
_CONFIG_CLASS = config.BlueskyConfig

@classmethod
def should_handle(cls, url: str) -> bool:
return super().should_handle(url) and '/post/' in url

@classmethod
def _create_instance(cls) -> None:
conf: config.BlueskyConfig = cls._load_config(conf=settings.INTEGRATION_CONFIGURATION.get('bluesky', {}))

if not conf.enabled:
logger.info('Bluesky integration not enabled')
cls._INSTANCE = base.MISSING
return

if not conf.username or not conf.password:
logger.warning('Missing bluesky username or password')
cls._INSTANCE = base.MISSING
return

if conf.base_url:
cls.DOMAINS = [conf.base_url]

cls._INSTANCE = BlueskyClient(
username=conf.username,
password=conf.password,
base_url=conf.base_url,
)


class BlueskyClient(base.BaseClient):
INTEGRATION = constants.Integration.BLUESKY

def __init__(self, username: str, password: str, base_url: typing.Optional[str] = None):
super().__init__()
self.client = atproto.AsyncClient(base_url=base_url)
self.logged_in = False
self.username = username
self.password = password

async def _login(self):
if self.logged_in:
return

profile = await self.client.login(self.username, self.password)
self.logged_in = True
logger.info('Logged in', integration=self.INTEGRATION.value, display_name=profile.display_name)

async def get_integration_data(self, url: str) -> typing.Tuple[constants.Integration, str, typing.Optional[int]]:
return self.INTEGRATION, url.strip('/').split('?')[0].split('/')[-1], None

@classmethod
def _url_to_uri(cls, url: str) -> atproto.AtUri:
parsed_url = urlparse(url)

parts = parsed_url.path.strip('/').split('/')
if len(parts) != 4 or parts[0] != 'profile' or parts[2] != 'post':
logger.error('Failed to parse bluesky url', url=url)
return ValueError('Invalid Bluesky post URL format')

did, rkey = parts[1], parts[3]
return atproto.AtUri.from_str(f'at://{did}/app.bsky.feed.post/{rkey}')

async def get_post(self, url: str) -> domain.Post:
uri = self._url_to_uri(url)

await self._login()

thread = (await self.client.get_post_thread(uri.http)).thread
if getattr(thread, 'not_found', False) or getattr(thread, 'blocked', False) or not thread.post:
logger.error(
'Post not found or blocked',
url=url,
uri=uri.http,
not_found=getattr(thread, 'not_found', False),
blocked=getattr(thread, 'blocked', False),
)
raise exceptions.IntegrationClientError('Failed to get post')

post = domain.Post(
url=url,
author=thread.post.author.display_name,
description=thread.post.record.text,
created=datetime.datetime.fromisoformat(thread.post.record.created_at),
likes=thread.post.like_count,
)

if thread.post.embed:
logger.debug('Got bluesky media post', py_type=thread.post.embed.py_type)
if atproto_models.ids.AppBskyEmbedImages in thread.post.embed.py_type:
post.buffer = utils.combine_images(
[await self._download(img.fullsize or img.thumb) for img in thread.post.embed.images[:3]]
)
elif atproto_models.ids.AppBskyEmbedVideo in thread.post.embed.py_type:
stream_url = thread.post.embed.playlist or thread.post.embed.alt
post.buffer = await m3u8.download_stream(
stream_url=stream_url,
prefix=stream_url[: stream_url.find('playlist.m3u8')],
)

return post
9 changes: 9 additions & 0 deletions bot/integrations/bluesky/config.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
import typing

from bot.integrations import base


class BlueskyConfig(base.BaseClientConfig):
username: typing.Optional[str] = None
password: typing.Optional[str] = None
base_url: typing.Optional[str] = None
2 changes: 2 additions & 0 deletions bot/integrations/registry.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import typing

from bot.integrations import base
from bot.integrations.bluesky import client as bluesky_client
from bot.integrations.facebook import client as facebook_client
from bot.integrations.four_chan import client as four_chan_client
from bot.integrations.instagram import singleton as instagram_client
Expand All @@ -15,6 +16,7 @@


CLASSES: typing.Set[typing.Type[base.BaseClientSingleton]] = {
bluesky_client.BlueskyClientSingleton,
facebook_client.FacebookClientSingleton,
four_chan_client.FourChanClientSingleton,
instagram_client.InstagramClientSingleton,
Expand Down
6 changes: 6 additions & 0 deletions conf/settings_base.py
Original file line number Diff line number Diff line change
Expand Up @@ -272,4 +272,10 @@
'4chan': {
'enabled': False,
},
'bluesky': {
'enabled': False,
'base_url': None, # If you want some other instance
'username': None,
'password': None,
},
}
Loading

0 comments on commit a19d18b

Please sign in to comment.