shapelessai 0.1.1

Rust client for the ShapelessAI API: list connected social accounts, schedule posts to X, LinkedIn, Instagram, TikTok, YouTube, Threads, Bluesky and Facebook, read post status.
Documentation
//! Rust client for the [ShapelessAI](https://shapelessai.com) API.
//!
//! ShapelessAI is an AI content team for founders: it makes and publishes social posts.
//! This crate covers the publishing core: list the social accounts a user connected,
//! schedule a post (next free queue slot, a set time, or now), and read post status.
//!
//! Get an `slk_` API key at <https://shapelessai.com/docs/api>. The full HTTP API is
//! described at <https://shapelessai.com/api/openapi.json>, and the same tools are
//! served to agents over MCP at <https://shapelessai.com/mcp>.
//!
//! ```no_run
//! # async fn run() -> Result<(), shapelessai::Error> {
//! let client = shapelessai::Client::from_env()?; // reads SHAPELESSAI_API_KEY
//! let accounts = client.accounts().await?;
//! let post = client
//!     .create_post(&shapelessai::NewPost::queued(&accounts[0].id, "Shipped today: ..."))
//!     .await?;
//! println!("{} is {}", post.id, post.status);
//! # Ok(()) }
//! ```

use serde::{Deserialize, Serialize};

const DEFAULT_BASE: &str = "https://shapelessai.com";

/// What can go wrong talking to ShapelessAI.
#[derive(Debug, thiserror::Error)]
pub enum Error {
    #[error("set SHAPELESSAI_API_KEY (make one at https://shapelessai.com/docs/api)")]
    MissingKey,
    #[error("ShapelessAI refused the API key; make a new one at https://shapelessai.com/docs/api")]
    Unauthorized,
    #[error("ShapelessAI answered {status}: {message}")]
    Api { status: u16, message: String },
    #[error(transparent)]
    Http(#[from] reqwest::Error),
}

/// A social account the key's owner connected.
#[derive(Debug, Clone, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct Connection {
    pub id: String,
    pub platform: String,
    pub handle: String,
    pub display_name: Option<String>,
    pub mode: String,
    pub status: String,
}

/// A post and where it stands.
#[derive(Debug, Clone, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct Post {
    pub id: String,
    pub connection_id: String,
    pub platform: String,
    pub status: String,
    pub post_text: String,
    pub scheduled_at: Option<String>,
    pub published_at: Option<String>,
    /// The live URL once published.
    pub published_location: Option<String>,
    pub error: Option<String>,
}

/// The answer to [`Client::create_post`].
#[derive(Debug, Clone, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct Created {
    pub id: String,
    pub status: String,
    pub scheduled_at: Option<String>,
    pub published_location: Option<String>,
    pub error: Option<String>,
}

/// A post to create on one connected account.
#[derive(Debug, Clone, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct NewPost {
    pub connection_id: String,
    pub post_text: String,
    #[serde(skip_serializing_if = "Option::is_none")]
    pub scheduled_at: Option<String>,
    #[serde(skip_serializing_if = "Option::is_none")]
    pub queue: Option<bool>,
}

impl NewPost {
    /// Goes to the account's next free queue slot.
    pub fn queued(connection_id: &str, text: &str) -> Self {
        Self { connection_id: connection_id.into(), post_text: text.into(), scheduled_at: None, queue: Some(true) }
    }
    /// Publishes at an ISO 8601 time.
    pub fn at(connection_id: &str, text: &str, iso_time: &str) -> Self {
        Self { connection_id: connection_id.into(), post_text: text.into(), scheduled_at: Some(iso_time.into()), queue: None }
    }
    /// Publishes right away.
    pub fn now(connection_id: &str, text: &str) -> Self {
        Self { connection_id: connection_id.into(), post_text: text.into(), scheduled_at: None, queue: None }
    }
}

/// A ShapelessAI API client.
#[derive(Debug, Clone)]
pub struct Client {
    http: reqwest::Client,
    key: String,
    base: String,
}

impl Client {
    pub fn new(api_key: impl Into<String>) -> Self {
        Self { http: reqwest::Client::new(), key: api_key.into(), base: DEFAULT_BASE.into() }
    }

    /// Reads the key from `SHAPELESSAI_API_KEY`.
    pub fn from_env() -> Result<Self, Error> {
        std::env::var("SHAPELESSAI_API_KEY").map(Self::new).map_err(|_| Error::MissingKey)
    }

    pub fn with_base_url(mut self, base: impl Into<String>) -> Self {
        self.base = base.into().trim_end_matches('/').to_string();
        self
    }

    async fn send<T: serde::de::DeserializeOwned>(&self, req: reqwest::RequestBuilder) -> Result<T, Error> {
        let res = req.bearer_auth(&self.key).send().await?;
        let status = res.status();
        if status == reqwest::StatusCode::UNAUTHORIZED {
            return Err(Error::Unauthorized);
        }
        let body: serde_json::Value = res.json().await.unwrap_or_default();
        if !status.is_success() {
            let message = body.get("error").and_then(|e| e.as_str()).unwrap_or("no message").to_string();
            return Err(Error::Api { status: status.as_u16(), message });
        }
        serde_json::from_value(body).map_err(|e| Error::Api { status: status.as_u16(), message: e.to_string() })
    }

    /// Every connection on the account, including tracked (read-only) ones.
    pub async fn connections(&self) -> Result<Vec<Connection>, Error> {
        #[derive(Deserialize)]
        struct R { connections: Vec<Connection> }
        let r: R = self.send(self.http.get(format!("{}/api/connections", self.base))).await?;
        Ok(r.connections)
    }

    /// The accounts a post can go to: connected by sign-in and active.
    pub async fn accounts(&self) -> Result<Vec<Connection>, Error> {
        Ok(self.connections().await?.into_iter().filter(|c| c.mode == "oauth" && c.status == "active").collect())
    }

    pub async fn create_post(&self, post: &NewPost) -> Result<Created, Error> {
        self.send(self.http.post(format!("{}/api/posts", self.base)).json(post)).await
    }

    /// Recent posts, newest first; `status` is one of proposed, scheduled, publishing, published, handoff, failed, canceled.
    pub async fn posts(&self, status: Option<&str>, limit: u32) -> Result<Vec<Post>, Error> {
        #[derive(Deserialize)]
        struct R { posts: Vec<Post> }
        let mut q = vec![("limit", limit.clamp(1, 50).to_string())];
        if let Some(s) = status { q.push(("status", s.to_string())); }
        let r: R = self.send(self.http.get(format!("{}/api/posts", self.base)).query(&q)).await?;
        Ok(r.posts)
    }

    /// Cancels a post that has not been published yet.
    pub async fn cancel_post(&self, id: &str) -> Result<(), Error> {
        let _: serde_json::Value = self.send(self.http.delete(format!("{}/api/posts/{id}", self.base))).await?;
        Ok(())
    }
}