Skip to content

Commit 6fe0326

Browse files
authored
Merge pull request #48 from codeforboston/mastodon
Use main Mastodon account and fix workflow install
2 parents 9445457 + 128c98b commit 6fe0326

11 files changed

Lines changed: 261 additions & 10 deletions

File tree

.github/workflows/prod.yml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -31,4 +31,5 @@ jobs:
3131
INSTAGRAM_PAGE_ACCESS_TOKEN: ${{ secrets.INSTAGRAM_PAGE_ACCESS_TOKEN }}
3232
BLUESKY_HANDLE: ${{ secrets.BLUESKY_HANDLE }}
3333
BLUESKY_PASSWORD: ${{ secrets.BLUESKY_PASSWORD }}
34+
MASTODON_TOKEN: ${{ secrets.MASTODON_TOKEN }}
3435
run: python ./main.py

README.md

Lines changed: 13 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -13,16 +13,23 @@ This Project runs on github actions and runs periodically.
1313
## Set up your environment variables
1414

1515
Required:
16-
- `RESCUEGROUPS_API_KEY`
16+
- `CUTEPETSBOSTON_RESCUEGROUPS_API_KEY`
1717

1818
Optional for Instagram posting:
19-
- `INSTAGRAM_USERNAME`
19+
- `INSTAGRAM_HANDLE`
2020
- `INSTAGRAM_PASSWORD`
2121

2222
Optional for Bluesky posting:
2323
- `BLUESKY_HANDLE` (or `BLUESKY_TEST_HANDLE`)
2424
- `BLUESKY_PASSWORD` (or `BLUESKY_TEST_PASSWORD`)
2525

26+
Optional for Mastodon posting:
27+
- `MASTODON_TOKEN` or `MASTODON_TEST_TOKEN`
28+
- `MASTODON_API_BASE_URL` (defaults to `https://mastodon.social`)
29+
30+
Optional platform selection:
31+
- `POSTER_PLATFORMS` to limit posting to specific platforms, for example `mastodon` or `bluesky,mastodon`
32+
2633
## File organization
2734

2835
- `main.py`: orchestrates fetching pets and publishing posts.
@@ -35,6 +42,10 @@ Optional for Bluesky posting:
3542

3643
python main.py
3744

45+
To run only the Mastodon poster locally or in GitHub Actions:
46+
47+
POSTER_PLATFORMS=mastodon python main.py
48+
3849
# History
3950

4051
This project was originally started by [Becky Boone](https://github.com/boonrs) and [Drew](https://github.com/drewrwilson) during their fellowship at Code for America in 2014.

main.py

Lines changed: 9 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -15,20 +15,24 @@ def main():
1515

1616

1717
def create_posters(debug=False):
18-
from social_posters import PosterDebug
19-
18+
from social_posters.debug import PosterDebug
19+
2020
if debug:
21-
return [PosterDebug()]
2221

22+
return [PosterDebug()]
2323
from social_posters.instagram import PosterInstagram
2424
from social_posters.bluesky import PosterBluesky
25+
from social_posters.mastodon import PosterMastodon
2526

2627
posters = []
28+
posters.append(PosterMastodon())
2729
posters.append(PosterBluesky())
2830
posters.append(PosterInstagram())
2931
return posters
3032

3133

34+
35+
3236
def create_sources(debug=False):
3337
from adoption_sources import SourceRescueGroups, SourceManual
3438

@@ -80,5 +84,7 @@ def pick_pet(pets):
8084
return random.choice(eligible)
8185

8286

87+
88+
8389
if __name__ == "__main__":
8490
main()
Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,33 @@
1+
import sys, os
2+
sys.path.insert(0, os.path.abspath(os.path.join(os.path.dirname(__file__), "..")))
3+
4+
from abstractions import Post
5+
from social_posters.mastodon import PosterMastodon
6+
7+
8+
9+
def main():
10+
poster = PosterMastodon()
11+
12+
if not poster.authenticate():
13+
print("Authentication failed!")
14+
exit(1)
15+
16+
print("Authenticated to Mastodon!")
17+
18+
post = Post(
19+
text="Test post",
20+
image_url="https://static.wikia.nocookie.net/familyguy/images/c/c2/FamilyGuy_Single_BrianWriter_R7.jpg/revision/latest?cb=20230807152447",
21+
alt_text="Cute animal",
22+
tags=["Test", "Mastodon"],
23+
)
24+
25+
result = poster.publish(post)
26+
27+
if result.success:
28+
print(f"Posted successfully! URL: {result.post_url}")
29+
else:
30+
print(f"Post failed: {result.error_message}")
31+
32+
if __name__ == "__main__":
33+
main()
Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
from mastodon import Mastodon
2+
import os
3+
from datetime import datetime
4+
5+
client = Mastodon(
6+
access_token=os.environ.get("MASTODON_TEST_TOKEN"),
7+
api_base_url=os.environ.get("MASTODON_API_BASE_URL", "https://mastodon.social"),
8+
)
9+
10+
client.account_verify_credentials()
11+
client.status_post(f"Simple Test at {datetime.now()}")
12+
print("Success")

requirements.txt

Lines changed: 49 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,51 @@
1-
instagrapi>=2.3.0
1+
anyio==4.12.1
2+
api-display-purposes==0.0.3
3+
attrs==25.4.0
4+
beautifulsoup4==4.14.3
5+
blurhash==1.1.5
6+
certifi==2026.2.25
7+
chardet==3.0.4
8+
charset-normalizer==3.4.4
29
clarifai==2.6.2
10+
configparser==3.8.1
11+
decorator==4.0.2
12+
EasyProcess==1.1
313
emoji==1.7.0
4-
requests>=2.28.0
5-
setuptools>=70.0
14+
future==1.0.0
15+
googleapis-common-protos==1.72.0
16+
grpcio==1.78.0
17+
h11==0.16.0
18+
httpcore==1.0.9
19+
httpx==0.28.1
20+
idna==2.10
21+
instapy==0.6.16
22+
jsonschema==2.6.0
23+
Mastodon.py==2.1.4
24+
MeaningCloud-python==2.0.0
25+
outcome==1.3.0.post0
26+
plyer==2.1.0
27+
protobuf==3.20.3
28+
PySocks==1.7.1
29+
python-dateutil==2.9.0.post0
30+
python-magic==0.4.27
31+
python-telegram-bot==22.6
32+
PyVirtualDisplay==3.0
33+
PyYAML==6.0.3
34+
regex==2026.2.28
35+
requests==2.32.5
36+
selenium==4.41.0
37+
semantic-version==2.10.0
38+
setuptools==82.0.0
39+
setuptools-rust==1.12.0
40+
six==1.17.0
41+
sniffio==1.3.1
42+
sortedcontainers==2.4.0
43+
soupsieve==2.8.3
44+
tqdm==4.67.3
45+
trio==0.33.0
46+
trio-websocket==0.12.2
47+
typing_extensions==4.15.0
48+
urllib3==2.6.3
49+
webdriverdownloader==1.1.0.4
50+
websocket-client==1.9.0
51+
wsproto==1.3.2

social_posters/__init__.py

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,7 @@
11
"""Social media poster implementations implementing the SocialPoster interface."""
2-
32
from social_posters.debug import PosterDebug
43

5-
__all__ = ["PosterBluesky", "PosterDebug", "PosterInstagram"]
4+
5+
__all__ = ["PosterBluesky", "PosterDebug", "PosterMastodon", "PosterInstagram"]
6+
7+

social_posters/debug.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,7 @@ def publish(self, post: Post) -> PostResult:
2222
f"Link: {post.link}\n"
2323
f"Alt: {post.alt_text}\n"
2424
f"Tags: {post.tags}\n"
25+
f"Url: {post.link}\n"
2526
)
2627
if self.stream:
2728
self.stream.write(output)

social_posters/mastodon.py

Lines changed: 111 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,111 @@
1+
import os
2+
from urllib.parse import urlparse
3+
import tempfile
4+
5+
import requests
6+
from mastodon import Mastodon
7+
8+
from abstractions import Post, PostResult, SocialPoster
9+
10+
MASTODON_CHARACTER_LIMIT = 500
11+
TRUNCATION_SUFFIX = "..."
12+
13+
14+
class PosterMastodon(SocialPoster):
15+
def __init__(self):
16+
raw_token = os.environ.get("MASTODON_TOKEN")
17+
self.token = raw_token.strip() if raw_token else None
18+
self.api_base_url = "https://mastodon.social"
19+
self._session = None
20+
self._is_available = bool(self.token)
21+
self._auth_error = None
22+
23+
@property
24+
def platform_name(self) -> str:
25+
return "Mastodon"
26+
27+
def authenticate(self) -> bool:
28+
try:
29+
self._session = Mastodon(
30+
access_token=self.token,
31+
api_base_url=self.api_base_url,
32+
)
33+
self._session.account_verify_credentials()
34+
self._auth_error = None
35+
return True
36+
except Exception as exc:
37+
self._session = None
38+
self._auth_error = f"{type(exc).__name__}: {exc}"
39+
return False
40+
41+
def publish(self, post: Post) -> PostResult:
42+
if not self._is_available:
43+
return PostResult(
44+
success=False,
45+
error_message="Mastodon credentials not available.",
46+
)
47+
48+
if not post.image_url:
49+
return PostResult(
50+
success=False,
51+
error_message="Mastodon posts require an image URL.",
52+
)
53+
54+
if not self._session and not self.authenticate():
55+
return PostResult(
56+
success=False,
57+
error_message=(
58+
"Mastodon authentication failed."
59+
if not self._auth_error
60+
else f"Mastodon authentication failed: {self._auth_error}"
61+
),
62+
)
63+
64+
image_path = None
65+
try:
66+
image_path = self._download_image(post.image_url)
67+
media = self._session.media_post(
68+
image_path,
69+
description=post.alt_text or "Photo of an adoptable pet",
70+
)
71+
status = self._session.status_post(
72+
self._format_caption(post),
73+
media_ids=[media["id"]],
74+
)
75+
return PostResult(
76+
success=True,
77+
post_id=str(status["id"]),
78+
post_url=status.get("url"),
79+
)
80+
except Exception as exc:
81+
return PostResult(success=False, error_message=str(exc))
82+
finally:
83+
self._session = None
84+
if image_path and os.path.exists(image_path):
85+
os.unlink(image_path)
86+
87+
def _format_caption(self, post: Post) -> str:
88+
tags = " ".join(f"#{tag}" for tag in post.tags if tag)
89+
tag_suffix = f"\n\n{tags}" if tags else ""
90+
available_text_length = MASTODON_CHARACTER_LIMIT - len(tag_suffix)
91+
92+
if available_text_length <= len(TRUNCATION_SUFFIX):
93+
return (tag_suffix[-MASTODON_CHARACTER_LIMIT:]).strip()
94+
95+
caption_text = post.text.strip()
96+
if len(caption_text) > available_text_length:
97+
caption_text = caption_text[: available_text_length - len(TRUNCATION_SUFFIX)].rstrip()
98+
caption_text = f"{caption_text}{TRUNCATION_SUFFIX}"
99+
100+
return f"{caption_text}{tag_suffix}"
101+
102+
def _download_image(self, image_url: str) -> str:
103+
parsed_url = urlparse(image_url)
104+
ext = os.path.splitext(parsed_url.path)[1] or ".jpg"
105+
with tempfile.NamedTemporaryFile(delete=False, suffix=ext) as tmp:
106+
response = requests.get(image_url, stream=True, timeout=20)
107+
response.raise_for_status()
108+
for chunk in response.iter_content(chunk_size=1024 * 128):
109+
if chunk:
110+
tmp.write(chunk)
111+
return tmp.name

tests/test_main.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -64,5 +64,6 @@ def test_debug_returns_debug_poster(self):
6464
self.assertEqual(posters[0].platform_name, "Debug")
6565

6666

67+
6768
if __name__ == "__main__":
6869
unittest.main()

0 commit comments

Comments
 (0)