Cross-post messages to multiple social media platforms from Rust.
A Rust rewrite and expansion of the @humanwhocodes/crosspost JavaScript library. Supports 16 platforms with typed credentials, concurrent posting, and per-platform error isolation.
Add to your Cargo.toml:
[dependencies]
crosspost = { git = "https://github.com/GraftAI-com/crosspost-rs.git" }
tokio = { version = "1", features = ["full"] }use crosspost::{Client, BlueskyStrategy, BlueskyCredentials, MastodonStrategy, MastodonCredentials, PostResult};
#[tokio::main]
async fn main() -> crosspost::Result<()> {
let client = Client::new(vec![
Box::new(BlueskyStrategy::new(BlueskyCredentials {
identifier: "user.bsky.social".into(),
password: "app-password".into(),
host: None,
})?),
Box::new(MastodonStrategy::new(MastodonCredentials {
access_token: "your-token".into(),
host: "mastodon.social".into(),
})?),
]);
let results = client.post("Hello from Rust!", None).await;
for result in &results {
match result {
PostResult::Success { name, url, .. } => {
println!("Posted to {}: {}", name, url.as_deref().unwrap_or("ok"));
}
PostResult::Failure { name, reason } => {
println!("Failed on {}: {}", name, reason);
}
}
}
Ok(())
}Each strategy has a from_env() constructor that reads platform-specific environment variables:
use crosspost::{Client, BlueskyStrategy, TwitterStrategy};
let client = Client::new(vec![
Box::new(BlueskyStrategy::from_env()?),
Box::new(TwitterStrategy::from_env()?),
]);Set CROSSPOST_DOTENV=1 to auto-load a .env file, or CROSSPOST_DOTENV=/path/to/.env for a custom path.
use crosspost::PostToEntry;
let results = client.post_to(&[
PostToEntry {
strategy_id: "bluesky".into(),
message: "Short post for Bluesky".into(),
images: None,
},
PostToEntry {
strategy_id: "mastodon".into(),
message: "Longer post with more detail for Mastodon...".into(),
images: None,
},
]).await;| Platform | Strategy | Auth | Images | Max Length |
|---|---|---|---|---|
| Twitter/X | TwitterStrategy |
OAuth2 bearer | Yes (upload) | 280 (URLs=23) |
| Bluesky | BlueskyStrategy |
App password | Yes (blob) | 300 (URLs=27) |
| Mastodon | MastodonStrategy |
OAuth2 bearer | Yes (media) | 500 |
LinkedInStrategy |
OAuth2 bearer | Yes (3-step) | 3,000 | |
FacebookStrategy |
OAuth2 bearer | Yes (multi) | 63,206 | |
InstagramStrategy |
OAuth2 bearer | Yes | 2,200 | |
| Discord Bot | DiscordStrategy |
Bot token | Yes (multipart) | 2,000 |
| Discord Webhook | DiscordWebhookStrategy |
Webhook URL | Yes (multipart) | 2,000 |
| Telegram | TelegramStrategy |
Bot API | Yes (sendPhoto) | 4,096 |
| Slack | SlackStrategy |
Bot token | Yes (3-step) | 40,000 |
| Dev.to | DevtoStrategy |
API key | Yes (base64 md) | Unlimited |
| Nostr | NostrStrategy |
Private key | No | 280 |
| YouTube | YouTubeStrategy |
OAuth2 bearer | No | 5,000 |
| TikTok | TikTokStrategy |
OAuth2 bearer | No | 2,200 |
RedditStrategy |
OAuth2 bearer | No | 40,000 | |
| Twitch | TwitchStrategy |
OAuth2 bearer | No | 500 |
Each strategy reads specific environment variables via from_env():
| Platform | Variables |
|---|---|
TWITTER_ACCESS_TOKEN (or TWITTER_ACCESS_TOKEN_KEY) |
|
| Bluesky | BLUESKY_IDENTIFIER, BLUESKY_PASSWORD, BLUESKY_HOST (optional) |
| Mastodon | MASTODON_ACCESS_TOKEN, MASTODON_HOST (optional, defaults to mastodon.social) |
LINKEDIN_ACCESS_TOKEN |
|
FACEBOOK_ACCESS_TOKEN, FACEBOOK_PAGE_ID (optional) |
|
INSTAGRAM_ACCESS_TOKEN |
|
| Discord Bot | DISCORD_BOT_TOKEN, DISCORD_CHANNEL_ID |
| Discord Webhook | DISCORD_WEBHOOK_URL |
| Telegram | TELEGRAM_BOT_TOKEN, TELEGRAM_CHAT_ID |
| Slack | SLACK_TOKEN, SLACK_CHANNEL (optional, defaults to #general) |
| Dev.to | DEVTO_API_KEY |
| Nostr | NOSTR_PRIVATE_KEY, NOSTR_RELAYS (comma-separated) |
| YouTube | YOUTUBE_ACCESS_TOKEN |
| TikTok | TIKTOK_ACCESS_TOKEN |
REDDIT_ACCESS_TOKEN, REDDIT_SUBREDDIT (optional) |
|
| Twitch | TWITCH_ACCESS_TOKEN, TWITCH_CLIENT_ID |
Attach images using PostOptions:
use crosspost::{PostOptions, ImageEmbed};
let options = PostOptions {
images: vec![ImageEmbed {
data: std::fs::read("photo.jpg")?,
alt: Some("A photo".into()),
mime_type: Some("image/jpeg".into()),
}],
};
let results = client.post("Check out this photo!", Some(&options)).await;- Max 4 images per post
- MIME type auto-detected if not provided
- Supported: JPEG, PNG, GIF
- Image compression utilities available in
crosspost::util::images
The library uses the Strategy pattern. Each platform is a Strategy implementation with credentials baked in at construction time. The Client orchestrates posting to multiple strategies concurrently.
Client::post("message")
├── TwitterStrategy::post() → PostResult::Success / Failure
├── BlueskyStrategy::post() → PostResult::Success / Failure
└── MastodonStrategy::post() → PostResult::Success / Failure
One strategy failing does not affect others. Results are collected as Vec<PostResult>.
An optional SaaS server layer is available behind the server feature flag:
[dependencies]
crosspost = { git = "https://github.com/GraftAI-com/crosspost-rs.git", features = ["server"] }This includes:
- Axum HTTP API with JWT authentication
- SurrealDB for data persistence
- OAuth2 flow handling for platform connections
- Rate limiting (global and per-user)
- Background scheduling for deferred posts
- Token refresh management
Run the server:
cargo run --features server --bin crosspost-servercargo check # Check library
cargo test # Library tests (38)
cargo test --features server # All tests (61)
cargo clippy --all-targets -- -D warnings # Lint library
cargo clippy --all-targets --features server -- -D warnings # Lint everything
cargo fmt --all -- --check # Format checkThis project is licensed under the Mozilla Public License 2.0. See LICENSE.
- Original crosspost library by Nicholas C. Zakas