Skip to main content

ghl_sdk/
conversations.rs

1//! Conversations — search threads, read messages, and send SMS/email.
2//!
3//! Access via [`Ghl::conversations`](crate::Ghl::conversations). See the
4//! [full conversations reference][ref] for all 29 v2 endpoints.
5//!
6//! | Method | Endpoint | Scope |
7//! |---|---|---|
8//! | [`ConversationsService::search`] | `GET /conversations/search` | `conversations.readonly` |
9//! | [`ConversationsService::messages`] | `GET /conversations/{id}/messages` | `conversations/message.readonly` |
10//! | [`ConversationsService::send_message`] | `POST /conversations/messages` | `conversations/message.write` |
11//!
12//! Channels accepted by [`SendMessage::message_type`]: `SMS`, `Email`,
13//! `WhatsApp`, `IG`, `FB`, `Custom`, `Live_Chat`.
14//!
15//! # Examples
16//!
17//! ```no_run
18//! # use ghl_sdk::{Ghl, conversations::SendMessage};
19//! # async fn demo(ghl: Ghl, loc: &str, contact_id: String) -> Result<(), ghl_sdk::Error> {
20//! // Find a thread, then read it (newest message first)
21//! let threads = ghl.conversations().search(loc, Some("ada"), 20).await?;
22//! let msgs = ghl.conversations().messages(&threads.conversations[0].id, 50).await?;
23//! for m in &msgs.messages {
24//!     println!("[{:?}] {:?}", m.direction, m.body);
25//! }
26//!
27//! // Send an SMS
28//! ghl.conversations().send_message(SendMessage {
29//!     message_type: "SMS".into(),
30//!     contact_id,
31//!     message: Some("Thanks for reaching out!".into()),
32//!     ..Default::default()
33//! }).await?;
34//! # Ok(()) }
35//! ```
36//!
37//! Email needs a `subject` and usually `html`:
38//!
39//! ```no_run
40//! # use ghl_sdk::{Ghl, conversations::SendMessage};
41//! # async fn demo(ghl: Ghl, contact_id: String) -> Result<(), ghl_sdk::Error> {
42//! ghl.conversations().send_message(SendMessage {
43//!     message_type: "Email".into(),
44//!     contact_id,
45//!     subject: Some("Your August invoice".into()),
46//!     html: Some("<p>Attached.</p>".into()),
47//!     ..Default::default()
48//! }).await?;
49//! # Ok(()) }
50//! ```
51//!
52//! [ref]: https://github.com/Shahroz/ghl-rs/blob/main/docs/api/conversations.md
53
54use reqwest::Method;
55use serde::{Deserialize, Serialize};
56
57use crate::client::Ghl;
58use crate::error::Result;
59
60/// A conversation thread with a contact.
61#[derive(Debug, Clone, Default, Serialize, Deserialize)]
62#[serde(rename_all = "camelCase")]
63#[allow(missing_docs)] // fields mirror the API wire format 1:1
64pub struct Conversation {
65    pub id: String,
66    #[serde(default, skip_serializing_if = "Option::is_none")]
67    pub location_id: Option<String>,
68    #[serde(default, skip_serializing_if = "Option::is_none")]
69    pub contact_id: Option<String>,
70    #[serde(default, skip_serializing_if = "Option::is_none")]
71    pub full_name: Option<String>,
72    #[serde(default, skip_serializing_if = "Option::is_none")]
73    pub last_message_body: Option<String>,
74    #[serde(default, skip_serializing_if = "Option::is_none")]
75    pub last_message_date: Option<serde_json::Value>,
76    #[serde(default, skip_serializing_if = "Option::is_none")]
77    pub last_message_type: Option<String>,
78    #[serde(default, skip_serializing_if = "Option::is_none")]
79    pub unread_count: Option<i64>,
80    /// Any fields this SDK doesn't model yet.
81    #[serde(flatten)]
82    pub extra: serde_json::Map<String, serde_json::Value>,
83}
84
85/// A single message within a conversation.
86#[derive(Debug, Clone, Default, Serialize, Deserialize)]
87#[serde(rename_all = "camelCase")]
88#[allow(missing_docs)] // fields mirror the API wire format 1:1
89pub struct Message {
90    pub id: String,
91    #[serde(default, skip_serializing_if = "Option::is_none")]
92    pub body: Option<String>,
93    /// `SMS`, `Email`, `WhatsApp`, `IG`, `FB`, `Live_Chat`, `CALL`, …
94    #[serde(default, skip_serializing_if = "Option::is_none")]
95    pub message_type: Option<String>,
96    /// `inbound` or `outbound`.
97    #[serde(default, skip_serializing_if = "Option::is_none")]
98    pub direction: Option<String>,
99    #[serde(default, skip_serializing_if = "Option::is_none")]
100    pub status: Option<String>,
101    #[serde(default, skip_serializing_if = "Option::is_none")]
102    pub date_added: Option<String>,
103    #[serde(default, skip_serializing_if = "Option::is_none")]
104    pub conversation_id: Option<String>,
105    /// Any fields this SDK doesn't model yet.
106    #[serde(flatten)]
107    pub extra: serde_json::Map<String, serde_json::Value>,
108}
109
110/// Payload for [`ConversationsService::send_message`].
111#[derive(Debug, Clone, Default, Serialize)]
112#[serde(rename_all = "camelCase")]
113#[allow(missing_docs)] // fields mirror the API wire format 1:1
114pub struct SendMessage {
115    /// Channel: `SMS`, `Email`, `WhatsApp`, `IG`, `FB`, `Custom`, `Live_Chat`.
116    #[serde(rename = "type")]
117    pub message_type: String,
118    pub contact_id: String,
119    #[serde(skip_serializing_if = "Option::is_none")]
120    pub message: Option<String>,
121    /// Email subject (email only).
122    #[serde(skip_serializing_if = "Option::is_none")]
123    pub subject: Option<String>,
124    #[serde(skip_serializing_if = "Option::is_none")]
125    pub html: Option<String>,
126    #[serde(skip_serializing_if = "Vec::is_empty", default)]
127    pub attachments: Vec<String>,
128}
129
130/// Result of sending a message.
131#[derive(Debug, Clone, Deserialize)]
132#[serde(rename_all = "camelCase")]
133#[allow(missing_docs)]
134pub struct SendMessageResult {
135    #[serde(default)]
136    pub conversation_id: Option<String>,
137    #[serde(default)]
138    pub message_id: Option<String>,
139    /// Any fields this SDK doesn't model yet.
140    #[serde(flatten)]
141    pub extra: serde_json::Map<String, serde_json::Value>,
142}
143
144#[derive(Deserialize)]
145struct ConversationSearch {
146    #[serde(default)]
147    conversations: Vec<Conversation>,
148    #[serde(default)]
149    total: Option<i64>,
150}
151
152#[derive(Deserialize)]
153struct MessagesEnvelope {
154    messages: MessagesInner,
155}
156
157#[derive(Deserialize)]
158struct MessagesInner {
159    #[serde(default)]
160    messages: Vec<Message>,
161    #[serde(default, rename = "nextPage")]
162    next_page: Option<bool>,
163}
164
165/// One page of conversations.
166#[derive(Debug, Clone)]
167pub struct ConversationPage {
168    /// The conversations on this page.
169    pub conversations: Vec<Conversation>,
170    /// Total matches, when the API reports it.
171    pub total: Option<i64>,
172}
173
174/// One page of messages within a conversation.
175#[derive(Debug, Clone)]
176pub struct MessagePage {
177    /// Messages, newest first.
178    pub messages: Vec<Message>,
179    /// Whether more pages exist.
180    pub next_page: Option<bool>,
181}
182
183/// Access to the Conversations API. Obtained via [`Ghl::conversations`].
184pub struct ConversationsService {
185    pub(crate) client: Ghl,
186}
187
188impl ConversationsService {
189    pub(crate) fn new(client: Ghl) -> Self {
190        Self { client }
191    }
192
193    /// `GET /conversations/search` — find threads in a location.
194    pub async fn search(
195        &self,
196        location_id: &str,
197        query: Option<&str>,
198        limit: u32,
199    ) -> Result<ConversationPage> {
200        let mut params: Vec<(String, String)> = vec![
201            ("locationId".into(), location_id.to_owned()),
202            ("limit".into(), limit.clamp(1, 100).to_string()),
203        ];
204        if let Some(q) = query {
205            params.push(("query".into(), q.to_owned()));
206        }
207        let result: ConversationSearch = self
208            .client
209            .send(Method::GET, "/conversations/search", &params, None::<&()>)
210            .await?;
211        Ok(ConversationPage {
212            conversations: result.conversations,
213            total: result.total,
214        })
215    }
216
217    /// `GET /conversations/{id}/messages` — messages in a thread, newest first.
218    pub async fn messages(&self, conversation_id: &str, limit: u32) -> Result<MessagePage> {
219        let envelope: MessagesEnvelope = self
220            .client
221            .send(
222                Method::GET,
223                &format!("/conversations/{conversation_id}/messages"),
224                &[("limit".into(), limit.clamp(1, 100).to_string())],
225                None::<&()>,
226            )
227            .await?;
228        Ok(MessagePage {
229            messages: envelope.messages.messages,
230            next_page: envelope.messages.next_page,
231        })
232    }
233
234    /// `POST /conversations/messages` — send an SMS, email, or channel message.
235    pub async fn send_message(&self, message: SendMessage) -> Result<SendMessageResult> {
236        self.client
237            .send(Method::POST, "/conversations/messages", &[], Some(&message))
238            .await
239    }
240}