use serde::{Deserialize, Serialize};
const DEFAULT_BASE: &str = "https://shapelessai.com";
#[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),
}
#[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,
}
#[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>,
pub published_location: Option<String>,
pub error: Option<String>,
}
#[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>,
}
#[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 {
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) }
}
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 }
}
pub fn now(connection_id: &str, text: &str) -> Self {
Self { connection_id: connection_id.into(), post_text: text.into(), scheduled_at: None, queue: None }
}
}
#[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() }
}
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() })
}
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)
}
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
}
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)
}
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(())
}
}