Skip to content

heysamtexas/hydra-poster

Repository files navigation

Hydra Poster

Python 3.12+ MIT License Code style: Ruff Type checked: mypy Tests

🤖 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.

Table of Contents

Features

  • 🔄 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() and post_thread() methods across platforms
  • 🤖 AI-agent optimized - Simple, predictable API designed for automated usage

Platform Support

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
LinkedIn ⚠️ Numbered series* Images, documents 2s delays
YouTube ❌ No threading Videos (required), thumbnails 1600 units/upload
WordPress ❌ No threading Images, videos, documents Site-dependent
Reddit ❌ No threading None (deprecated) Standard API limits

*LinkedIn "threading" creates separate, unconnected posts with numbers - not true threads.

Installation

# From PyPI
pip install hydra-poster

# Development installation
git clone https://github.com/heysamtexas/hydra-poster
cd hydra-poster
make install

Quick Start

from 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}")

API Overview

The Config Pattern

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

Why Configuration Objects?

  1. Type safety - IDE autocomplete and compile-time checking
  2. Validation - Errors caught when creating config, not during posting
  3. Clarity - Platform-specific fields are explicit and documented
  4. Future-proof - New fields can be added without breaking existing code

Simple Examples

Text Posts

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)

Posts with Media

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)

Reddit Posts

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)

Advanced Examples

Threading with Error Handling

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")

YouTube with Full Metadata

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}")

Cross-Platform Posting

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}")

Platform-Specific Details

Twitter/X - Native Reply Chains

  • 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

Bluesky - AT Protocol Threading

  • 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

LinkedIn - Numbered Post Series ⚠️

  • Connection: NO CONNECTION - Posts are independent
  • UI: Posts appear separately in feed
  • Method: Use post_series() not post_thread()
  • Character Limit: 3000 characters per post
  • Media: Images and documents (PDFs, Word docs)
  • Delays: 2-second delay between posts required

YouTube - Video Uploads with Scheduling

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 characters
  • video (required): MediaItem with video file
  • description: Video description, max 5000 characters
  • thumbnail: Optional thumbnail image
  • tags: List of keywords, max 500 chars total
  • category_id: YouTube category ID (default: "22" - People & Blogs)
  • privacy: "public", "private", or "unlisted" (default: "private")
  • publish_at: ISO 8601 timestamp for scheduled publishing
  • self_declared_made_for_kids: Boolean for COPPA compliance

WordPress - Blog Posts

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..."
)

Reddit - Text and Link Posts

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"
)

For AI Coding Agents

Prerequisites Verification

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)

Decision Tree for Platform Selection

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

Validation Checklist

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.jpg not ./file.jpg)
  • ISO 8601 dates end with 'Z' (2024-12-25T10:00:00Z)
  • COPPA flag set for YouTube kids content

Common Patterns for AI Agents

# 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()))

API Reference

Core Classes

Service Methods

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."""

Configuration Classes

# 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
)

MediaItem

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"
)

Result Objects

# 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)

Troubleshooting

Common Errors and Solutions

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

Debugging Tips

# 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")

Glossary

Technical Terms

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=True for content targeting children under 13
  • Set to False for general audience content
  • Leave as None to 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

Development & Testing

Development Setup

# 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 checks

CLI Testing Tool

Test 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 examples

Contributing

We welcome contributions! This AI-generated project benefits from human review and enhancement.

Development Process

  1. Fork the repository
  2. Create a feature branch
  3. Make changes following existing patterns
  4. Add tests for new functionality
  5. Run make ci to verify all checks pass
  6. Submit a pull request

Code Standards

  • Type hints required for all functions
  • Maintain >90% test coverage
  • Follow existing code patterns
  • Update documentation for new features

License

MIT License - see LICENSE file for details.

Credits

  • 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

About

A Python library for reliable social media posting with threading support across multiple platforms (X, BlueSky, Reddit, LinkedIn)

Resources

Stars

Watchers

Forks

Packages

 
 
 

Contributors