Skip to content

Commit

Permalink
Added BSky - Closes #30, #31.
Browse files Browse the repository at this point in the history
  • Loading branch information
soup-bowl committed Jul 29, 2024
1 parent 9b5fca4 commit 9ed568b
Show file tree
Hide file tree
Showing 9 changed files with 184 additions and 10 deletions.
4 changes: 2 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@
<img src="https://user-images.githubusercontent.com/11209477/214368280-532459b4-eb5d-46f7-82cd-2913d4da1633.png" alt="A view of a Mastodon post showing a 5-picture collage, 1 larger image on the left and 4 small images in a grid orientation"/>
</p>

An experimental bot that posts a rundown of your musical week on Mastodon.
An experimental bot that posts a rundown of your musical week on Mastodon and/or Bluesky.

## 🤔 What does this do?

Expand All @@ -23,7 +23,7 @@ This clever bot does the following:
* API kindly hands over the info (or gives us a whack of the handbag if we have no API key).
* We sneakily scrape the last.fm website for the artist pictures 🤫 (better solutions welcome).
* We do some arts and crafts wizardary 🪄 to formulate a collage picture.
* Lastly, the app phones up Mastodon 📞, asks how their turtle is hanging 🐢, and posts the info and picture.
* Lastly, the app phones up Mastodon/Bluesky 📞, asks how their turtle is hanging 🐢, and posts the info and picture.

⭐ Collage is made using the power of Python using [Pillow][p-pillow] for image manipulation, [Mastodon][p-mstdn] and [urllib3][p-urllib3] for API communication, and [lxml][p-lxml] for scraping the internet.

Expand Down
6 changes: 6 additions & 0 deletions config.json.example
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,12 @@
"lastfmUsername": "example",
"mastodonUsername": "example@mastodon.social",
"mastodonAccessToken": "key"
},
{
"lastfmUsername": "example",
"BlueskyInstance": "https://bsky.social",
"BlueskyHandle": "example-user",
"BlueskyPassword": "bluesky-app-password"
}
],
"config": {
Expand Down
31 changes: 26 additions & 5 deletions htw/cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,10 +9,11 @@
from os.path import realpath, exists
from pathlib import Path

from htw.lfm import LFM, LFMPeriod
from htw.collage import Collage
from htw.compose import compose_tweet
from htw.mastodon import Mastodon
from htw.providers.lfm import LFM, LFMPeriod
from htw.providers.mastodon import Mastodon
from htw.providers.bluesky import Bluesky

class CLI():
"""Command line handler for the Hot-this-Week toolset.
Expand Down Expand Up @@ -135,8 +136,8 @@ def process_user(self, user_conf):
pic = colgen.new(artists, self.keep_pic)

if not self.suppress:
print("- Composing tweet...")
tweet = compose_tweet(artists, user_conf['lastfmUsername'])
print("- Composing message...")
tweet = compose_tweet(artists, user_conf['lastfmUsername'], self.lfm_period)

if self.display_only:
print(tweet)
Expand All @@ -145,6 +146,26 @@ def process_user(self, user_conf):
colgen.cleanup()
return True

if 'BlueskyHandle' in user_conf:
if not self.suppress:
print("- Posting to \033[96mBluesky\033[00m...")

try:
bsky = Bluesky(
url=user_conf['BlueskyInstance'],
handle=user_conf['BlueskyHandle'],
application_password=user_conf['BlueskyPassword']
)

bsky.post(tweet, pic)
except Exception as error:
print("\033[91mError\033[00m: " + str(error) + ".")
return False
finally:
colgen.cleanup()

sys.exit(443)

if 'mastodonAccessToken' in user_conf:
if not self.suppress:
print("- Posting to \033[95mMastodon\033[00m...")
Expand Down Expand Up @@ -180,7 +201,7 @@ def read_config(self, location):

if 'lastfmKey' in conf['config']:
self.lastfm_key = conf['config']['lastfmKey'] if self.lastfm_key is None else self.lastfm_key

if 'mastodonURL' in conf['config']:
self.mastodon_url = conf['config']['mastodonURL'] if self.mastodon_url is None else self.mastodon_url
if 'mastodonKey' in conf['config']:
Expand Down
33 changes: 31 additions & 2 deletions htw/compose.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,18 +2,47 @@
Contains the communication class for composing messages.
"""

def compose_tweet(lfm_collection, name):
from htw.providers.lfm import LFMPeriod

def compose_tweet(lfm_collection, name, period):
"""Compose tweet message contents.
Args:
lfm_collection ([type]): Last.fm user data collection.
name (str): Last.fm username.
period (LFMPeriod): The period to be displayed.
Returns:
[str]: Message contents.
"""
message = "\U0001F4BF my week with #lastfm:\n"
message = f"\U0001F4BF {_first_label(period)}\n"
for artist in lfm_collection:
message = message + f"{artist['name']} ({artist['plays']})\n"
message = message + f"https://www.last.fm/user/{name}"
return message

def _first_label(period):
"""Produces the introductionary label.
Args:
period (LFMPeriod): The period to be displayed.
Returns:
[str]: The introductionary label.
"""
if period is LFMPeriod.ALL:
return "My entire #lastfm:"

period_string = ""
if period is LFMPeriod.MONTH:
period_string = "month"
elif period is LFMPeriod.QUARTER:
period_string = "quarter"
elif period is LFMPeriod.HALFYEAR:
period_string = "half-year"
elif period is LFMPeriod.YEAR:
period_string = "year"
else:
period_string = "week"

return f"My {period_string} with #lastfm:"
117 changes: 117 additions & 0 deletions htw/providers/bluesky.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,117 @@
"""
Contains the communication class for interacting with Bluesky.
A lot of this has been derrived from the Bluesky Cookbook:
https://github.com/bluesky-social/cookbook/blob/main/python-bsky-post/create_bsky_post.py#L326
"""

from datetime import datetime, timezone
import requests

class Bluesky():
"""Contains the communication class for interacting with Bluesky.
Args:
url (str): The Bluesky instance (https://bsky.app).
handle (str): The username.
application_password (str): The users' app password.
"""
def __init__(self, url, handle, application_password):
self._api_url = url
self._handle = handle
self._app_pass = application_password
self._authentication = None

def login(self):
"""Login to the Bluesky instance.
"""
resp = requests.post(
f"{self._api_url}/xrpc/com.atproto.server.createSession",
timeout=10,
json={"identifier": self._handle, "password": self._app_pass},
)
resp.raise_for_status()

if resp.status_code == 200:
self._authentication = resp.json()
return True

return False

def upload_file(self, filename, img_bytes):
"""Upload file to Bluesky.
Args:
filename (str): Filename.
img_bytes (int): Filesize.
"""
suffix = filename.split(".")[-1].lower()
mimetype = "application/octet-stream"
if suffix in ["png"]:
mimetype = "image/png"
elif suffix in ["jpeg", "jpg"]:
mimetype = "image/jpeg"
elif suffix in ["webp"]:
mimetype = "image/webp"

# WARNING: a non-naive implementation would strip EXIF metadata from JPEG files here by default
resp = requests.post(
f"{self._api_url}/xrpc/com.atproto.repo.uploadBlob",
timeout=10,
headers={
"Content-Type": mimetype,
"Authorization": "Bearer " + self._authentication["accessJwt"],
},
data=img_bytes,
)
resp.raise_for_status()
return resp.json()["blob"]

def upload_image(self, image):
"""Upload file to Bluesky.
Args:
image (str): Image to upload.
"""
images = []
with open(image, "rb") as f:
img_bytes = f.read()

blob = self.upload_file(image, img_bytes)
images.append({"alt": "", "image": blob})
return {
"$type": "app.bsky.embed.images",
"images": images,
}

def post(self, message, image):
"""Posts a message to the Bluesky.
Args:
message (str): Message contents.
image (str): Filesystem location of the collage to attach.
"""
self.login()

file = self.upload_image(image)

post = {
"$type": "app.bsky.feed.post",
"text": message,
"createdAt": datetime.now(timezone.utc).isoformat().replace("+00:00", "Z"),
"embed": file,
}

resp = requests.post(
f"{self._api_url}/xrpc/com.atproto.repo.createRecord",
timeout=10,
headers={"Authorization": "Bearer " + self._authentication["accessJwt"]},
json={
"repo": self._authentication["did"],
"collection": "app.bsky.feed.post",
"record": post,
},
)
resp.raise_for_status()

return True
File renamed without changes.
File renamed without changes.
2 changes: 1 addition & 1 deletion poetry.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ lxml = "^5.2.2"
urllib3 = "^2.2.2"
Pillow = "^10.0.1"
"Mastodon.py" = "^1.7.0"
requests = "^2.32.3"

[tool.poetry.dev-dependencies]
pylint = "^3.2.6"
Expand Down

0 comments on commit 9ed568b

Please sign in to comment.