use std::borrow::Cow;
use bytes::{Bytes, BytesMut};
use percent_encoding::{AsciiSet, NON_ALPHANUMERIC, utf8_percent_encode};
use crate::client::Client;
use crate::error::Error;
use crate::http::Method;
use crate::observability::OperationInfo;
use crate::services::write_info;
pub const WORLD_ADDRESS: &str = "world@hey.com";
const POST_PATH: &str = "/world/posts/";
const IMPORT_PART: &str = "world_list_import[source]";
const DEFAULT_IMPORT_FILENAME: &str = "subscribers.csv";
const PATH_SEGMENT: &AsciiSet = &NON_ALPHANUMERIC
.remove(b'-')
.remove(b'_')
.remove(b'.')
.remove(b'~');
pub struct World<'a> {
client: &'a Client,
}
impl Client {
pub fn world(&self) -> World<'_> {
World::new(self)
}
}
impl<'a> World<'a> {
pub(crate) fn new(client: &'a Client) -> World<'a> {
World { client }
}
pub fn client(&self) -> &'a Client {
self.client
}
pub async fn publish(&self, subject: &str, content: &str) -> Result<String, Error> {
let sender_id = self.client.default_sender_id().await?.to_string();
let mut operation = self.client.form(Method::POST, "/messages")?;
operation.info(write_info("World", "PublishWorldPost", "world_post", None));
operation.form(&[
("acting_sender_id", sender_id.as_str()),
("message[subject]", subject),
("message[content]", content),
("entry[addressed][directly]", WORLD_ADDRESS),
("entry[status]", "active"),
]);
let sent = self.client.send_form(operation).await?;
let location = sent.location.unwrap_or_default();
post_token(&location).ok_or_else(|| {
Error::api(
0,
format!(
"the message was sent but did not become a HEY World post (landed on {location:?})"
),
)
})
}
pub async fn update_post(
&self,
token: &str,
subject: &str,
content: &str,
) -> Result<(), Error> {
let mut fields = Vec::new();
if !subject.is_empty() {
fields.push(("world_post[subject]", subject));
}
if !content.is_empty() {
fields.push(("world_post[content]", content));
}
let mut operation = self.client.form(Method::PATCH, &post_path(token))?;
operation.info(write_info("World", "UpdateWorldPost", "world_post", None));
operation.form(&fields);
self.client.send_unit(operation).await
}
pub async fn delete_post(&self, token: &str) -> Result<(), Error> {
let mut operation = self.client.form(Method::DELETE, &post_path(token))?;
operation.info(write_info("World", "DeleteWorldPost", "world_post", None));
self.client.send_unit(operation).await
}
pub async fn export_subscribers(&self, list_email_address: &str) -> Result<Bytes, Error> {
let path = format!("/world/lists/{}/export.csv", escape(list_email_address));
let mut operation = self.client.csv(&path)?;
operation.info(OperationInfo {
service: Cow::Borrowed("World"),
operation: Cow::Borrowed("ExportWorldSubscribers"),
resource_type: Cow::Borrowed("world_list"),
is_mutation: false,
resource_id: None,
});
Ok(self.client.execute(operation).await?.body)
}
pub async fn import_subscribers(
&self,
list_email_address: &str,
filename: &str,
csv: &[u8],
) -> Result<(), Error> {
let (content_type, body) = subscriber_import_body(filename, csv);
let mut operation = self.client.form(
Method::POST,
&format!("/world/lists/{}/imports", escape(list_email_address)),
)?;
operation.info(write_info(
"World",
"ImportWorldSubscribers",
"world_list",
None,
));
operation.multipart(content_type, body);
self.client.send_unit(operation).await
}
}
fn post_token(location: &str) -> Option<String> {
location
.match_indices(POST_PATH)
.map(|(at, _)| hex_run(&location[at + POST_PATH.len()..]))
.find(|token| !token.is_empty())
}
fn hex_run(text: &str) -> String {
text.chars()
.take_while(|character| matches!(character, '0'..='9' | 'a'..='f'))
.collect()
}
fn post_path(token: &str) -> String {
format!("{POST_PATH}{}", escape(token))
}
fn escape(value: &str) -> String {
utf8_percent_encode(value, PATH_SEGMENT).to_string()
}
fn subscriber_import_body(filename: &str, csv: &[u8]) -> (String, Bytes) {
let boundary = boundary();
let mut body = BytesMut::from(
format!(
"--{boundary}\r\nContent-Disposition: form-data; name=\"{IMPORT_PART}\"; filename=\"{}\"\r\nContent-Type: application/octet-stream\r\n\r\n",
escape_quotes(&import_filename(filename))
)
.as_bytes(),
);
body.extend_from_slice(csv);
body.extend_from_slice(format!("\r\n--{boundary}--\r\n").as_bytes());
(
format!("multipart/form-data; boundary={boundary}"),
body.freeze(),
)
}
fn boundary() -> String {
format!("{:032x}", rand::random::<u128>())
}
#[allow(clippy::case_sensitive_file_extension_comparisons)] fn import_filename(filename: &str) -> String {
if filename.is_empty() {
DEFAULT_IMPORT_FILENAME.to_string()
} else if filename.ends_with(".csv") {
filename.to_string()
} else {
format!("{filename}.csv")
}
}
fn escape_quotes(text: &str) -> String {
text.replace('\\', "\\\\").replace('"', "\\\"")
}