hey_sdk/services/
world.rs1use std::borrow::Cow;
7
8use bytes::{Bytes, BytesMut};
9use percent_encoding::{AsciiSet, NON_ALPHANUMERIC, utf8_percent_encode};
10
11use crate::client::Client;
12use crate::error::Error;
13use crate::http::Method;
14use crate::observability::OperationInfo;
15use crate::services::write_info;
16
17pub const WORLD_ADDRESS: &str = "world@hey.com";
19
20const POST_PATH: &str = "/world/posts/";
22
23const IMPORT_PART: &str = "world_list_import[source]";
25
26const DEFAULT_IMPORT_FILENAME: &str = "subscribers.csv";
28
29const PATH_SEGMENT: &AsciiSet = &NON_ALPHANUMERIC
32 .remove(b'-')
33 .remove(b'_')
34 .remove(b'.')
35 .remove(b'~');
36
37pub struct World<'a> {
39 client: &'a Client,
40}
41
42impl Client {
43 pub fn world(&self) -> World<'_> {
45 World::new(self)
46 }
47}
48
49impl<'a> World<'a> {
50 pub(crate) fn new(client: &'a Client) -> World<'a> {
51 World { client }
52 }
53
54 pub fn client(&self) -> &'a Client {
56 self.client
57 }
58
59 pub async fn publish(&self, subject: &str, content: &str) -> Result<String, Error> {
62 let sender_id = self.client.default_sender_id().await?.to_string();
63 let mut operation = self.client.form(Method::POST, "/messages")?;
64 operation.info(write_info("World", "PublishWorldPost", "world_post", None));
65 operation.form(&[
66 ("acting_sender_id", sender_id.as_str()),
67 ("message[subject]", subject),
68 ("message[content]", content),
69 ("entry[addressed][directly]", WORLD_ADDRESS),
70 ("entry[status]", "active"),
71 ]);
72
73 let sent = self.client.send_form(operation).await?;
74 let location = sent.location.unwrap_or_default();
75 post_token(&location).ok_or_else(|| {
76 Error::api(
77 0,
78 format!(
79 "the message was sent but did not become a HEY World post (landed on {location:?})"
80 ),
81 )
82 })
83 }
84
85 pub async fn update_post(
88 &self,
89 token: &str,
90 subject: &str,
91 content: &str,
92 ) -> Result<(), Error> {
93 let mut fields = Vec::new();
94 if !subject.is_empty() {
95 fields.push(("world_post[subject]", subject));
96 }
97 if !content.is_empty() {
98 fields.push(("world_post[content]", content));
99 }
100
101 let mut operation = self.client.form(Method::PATCH, &post_path(token))?;
102 operation.info(write_info("World", "UpdateWorldPost", "world_post", None));
103 operation.form(&fields);
104 self.client.send_unit(operation).await
105 }
106
107 pub async fn delete_post(&self, token: &str) -> Result<(), Error> {
109 let mut operation = self.client.form(Method::DELETE, &post_path(token))?;
110 operation.info(write_info("World", "DeleteWorldPost", "world_post", None));
111 self.client.send_unit(operation).await
112 }
113
114 pub async fn export_subscribers(&self, list_email_address: &str) -> Result<Bytes, Error> {
117 let path = format!("/world/lists/{}/export.csv", escape(list_email_address));
118 let mut operation = self.client.csv(&path)?;
119 operation.info(OperationInfo {
120 service: Cow::Borrowed("World"),
121 operation: Cow::Borrowed("ExportWorldSubscribers"),
122 resource_type: Cow::Borrowed("world_list"),
123 is_mutation: false,
124 resource_id: None,
125 });
126 Ok(self.client.execute(operation).await?.body)
127 }
128
129 pub async fn import_subscribers(
133 &self,
134 list_email_address: &str,
135 filename: &str,
136 csv: &[u8],
137 ) -> Result<(), Error> {
138 let (content_type, body) = subscriber_import_body(filename, csv);
139 let mut operation = self.client.form(
140 Method::POST,
141 &format!("/world/lists/{}/imports", escape(list_email_address)),
142 )?;
143 operation.info(write_info(
144 "World",
145 "ImportWorldSubscribers",
146 "world_list",
147 None,
148 ));
149 operation.multipart(content_type, body);
150 self.client.send_unit(operation).await
151 }
152}
153
154fn post_token(location: &str) -> Option<String> {
158 location
159 .match_indices(POST_PATH)
160 .map(|(at, _)| hex_run(&location[at + POST_PATH.len()..]))
161 .find(|token| !token.is_empty())
162}
163
164fn hex_run(text: &str) -> String {
165 text.chars()
166 .take_while(|character| matches!(character, '0'..='9' | 'a'..='f'))
167 .collect()
168}
169
170fn post_path(token: &str) -> String {
171 format!("{POST_PATH}{}", escape(token))
172}
173
174fn escape(value: &str) -> String {
175 utf8_percent_encode(value, PATH_SEGMENT).to_string()
176}
177
178fn subscriber_import_body(filename: &str, csv: &[u8]) -> (String, Bytes) {
181 let boundary = boundary();
182 let mut body = BytesMut::from(
183 format!(
184 "--{boundary}\r\nContent-Disposition: form-data; name=\"{IMPORT_PART}\"; filename=\"{}\"\r\nContent-Type: application/octet-stream\r\n\r\n",
185 escape_quotes(&import_filename(filename))
186 )
187 .as_bytes(),
188 );
189 body.extend_from_slice(csv);
190 body.extend_from_slice(format!("\r\n--{boundary}--\r\n").as_bytes());
191 (
192 format!("multipart/form-data; boundary={boundary}"),
193 body.freeze(),
194 )
195}
196
197fn boundary() -> String {
198 format!("{:032x}", rand::random::<u128>())
199}
200
201#[allow(clippy::case_sensitive_file_extension_comparisons)] fn import_filename(filename: &str) -> String {
203 if filename.is_empty() {
204 DEFAULT_IMPORT_FILENAME.to_string()
205 } else if filename.ends_with(".csv") {
206 filename.to_string()
207 } else {
208 format!("{filename}.csv")
209 }
210}
211
212fn escape_quotes(text: &str) -> String {
215 text.replace('\\', "\\\\").replace('"', "\\\"")
216}