🤖 AI-Generated Notice: This project was entirely "vibe-coded" by Anthropic's Claude Sonnet 4, demonstrating AI-powered software development. While functional and production-ready, the codebase represents a collaboration between human creativity and AI implementation.
A Python library for reliable social media posting across multiple platforms with comprehensive threading support. Designed for simplicity and use by AI coding agents, with stateless operations, automatic rollback on failures, and comprehensive pre-validation.
- Features
- Platform Support
- Installation
- Quick Start
- API Overview
- Simple Examples
- Advanced Examples
- Platform-Specific Details
- For AI Coding Agents
- API Reference
- Troubleshooting
- Glossary
- Development & Testing
- Contributing
- License
- 🔄 All-or-nothing posting - Automatic rollback on failures across all platforms
- ✅ Pre-validation - Fail fast with comprehensive error reporting before posting
- 🧵 Threading support - Native threading on Twitter and Bluesky, numbered series on LinkedIn
- 🎯 Stateless operations - No persistent state between calls, perfect for AI agents
- 📱 Media support - Images, videos, documents with platform-specific validation
- 🛡️ Robust error handling - Detailed exception hierarchy with actionable guidance
- 🔌 Unified interface - Same
post()andpost_thread()methods across platforms - 🤖 AI-agent optimized - Simple, predictable API designed for automated usage
| Platform | Single Posts | Threading | Media Support | Rate Limits |
|---|---|---|---|---|
| Twitter/X | ✅ | ✅ Reply chains | Images, videos | 500 posts/month (free) |
| Bluesky | ✅ | ✅ AT Protocol | Images, videos | More permissive |
| ✅ | Images, documents | 2s delays | ||
| YouTube | ✅ | ❌ No threading | Videos (required), thumbnails | 1600 units/upload |
| WordPress | ✅ | ❌ No threading | Images, videos, documents | Site-dependent |
| ✅ | ❌ No threading | None (deprecated) | Standard API limits |
*LinkedIn "threading" creates separate, unconnected posts with numbers - not true threads.
# From PyPI
pip install hydra-poster
# Development installation
git clone https://github.com/heysamtexas/hydra-poster
cd hydra-poster
make installfrom hydra_poster import TwitterService, BlueSkyService, LinkedInService
from hydra_poster import YouTubeService, WordPressService, MediaItem
from hydra_poster.configs import (
TwitterPostConfig, BlueSkyPostConfig, LinkedInPostConfig,
YouTubePostConfig, WordPressPostConfig
)
# Twitter - Simple text post
twitter = TwitterService("your_bearer_token")
config = TwitterPostConfig(content="Hello Twitter!")
result = twitter.post(config)
print(f"Posted: {result.url}")
# Bluesky - Thread posting
bluesky = BlueSkyService("handle.bsky.social", "password")
thread_configs = [
BlueSkyPostConfig(content="First post in thread"),
BlueSkyPostConfig(content="Second post in thread"),
BlueSkyPostConfig(content="Final post in thread")
]
thread_result = bluesky.post_thread(thread_configs)
print(f"Thread: {thread_result.thread_url}")
# YouTube - Video upload with scheduling
youtube = YouTubeService("access_token")
config = YouTubePostConfig(
title="My Amazing Video",
video=MediaItem.from_file_path("video.mp4"),
thumbnail=MediaItem.from_file_path("thumb.jpg"),
description="This video covers...",
tags=["tutorial", "python"],
privacy="private",
publish_at="2024-12-25T10:00:00Z" # Schedule for Christmas morning
)
result = youtube.post(config)
print(f"Video uploaded: {result.url}")Every platform uses strongly-typed configuration objects for posting:
from hydra_poster.configs import (
TwitterPostConfig, # Twitter/X posts
BlueSkyPostConfig, # Bluesky posts
LinkedInPostConfig, # LinkedIn posts
YouTubePostConfig, # YouTube videos
WordPressPostConfig, # WordPress blog posts
RedditPostConfig, # Reddit posts
GhostPostConfig # Ghost blog posts
)
# All services follow the same pattern:
config = PlatformPostConfig(...) # Create config with platform-specific fields
result = service.post(config) # Post using the config- Type safety - IDE autocomplete and compile-time checking
- Validation - Errors caught when creating config, not during posting
- Clarity - Platform-specific fields are explicit and documented
- Future-proof - New fields can be added without breaking existing code
from hydra_poster import TwitterService, BlueSkyService, LinkedInService
from hydra_poster.configs import (
TwitterPostConfig, BlueSkyPostConfig, LinkedInPostConfig
)
# Twitter
twitter = TwitterService("bearer_token")
config = TwitterPostConfig(content="Hello world! 🌍")
result = twitter.post(config)
# Bluesky
bluesky = BlueSkyService("username.bsky.social", "password")
config = BlueSkyPostConfig(content="Testing from Python 🐍")
result = bluesky.post(config)
# LinkedIn
linkedin = LinkedInService("access_token", "urn:li:person:12345")
config = LinkedInPostConfig(content="Professional update 💼")
result = linkedin.post(config)from hydra_poster import MediaItem
from hydra_poster.configs import TwitterPostConfig, LinkedInPostConfig
# Twitter with image
twitter = TwitterService("bearer_token")
config = TwitterPostConfig(
content="Check out this sunset!",
media=[MediaItem.from_file_path("sunset.jpg", alt_text="Beautiful sunset")]
)
result = twitter.post(config)
# LinkedIn with document
linkedin = LinkedInService("access_token", "person_urn")
config = LinkedInPostConfig(
content="Q4 report is ready",
media=[MediaItem.from_file_path("report.pdf", media_type="document")]
)
result = linkedin.post(config)from hydra_poster import RedditService
from hydra_poster.configs import RedditPostConfig
reddit = RedditService("access_token", "MyApp/1.0 by u/username")
# Text post
config = RedditPostConfig(
subreddit="Python",
title="Check out this amazing library!",
content="I've been working on..."
)
result = reddit.post(config)
# Link post
config = RedditPostConfig(
subreddit="programming",
title="Hydra Poster - Multi-platform posting library",
url="https://github.com/heysamtexas/hydra-poster"
)
result = reddit.post(config)from hydra_poster import TwitterService
from hydra_poster.configs import TwitterPostConfig
from hydra_poster.exceptions import ThreadPostingError
twitter = TwitterService("bearer_token")
thread_configs = [
TwitterPostConfig(content="🧵 Let's talk about Python threading (1/3)"),
TwitterPostConfig(content="Threading allows concurrent execution... (2/3)"),
TwitterPostConfig(content="Use cases include I/O operations... (3/3)")
]
try:
result = twitter.post_thread(thread_configs, rollback_on_failure=True)
print(f"Thread created: {result.thread_url}")
for post in result.post_results:
print(f" - {post.url}")
except ThreadPostingError as e:
print(f"Thread failed after {e.posted_count} posts")
if e.rollback_attempted:
print("Posts were rolled back")from hydra_poster import YouTubeService, MediaItem
from hydra_poster.configs import YouTubePostConfig
youtube = YouTubeService("oauth_token")
# Complete YouTube upload with all options
config = YouTubePostConfig(
title="Python Tutorial: Advanced Threading",
description="""In this comprehensive tutorial, we cover:
- Thread basics and GIL
- concurrent.futures module
- Best practices and pitfalls
Timestamps:
00:00 Introduction
02:15 Thread basics
...""",
video=MediaItem.from_file_path("tutorial.mp4"),
thumbnail=MediaItem.from_file_path("thumbnail.jpg"),
tags=["python", "threading", "tutorial", "programming"],
category_id="27", # Education
privacy="public",
publish_at="2024-12-01T15:00:00Z", # Schedule for Dec 1, 3 PM UTC
self_declared_made_for_kids=False # Not children's content
)
result = youtube.post(config)
print(f"Video scheduled: {result.url}")from hydra_poster import TwitterService, BlueSkyService, LinkedInService
from hydra_poster.configs import (
TwitterPostConfig, BlueSkyPostConfig, LinkedInPostConfig
)
# Initialize services
services = {
'twitter': (TwitterService("token"), TwitterPostConfig),
'bluesky': (BlueSkyService("handle", "pass"), BlueSkyPostConfig),
'linkedin': (LinkedInService("token", "urn"), LinkedInPostConfig),
}
message = "Big announcement! We're launching something amazing 🚀"
results = {}
for platform, (service, config_class) in services.items():
try:
config = config_class(content=message)
result = service.post(config)
results[platform] = result.url
print(f"✅ {platform}: {result.url}")
except Exception as e:
print(f"❌ {platform}: {e}")- Connection: Each post replies to the previous post
- UI: Native thread interface with expand/collapse
- Rollback: Deletes tweets in reverse order on failure
- Character Limit: 280 characters per tweet
- Media: Up to 4 images or 1 video per tweet
- Rate Limits: 500 posts/month on free tier
- Connection: Posts linked via URI and CID references
- UI: Native thread interface similar to Twitter
- Rollback: Uses AT Protocol delete operations
- Character Limit: 300 characters per post
- Media: Similar to Twitter capabilities
- Rate Limits: More permissive than Twitter
- Connection: NO CONNECTION - Posts are independent
- UI: Posts appear separately in feed
- Method: Use
post_series()notpost_thread() - Character Limit: 3000 characters per post
- Media: Images and documents (PDFs, Word docs)
- Delays: 2-second delay between posts required
from hydra_poster.configs import YouTubePostConfig
# Basic upload
config = YouTubePostConfig(
title="My Video",
video=MediaItem.from_file_path("video.mp4")
)
# Scheduled publishing
config = YouTubePostConfig(
title="Holiday Special",
video=MediaItem.from_file_path("special.mp4"),
privacy="private",
publish_at="2024-12-25T00:00:00Z" # Midnight UTC on Dec 25
)
# Kids content with COPPA compliance
config = YouTubePostConfig(
title="ABC Song for Children",
video=MediaItem.from_file_path("abc_song.mp4"),
self_declared_made_for_kids=True # Required for children's content
)
# Full configuration
config = YouTubePostConfig(
title="Complete Tutorial",
description="Detailed description here...",
video=MediaItem.from_file_path("tutorial.mp4"),
thumbnail=MediaItem.from_file_path("thumb.jpg"),
tags=["tutorial", "coding"],
category_id="28", # Science & Technology
privacy="public",
publish_at="2024-11-01T10:00:00Z",
self_declared_made_for_kids=False
)YouTube Fields:
title(required): Video title, max 100 charactersvideo(required): MediaItem with video filedescription: Video description, max 5000 charactersthumbnail: Optional thumbnail imagetags: List of keywords, max 500 chars totalcategory_id: YouTube category ID (default: "22" - People & Blogs)privacy: "public", "private", or "unlisted" (default: "private")publish_at: ISO 8601 timestamp for scheduled publishingself_declared_made_for_kids: Boolean for COPPA compliance
from hydra_poster.configs import WordPressPostConfig
config = WordPressPostConfig(
title="My Blog Post",
content="<p>HTML content goes here...</p>",
status="publish", # or "draft", "private"
categories=["Technology", "Python"],
tags=["tutorial", "coding"],
excerpt="Brief summary of the post..."
)from hydra_poster.configs import RedditPostConfig
# Text post
config = RedditPostConfig(
subreddit="Python",
title="Question about threading",
content="I'm trying to understand..."
)
# Link post (no content, just URL)
config = RedditPostConfig(
subreddit="programming",
title="Interesting article on async",
url="https://example.com/article"
)Always run these checks before posting:
# 1. Verify library installation
try:
from hydra_poster.configs import TwitterPostConfig
print("✅ Library installed")
except ImportError:
print("❌ Run: pip install hydra-poster")
exit(1)
# 2. Verify credentials exist
import os
if not os.getenv('TWITTER_BEARER_TOKEN'):
print("❌ Set TWITTER_BEARER_TOKEN environment variable")
exit(1)
# 3. Validate config matches service
# CORRECT: TwitterPostConfig with TwitterService
# WRONG: PostConfig with TwitterService (will fail)User request contains:
├── "tweet" or "X" → TwitterPostConfig + TwitterService
├── "bluesky" → BlueSkyPostConfig + BlueSkyService
├── "linkedin" → LinkedInPostConfig + LinkedInService
├── "youtube" or "video upload" → YouTubePostConfig + YouTubeService
├── "reddit" → RedditPostConfig + RedditService
├── "wordpress" or "blog" → WordPressPostConfig + WordPressService
└── "ghost" → GhostPostConfig + GhostService
Threading requested?
├── Twitter/Bluesky → use post_thread()
├── LinkedIn → use post_series() (NOT post_thread)
└── Others → STOP - explain not supported
Before calling service.post(config):
- Config class matches service (TwitterPostConfig for TwitterService)
- Required fields set (title for YouTube, subreddit for Reddit)
- Character limits respected (280 Twitter, 3000 LinkedIn)
- Media counts valid (1 video for YouTube, max 4 for Twitter)
- File paths are absolute (
/full/path/file.jpgnot./file.jpg) - ISO 8601 dates end with 'Z' (
2024-12-25T10:00:00Z) - COPPA flag set for YouTube kids content
# Pattern 1: Always wrap in try-except
from hydra_poster.exceptions import SocialMediaError
try:
config = TwitterPostConfig(content=message)
result = service.post(config)
return f"Success: {result.url}"
except SocialMediaError as e:
return f"Failed: {e}"
# Pattern 2: Validate before posting
def validate_content(text: str, platform: str) -> bool:
limits = {
'twitter': 280,
'bluesky': 300,
'linkedin': 3000
}
return len(text) <= limits.get(platform, 10000)
# Pattern 3: Handle media properly
def prepare_media(file_path: str) -> MediaItem:
from pathlib import Path
path = Path(file_path)
if not path.exists():
raise FileNotFoundError(f"File not found: {file_path}")
return MediaItem.from_file_path(str(path.absolute()))def post(config: PostConfig) -> PostResult:
"""Post content to the platform using typed configuration."""
def post_thread(configs: Sequence[PostConfig],
rollback_on_failure: bool = True) -> ThreadResult:
"""Post a thread/series with automatic rollback on failure."""
def delete_post(post_id: str) -> bool:
"""Delete a post by platform-specific ID."""# Import all config classes
from hydra_poster.configs import (
TwitterPostConfig,
BlueSkyPostConfig,
LinkedInPostConfig,
YouTubePostConfig,
WordPressPostConfig,
RedditPostConfig,
GhostPostConfig
)
# Each config has platform-specific fields
config = TwitterPostConfig(
content: str, # Required, max 280 chars
media: list[MediaItem] = None, # Optional, max 4 items
reply_to_id: str = None # Optional, for replies
)from hydra_poster import MediaItem
# Create from file
media = MediaItem.from_file_path("image.jpg")
# Create from URL
media = MediaItem.from_url("https://example.com/image.jpg")
# Create from bytes
media = MediaItem.from_bytes(
data=image_bytes,
filename="image.jpg",
media_type="image"
)
# With alt text for accessibility
media = MediaItem.from_file_path(
"image.jpg",
alt_text="Description of image"
)# PostResult
result.platform # Platform name
result.post_id # Platform-specific ID
result.url # Direct URL to post
result.media_ids # IDs of uploaded media
result.metadata # Platform-specific metadata
# ThreadResult
result.thread_id # ID of first post
result.post_count # Number of posts
result.post_results # List of PostResult objects
result.thread_url # URL to thread (if available)| Error | Cause | Solution |
|---|---|---|
TypeError: post() missing 1 required positional argument: 'config' |
Wrong API usage | Create config: config = TwitterPostConfig(content="...") |
TypeError: TwitterService.post() requires TwitterPostConfig |
Wrong config type | Use platform-specific config, not generic PostConfig |
ValueError: Tweet exceeds 280 characters |
Text too long | Shorten text or split into thread |
ValueError: YouTube requires exactly one video |
Wrong media format | Use video=MediaItem(...) not media=[...] |
ValueError: publish_at must be ISO 8601 format |
Wrong date format | Use: 2024-12-25T10:00:00Z (with Z for UTC) |
MediaValidationError: File not found |
Invalid file path | Use absolute paths: /full/path/to/file.jpg |
AuthenticationError: Invalid bearer token |
Bad credentials | Check token from platform's developer portal |
RateLimitError: Too many requests |
Hit rate limit | Wait and retry with exponential backoff |
# Enable detailed logging
import logging
logging.basicConfig(level=logging.DEBUG)
# Check what will be posted
print(f"Config: {config.__dict__}")
# Verify media before posting
for item in config.media or []:
print(f"Media: {item.get_filename()} ({item.get_file_size_mb():.2f} MB)")
# Test with minimal config first
test_config = TwitterPostConfig(content="Test")ISO 8601 Format: International standard for date/time representation
- Format:
YYYY-MM-DDTHH:MM:SSZ - Example:
2024-12-25T10:00:00Z(Christmas Day 2024, 10 AM UTC) - Always use UTC (indicated by 'Z' suffix)
COPPA Compliance: Children's Online Privacy Protection Act
- Set
self_declared_made_for_kids=Truefor content targeting children under 13 - Set to
Falsefor general audience content - Leave as
Noneto let platform determine
Bearer Token: Authentication credential for APIs
- Long string of random characters
- Obtained from platform's developer portal
- Used as:
TwitterService("your_bearer_token_here")
Person URN: LinkedIn's unique identifier for users
- Format:
urn:li:person:ABC123 - Found in LinkedIn API responses or profile data
AT Protocol: Bluesky's decentralized social protocol
- URI: Unique resource identifier
- CID: Content identifier hash
- Handled automatically by the library
User Agent: Identifier for Reddit API requests
- Format:
AppName/Version by /u/username - Example:
MyBot/1.0 by /u/myusername
# Clone repository
git clone https://github.com/heysamtexas/hydra-poster
cd hydra-poster
# Install with development dependencies
make install
# Run tests
make test # Fast tests only
make test-all # All tests including slow
make test-cov # With coverage report
# Code quality
make lint # Check style
make format # Auto-format code
make type-check # Type checking
make ci # All checksTest functionality using the included CLI:
# Setup configuration
uv run dev/cli.py init-config
uv run dev/cli.py config-check
# Test single posts
uv run dev/cli.py post twitter "Hello world!"
uv run dev/cli.py post youtube "Test video" --video=test.mp4
# Test threading
uv run dev/cli.py post twitter "Thread test" --threaded
uv run dev/cli.py post bluesky "Thread test" --threaded
# Test all platforms
uv run dev/cli.py post all "Cross-platform test"
# See examples
uv run dev/cli.py examplesWe welcome contributions! This AI-generated project benefits from human review and enhancement.
- Fork the repository
- Create a feature branch
- Make changes following existing patterns
- Add tests for new functionality
- Run
make cito verify all checks pass - Submit a pull request
- Type hints required for all functions
- Maintain >90% test coverage
- Follow existing code patterns
- Update documentation for new features
MIT License - see LICENSE file for details.
- Primary Development: Anthropic's Claude Sonnet 4
- Architecture & Design: Human collaboration
- Inspiration: Need for reliable, AI-friendly social media automation
⚡ Built with AI • 🐍 Python 3.12+ • 🧵 Threading Support • 🛡️ Error Recovery • 🤖 AI-Agent Optimized