From 68389d9bb2e2e57500abbf7ece783f3bbf3a78ec Mon Sep 17 00:00:00 2001 From: Radmir Date: Tue, 26 May 2026 22:36:02 +0500 Subject: [PATCH 1/3] feat(support): add support-chat domain models MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit TypeShare types for the upcoming device support-chat API: SupportConversation, SupportMessage, SupportAgent, and status enums. Models only — no backend logic. --- crates/primitives/src/lib.rs | 3 ++ crates/primitives/src/support.rs | 65 ++++++++++++++++++++++++++++++++ 2 files changed, 68 insertions(+) create mode 100644 crates/primitives/src/support.rs diff --git a/crates/primitives/src/lib.rs b/crates/primitives/src/lib.rs index 2fcc69223..69ba6515c 100644 --- a/crates/primitives/src/lib.rs +++ b/crates/primitives/src/lib.rs @@ -324,5 +324,8 @@ pub use self::metrics::{ConsumerStatus, ParserStatus, ReportedError}; pub mod value_access; pub use self::value_access::{JsonDecode, ValueAccess}; +pub mod support; +pub use self::support::{SupportAgent, SupportConversation, SupportConversationStatus, SupportMessage, SupportMessageDeliveryStatus, SupportMessageDirection}; + #[cfg(any(test, feature = "testkit"))] pub mod testkit; diff --git a/crates/primitives/src/support.rs b/crates/primitives/src/support.rs new file mode 100644 index 000000000..059100814 --- /dev/null +++ b/crates/primitives/src/support.rs @@ -0,0 +1,65 @@ +use chrono::{DateTime, Utc}; +use serde::{Deserialize, Serialize}; +use typeshare::typeshare; + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[typeshare(swift = "Equatable, CaseIterable, Sendable")] +#[serde(rename_all = "lowercase")] +pub enum SupportConversationStatus { + Open, + Resolved, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[typeshare(swift = "Equatable, CaseIterable, Sendable")] +#[serde(rename_all = "lowercase")] +pub enum SupportMessageDirection { + Incoming, + Outgoing, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[typeshare(swift = "Equatable, CaseIterable, Sendable")] +#[serde(rename_all = "lowercase")] +pub enum SupportMessageDeliveryStatus { + Sending, + Sent, + Failed, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[typeshare(swift = "Sendable, Equatable")] +#[serde(rename_all = "camelCase")] +pub struct SupportAgent { + pub name: String, + #[serde(skip_serializing_if = "Option::is_none")] + pub avatar_url: Option, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[typeshare(swift = "Sendable, Equatable, Hashable, Identifiable")] +#[serde(rename_all = "camelCase")] +pub struct SupportConversation { + pub id: String, + pub status: SupportConversationStatus, + #[serde(skip_serializing_if = "Option::is_none")] + pub first_message: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub last_message: Option, + pub last_activity_at: DateTime, + pub unread_count: i32, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[typeshare(swift = "Sendable, Equatable")] +#[serde(rename_all = "camelCase")] +pub struct SupportMessage { + pub id: String, + pub conversation_id: String, + pub content: String, + pub direction: SupportMessageDirection, + pub delivery_status: SupportMessageDeliveryStatus, + #[serde(skip_serializing_if = "Option::is_none")] + pub agent: Option, + pub created_at: DateTime, +} From 46d0e27f03eb0fbeacc9d3607c73b58a9627cf83 Mon Sep 17 00:00:00 2001 From: Radmir Date: Tue, 26 May 2026 23:02:11 +0500 Subject: [PATCH 2/3] refactor(support): replace direction + optional agent with sender sum type Per review: agent shouldn't be optional. New `SupportMessageSender { User, Agent(SupportAgent) }` ties agent info to the agent case; `SupportMessageDirection` is removed (sender encodes direction). --- crates/primitives/src/lib.rs | 2 +- crates/primitives/src/support.rs | 20 +++++++++----------- 2 files changed, 10 insertions(+), 12 deletions(-) diff --git a/crates/primitives/src/lib.rs b/crates/primitives/src/lib.rs index 69ba6515c..e0c913ade 100644 --- a/crates/primitives/src/lib.rs +++ b/crates/primitives/src/lib.rs @@ -325,7 +325,7 @@ pub mod value_access; pub use self::value_access::{JsonDecode, ValueAccess}; pub mod support; -pub use self::support::{SupportAgent, SupportConversation, SupportConversationStatus, SupportMessage, SupportMessageDeliveryStatus, SupportMessageDirection}; +pub use self::support::{SupportAgent, SupportConversation, SupportConversationStatus, SupportMessage, SupportMessageDeliveryStatus, SupportMessageSender}; #[cfg(any(test, feature = "testkit"))] pub mod testkit; diff --git a/crates/primitives/src/support.rs b/crates/primitives/src/support.rs index 059100814..dca046df8 100644 --- a/crates/primitives/src/support.rs +++ b/crates/primitives/src/support.rs @@ -10,14 +10,6 @@ pub enum SupportConversationStatus { Resolved, } -#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] -#[typeshare(swift = "Equatable, CaseIterable, Sendable")] -#[serde(rename_all = "lowercase")] -pub enum SupportMessageDirection { - Incoming, - Outgoing, -} - #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] #[typeshare(swift = "Equatable, CaseIterable, Sendable")] #[serde(rename_all = "lowercase")] @@ -36,6 +28,14 @@ pub struct SupportAgent { pub avatar_url: Option, } +#[derive(Debug, Clone, Serialize, Deserialize)] +#[typeshare(swift = "Equatable, Sendable")] +#[serde(tag = "type", content = "data", rename_all = "lowercase")] +pub enum SupportMessageSender { + User, + Agent(SupportAgent), +} + #[derive(Debug, Clone, Serialize, Deserialize)] #[typeshare(swift = "Sendable, Equatable, Hashable, Identifiable")] #[serde(rename_all = "camelCase")] @@ -57,9 +57,7 @@ pub struct SupportMessage { pub id: String, pub conversation_id: String, pub content: String, - pub direction: SupportMessageDirection, + pub sender: SupportMessageSender, pub delivery_status: SupportMessageDeliveryStatus, - #[serde(skip_serializing_if = "Option::is_none")] - pub agent: Option, pub created_at: DateTime, } From 9539174d59a21ac05c5a92e2d330072ea5f9c97f Mon Sep 17 00:00:00 2001 From: gemcoder21 <104884878+gemcoder21@users.noreply.github.com> Date: Wed, 27 May 2026 15:43:12 +0000 Subject: [PATCH 3/3] Add support progress --- Cargo.lock | 4 + Settings.yaml | 4 + apps/api/Cargo.toml | 1 + apps/api/src/main.rs | 9 + apps/api/src/support.rs | 121 ++++++++ apps/daemon/src/consumers/support/mod.rs | 4 +- .../support/support_webhook_consumer.rs | 21 +- crates/cacher/src/keys.rs | 3 + crates/primitives/src/lib.rs | 8 +- crates/primitives/src/stream.rs | 3 +- crates/primitives/src/support.rs | 54 +++- crates/settings/src/lib.rs | 7 + crates/support/Cargo.toml | 3 + crates/support/src/chatwoot.rs | 193 +++++++++++++ crates/support/src/client.rs | 54 +++- crates/support/src/error.rs | 55 ++++ crates/support/src/lib.rs | 6 +- crates/support/src/model.rs | 270 +++++++++++++++++- crates/support/tests/model_tests.rs | 26 ++ gemstone/src/api_client/mod.rs | 68 ++++- gemstone/src/gateway/mod.rs | 30 +- gemstone/src/models/mod.rs | 2 + gemstone/src/models/support.rs | 76 +++++ 23 files changed, 992 insertions(+), 30 deletions(-) create mode 100644 apps/api/src/support.rs create mode 100644 crates/support/src/chatwoot.rs create mode 100644 crates/support/src/error.rs create mode 100644 gemstone/src/models/support.rs diff --git a/Cargo.lock b/Cargo.lock index 811d8376a..622f18cdc 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -640,6 +640,7 @@ dependencies = [ "storage", "streamer", "strum", + "support", "swapper", "tokio", "unic-langid", @@ -7320,8 +7321,11 @@ dependencies = [ name = "support" version = "1.0.0" dependencies = [ + "cacher", + "chrono", "localizer", "primitives", + "reqwest 0.13.4", "serde", "serde_json", "storage", diff --git a/Settings.yaml b/Settings.yaml index 988c8885b..37ea1380a 100644 --- a/Settings.yaml +++ b/Settings.yaml @@ -220,6 +220,10 @@ scan: public: "" secret: "" +support: + url: "https://support.gemwallet.com" + website_token: "GkTYRSL8msUHt3qcZzDxoDNJ" + parser: timeout: 1s shutdown: diff --git a/apps/api/Cargo.toml b/apps/api/Cargo.toml index 7ed46b049..a9c1f332e 100644 --- a/apps/api/Cargo.toml +++ b/apps/api/Cargo.toml @@ -24,6 +24,7 @@ reqwest = { workspace = true } gem_tracing = { path = "../../crates/tracing" } storage = { path = "../../crates/storage" } +support = { path = "../../crates/support" } pricer = { path = "../../crates/pricer" } prices = { path = "../../crates/prices" } fiat = { path = "../../crates/fiat" } diff --git a/apps/api/src/main.rs b/apps/api/src/main.rs index 7fcb92557..20ee533a9 100644 --- a/apps/api/src/main.rs +++ b/apps/api/src/main.rs @@ -14,6 +14,7 @@ mod prices; mod referral; mod responders; mod status; +mod support; mod swap; mod webhooks; mod websocket; @@ -51,6 +52,7 @@ use settings::Settings; use settings_chain::{ChainProviders, ProviderFactory}; use storage::Database; use streamer::{StreamProducer, StreamProducerConfig}; +use support::SupportApiClient; use swap::SwapClient; use swapper::okx::{OkxClientConfig, OkxProvider}; use swapper::swapper::GemSwapper; @@ -98,6 +100,11 @@ fn mount_routes(rocket: Rocket, admin_enabled: bool) -> Rocket { chain::transaction::get_transaction, chain::transaction::get_transaction_status, referral::get_rewards_leaderboard, + support::get_support_conversation, + support::get_support_messages, + support::post_support_message, + support::post_support_typing, + support::post_support_last_seen, swap::post_near_intents_quote, swap::okx::post_okx_quote, swap::okx::post_okx_quote_data, @@ -237,6 +244,7 @@ async fn rocket_api(settings: Settings) -> Result, Box Result, Box Self { + Self { + chatwoot: ChatwootClient::new(url, website_token), + cacher, + } + } + + pub async fn conversation(&self, device: &DeviceRow) -> Result, Box> { + self.with_session(device, |session| async move { self.chatwoot.conversation(&session).await }).await + } + + pub async fn messages(&self, device: &DeviceRow, before: Option, after: Option) -> Result, Box> { + self.with_session(device, |session| async move { self.chatwoot.messages(&session, before, after).await }) + .await + } + + pub async fn send_message(&self, device: &DeviceRow, input: SupportMessageInput) -> Result> { + let content = input.content; + self.with_session(device, |session| { + let content = content.clone(); + async move { self.chatwoot.send_message(&session, content).await } + }) + .await + } + + pub async fn set_typing(&self, device: &DeviceRow, typing: SupportTyping) -> Result> { + let status = typing.status; + self.with_session(device, |session| { + let status = status.clone(); + async move { self.chatwoot.set_typing(&session, status).await } + }) + .await + } + + pub async fn update_last_seen(&self, device: &DeviceRow) -> Result> { + self.with_session(device, |session| async move { self.chatwoot.update_last_seen(&session).await }).await + } + + async fn with_session(&self, device: &DeviceRow, call: F) -> Result> + where + F: Fn(ChatwootSession) -> Fut, + Fut: Future>, + { + let session = self.chatwoot_session(device).await?; + match call(session).await { + Ok(value) => Ok(value), + Err(error) if error.is_session_error() => { + let session = self.refresh_session(device).await?; + Ok(call(session).await?) + } + Err(error) => Err(Box::new(error)), + } + } + + async fn chatwoot_session(&self, device: &DeviceRow) -> Result> { + let cache_key = CacheKey::SupportDeviceSession(&device.device_id); + if let Some(session) = self.cacher.get_cached_optional(cache_key).await? { + return Ok(session); + } + self.refresh_session(device).await + } + + async fn refresh_session(&self, device: &DeviceRow) -> Result> { + let cache_key = CacheKey::SupportDeviceSession(&device.device_id); + let session = self.chatwoot.create_session(&device.as_primitive()).await?; + self.cacher.set_cached(cache_key, &session).await?; + Ok(session) + } +} + +#[get("/support")] +pub async fn get_support_conversation(device: AuthenticatedDevice, client: &State>) -> Result>, ApiError> { + Ok(client.lock().await.conversation(&device.device_row).await?.into()) +} + +#[get("/support/messages?&")] +pub async fn get_support_messages( + device: AuthenticatedDevice, + before: Option, + after: Option, + client: &State>, +) -> Result>, ApiError> { + Ok(client.lock().await.messages(&device.device_row, before, after).await?.into()) +} + +#[post("/support/messages", format = "json", data = "")] +pub async fn post_support_message( + device: AuthenticatedDevice, + input: Json, + client: &State>, +) -> Result, ApiError> { + Ok(client.lock().await.send_message(&device.device_row, input.into_inner()).await?.into()) +} + +#[post("/support/typing", format = "json", data = "")] +pub async fn post_support_typing(device: AuthenticatedDevice, typing: Json, client: &State>) -> Result, ApiError> { + Ok(client.lock().await.set_typing(&device.device_row, typing.into_inner()).await?.into()) +} + +#[post("/support/last_seen")] +pub async fn post_support_last_seen(device: AuthenticatedDevice, client: &State>) -> Result, ApiError> { + Ok(client.lock().await.update_last_seen(&device.device_row).await?.into()) +} diff --git a/apps/daemon/src/consumers/support/mod.rs b/apps/daemon/src/consumers/support/mod.rs index 998ccf916..9d962a574 100644 --- a/apps/daemon/src/consumers/support/mod.rs +++ b/apps/daemon/src/consumers/support/mod.rs @@ -3,6 +3,7 @@ pub mod support_webhook_consumer; use std::error::Error; use std::sync::Arc; +use cacher::CacherClient; use settings::Settings; use storage::Database; use streamer::{ConsumerStatusReporter, QueueName, ShutdownReceiver, StreamProducer, StreamProducerConfig, SupportWebhookPayload, run_consumer}; @@ -18,8 +19,9 @@ pub async fn run_consumer_support(settings: Settings, shutdown_rx: ShutdownRecei let retry = streamer::Retry::new(settings.rabbitmq.retry.delay, settings.rabbitmq.retry.timeout); let rabbitmq_config = StreamProducerConfig::new(settings.rabbitmq.url.clone(), retry); let stream_producer = StreamProducer::new(&rabbitmq_config, "daemon_support_producer", shutdown_rx.clone()).await?; + let cacher = CacherClient::new(&settings.redis.url).await; - let support_client = SupportClient::new(database, stream_producer); + let support_client = SupportClient::new(database, stream_producer, cacher); let consumer = SupportWebhookConsumer::new(support_client); let queue = QueueName::SupportWebhooks; diff --git a/apps/daemon/src/consumers/support/support_webhook_consumer.rs b/apps/daemon/src/consumers/support/support_webhook_consumer.rs index 40a7f1c83..6b43ec47c 100644 --- a/apps/daemon/src/consumers/support/support_webhook_consumer.rs +++ b/apps/daemon/src/consumers/support/support_webhook_consumer.rs @@ -6,7 +6,7 @@ use streamer::SupportWebhookPayload; use streamer::consumer::MessageConsumer; use primitives::Device; -use support::{ChatwootWebhookPayload, EVENT_CONVERSATION_STATUS_CHANGED, EVENT_CONVERSATION_UPDATED, EVENT_MESSAGE_CREATED, SupportClient}; +use support::{ChatwootWebhookPayload, EVENT_CONVERSATION_STATUS_CHANGED, EVENT_CONVERSATION_UPDATED, EVENT_MESSAGE_CREATED, SupportClient, SupportProcessResult}; pub struct SupportWebhookConsumer { support_client: SupportClient, @@ -17,11 +17,14 @@ impl SupportWebhookConsumer { Self { support_client } } - async fn process_notification(&self, device: &Device, webhook: &ChatwootWebhookPayload) -> Result> { + async fn process_notification(&self, device: &Device, webhook: &ChatwootWebhookPayload) -> Result> { match webhook.event.as_str() { EVENT_MESSAGE_CREATED => self.support_client.handle_message_created(device, webhook).await, - EVENT_CONVERSATION_UPDATED | EVENT_CONVERSATION_STATUS_CHANGED => self.support_client.handle_conversation_updated(webhook).map(|_| 0), - _ => Ok(0), + EVENT_CONVERSATION_UPDATED | EVENT_CONVERSATION_STATUS_CHANGED => self.support_client.handle_conversation_updated(device, webhook).await, + _ => Ok(SupportProcessResult { + notifications: 0, + stream_events: 0, + }), } } } @@ -52,8 +55,14 @@ impl MessageConsumer for SupportWebhookConsumer { }; match self.process_notification(&device, &webhook).await { - Ok(notifications) => { - info_with_fields!("support webhook processed", device_id = device_id, event = webhook.event, notifications = notifications); + Ok(result) => { + info_with_fields!( + "support webhook processed", + device_id = device_id, + event = webhook.event, + notifications = result.notifications, + stream_events = result.stream_events + ); Ok(true) } Err(error) => { diff --git a/crates/cacher/src/keys.rs b/crates/cacher/src/keys.rs index b4a9ffa7b..01aecc065 100644 --- a/crates/cacher/src/keys.rs +++ b/crates/cacher/src/keys.rs @@ -14,6 +14,7 @@ pub enum CacheKey<'a> { // Device keys InactiveDeviceObserver(&'a str), + SupportDeviceSession(&'a str), // Fetch consumer keys (chain, address) FetchCoinAddresses(&'a str, &'a str), @@ -79,6 +80,7 @@ impl CacheKey<'_> { Self::UsernameCreationGlobalDaily => "username:global:daily".to_string(), Self::UsernameCreationPerCountryDaily(country) => format!("username:country:daily:{}", country), Self::InactiveDeviceObserver(device_id) => format!("device:inactive_observer:{}", device_id), + Self::SupportDeviceSession(device_id) => format!("support:device_session:{}", device_id), Self::FetchCoinAddresses(chain, address) => format!("fetch:coin_addresses:{}:{}", chain, address), Self::FetchTokenAddresses(chain, address) => format!("fetch:token_addresses:{}:{}", chain, address), Self::FetchNftAssetsAddresses(chain, address) => format!("fetch:nft_assets_addresses:{}:{}", chain, address), @@ -117,6 +119,7 @@ impl CacheKey<'_> { Self::UsernameCreationGlobalDaily => SECONDS_PER_DAY, Self::UsernameCreationPerCountryDaily(_) => SECONDS_PER_DAY, Self::InactiveDeviceObserver(_) => 30 * SECONDS_PER_DAY, + Self::SupportDeviceSession(_) => 170 * SECONDS_PER_DAY, Self::FetchCoinAddresses(_, _) => 7 * SECONDS_PER_DAY, Self::FetchTokenAddresses(_, _) => 30 * SECONDS_PER_DAY, Self::FetchNftAssetsAddresses(_, _) => 30 * SECONDS_PER_DAY, diff --git a/crates/primitives/src/lib.rs b/crates/primitives/src/lib.rs index e0c913ade..b3dd01879 100644 --- a/crates/primitives/src/lib.rs +++ b/crates/primitives/src/lib.rs @@ -224,6 +224,11 @@ pub mod websocket; pub use self::websocket::{WebSocketPriceAction, WebSocketPriceActionType, WebSocketPricePayload}; pub mod stream; pub use self::stream::{StreamBalanceUpdate, StreamEvent, StreamMessage, StreamMessagePrices, StreamTransactionsUpdate, StreamWalletUpdate, device_stream_channel}; +pub mod support; +pub use self::support::{ + SupportAgent, SupportConversation, SupportConversationStatus, SupportMessage, SupportMessageDeliveryStatus, SupportMessageInput, SupportMessageSender, SupportStreamEvent, + SupportTyping, SupportTypingStatus, +}; pub mod asset_balance; pub use self::asset_balance::{AddressBalances, AssetBalance, Balance}; pub mod chain_address; @@ -324,8 +329,5 @@ pub use self::metrics::{ConsumerStatus, ParserStatus, ReportedError}; pub mod value_access; pub use self::value_access::{JsonDecode, ValueAccess}; -pub mod support; -pub use self::support::{SupportAgent, SupportConversation, SupportConversationStatus, SupportMessage, SupportMessageDeliveryStatus, SupportMessageSender}; - #[cfg(any(test, feature = "testkit"))] pub mod testkit; diff --git a/crates/primitives/src/stream.rs b/crates/primitives/src/stream.rs index 02474fc29..39720e8b1 100644 --- a/crates/primitives/src/stream.rs +++ b/crates/primitives/src/stream.rs @@ -1,7 +1,7 @@ use serde::{Deserialize, Serialize}; use typeshare::typeshare; -use crate::{AssetId, InAppNotification, TransactionId, WalletId, WebSocketPricePayload}; +use crate::{AssetId, InAppNotification, SupportStreamEvent, TransactionId, WalletId, WebSocketPricePayload}; pub const DEVICE_STREAM_CHANNEL_PREFIX: &str = "stream:device:"; @@ -22,6 +22,7 @@ pub enum StreamEvent { Perpetual(StreamWalletUpdate), InAppNotification(StreamNotificationlUpdate), FiatTransaction(StreamWalletUpdate), + Support(SupportStreamEvent), } #[derive(Debug, Clone, Serialize, Deserialize)] diff --git a/crates/primitives/src/support.rs b/crates/primitives/src/support.rs index dca046df8..6f8e7d788 100644 --- a/crates/primitives/src/support.rs +++ b/crates/primitives/src/support.rs @@ -19,7 +19,7 @@ pub enum SupportMessageDeliveryStatus { Failed, } -#[derive(Debug, Clone, Serialize, Deserialize)] +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] #[typeshare(swift = "Sendable, Equatable")] #[serde(rename_all = "camelCase")] pub struct SupportAgent { @@ -28,7 +28,7 @@ pub struct SupportAgent { pub avatar_url: Option, } -#[derive(Debug, Clone, Serialize, Deserialize)] +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] #[typeshare(swift = "Equatable, Sendable")] #[serde(tag = "type", content = "data", rename_all = "lowercase")] pub enum SupportMessageSender { @@ -36,7 +36,23 @@ pub enum SupportMessageSender { Agent(SupportAgent), } -#[derive(Debug, Clone, Serialize, Deserialize)] +impl SupportMessageSender { + pub fn is_user(&self) -> bool { + match self { + Self::User => true, + Self::Agent(_) => false, + } + } + + pub fn is_agent(&self) -> bool { + match self { + Self::User => false, + Self::Agent(_) => true, + } + } +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] #[typeshare(swift = "Sendable, Equatable, Hashable, Identifiable")] #[serde(rename_all = "camelCase")] pub struct SupportConversation { @@ -50,7 +66,7 @@ pub struct SupportConversation { pub unread_count: i32, } -#[derive(Debug, Clone, Serialize, Deserialize)] +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] #[typeshare(swift = "Sendable, Equatable")] #[serde(rename_all = "camelCase")] pub struct SupportMessage { @@ -61,3 +77,33 @@ pub struct SupportMessage { pub delivery_status: SupportMessageDeliveryStatus, pub created_at: DateTime, } + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[typeshare(swift = "Sendable, Equatable")] +#[serde(rename_all = "camelCase")] +pub struct SupportMessageInput { + pub content: String, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[typeshare(swift = "Equatable, CaseIterable, Sendable")] +#[serde(rename_all = "lowercase")] +pub enum SupportTypingStatus { + On, + Off, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[typeshare(swift = "Sendable, Equatable")] +#[serde(rename_all = "camelCase")] +pub struct SupportTyping { + pub status: SupportTypingStatus, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(tag = "type", content = "data", rename_all = "camelCase")] +#[typeshare(swift = "Sendable")] +pub enum SupportStreamEvent { + Message(SupportMessage), + Conversation(SupportConversation), +} diff --git a/crates/settings/src/lib.rs b/crates/settings/src/lib.rs index 33199a1bb..78754e19d 100644 --- a/crates/settings/src/lib.rs +++ b/crates/settings/src/lib.rs @@ -36,6 +36,7 @@ pub struct Settings { pub chains: Chains, pub pusher: Pusher, pub scan: Scan, + pub support: Support, pub nft: NFT, pub ankr: Ankr, pub trongrid: Trongrid, @@ -332,6 +333,12 @@ pub struct Scan { pub goplus: UrlKeySettings, } +#[derive(Debug, Deserialize, Clone)] +pub struct Support { + pub url: String, + pub website_token: String, +} + impl Settings { pub fn new() -> Result { let current_dir = env::current_dir().unwrap(); diff --git a/crates/support/Cargo.toml b/crates/support/Cargo.toml index e86bb7962..bee10e09a 100644 --- a/crates/support/Cargo.toml +++ b/crates/support/Cargo.toml @@ -4,8 +4,11 @@ edition = { workspace = true } version = { workspace = true } [dependencies] +chrono = { workspace = true } +reqwest = { workspace = true } serde = { workspace = true } serde_json = { workspace = true } +cacher = { path = "../cacher" } localizer = { path = "../localizer" } primitives = { path = "../primitives" } storage = { path = "../storage" } diff --git a/crates/support/src/chatwoot.rs b/crates/support/src/chatwoot.rs new file mode 100644 index 000000000..fafc27373 --- /dev/null +++ b/crates/support/src/chatwoot.rs @@ -0,0 +1,193 @@ +use primitives::{Device, SupportConversation, SupportMessage, SupportTypingStatus}; +use reqwest::header::{HeaderMap, HeaderName, HeaderValue}; +use reqwest::{Client, Response}; +use serde::de::DeserializeOwned; + +use crate::{ + ChatwootConfigResponse, ChatwootContactResponse, ChatwootContactUpdate, ChatwootConversationResponse, ChatwootError, ChatwootMessageInput, ChatwootMessagesResponse, + ChatwootSession, ChatwootTypingInput, Message, support_conversation_status, support_messages, timestamp, unread_count, +}; + +const PATH_CONFIG: &str = "config"; +const PATH_CONTACT_SET_USER: &str = "contact/set_user"; +const PATH_CONVERSATIONS: &str = "conversations"; +const PATH_MESSAGES: &str = "messages"; +const PATH_TOGGLE_TYPING: &str = "conversations/toggle_typing"; +const PATH_UPDATE_LAST_SEEN: &str = "conversations/update_last_seen"; + +#[derive(Clone)] +pub struct ChatwootClient { + client: Client, + url: String, + website_token: String, +} + +impl ChatwootClient { + pub fn new(url: String, website_token: String) -> Self { + Self { + client: Client::new(), + url: url.trim_end_matches('/').to_string(), + website_token, + } + } + + pub async fn create_session(&self, device: &Device) -> Result { + let response: ChatwootConfigResponse = self + .json( + self.client + .post(self.widget_url(PATH_CONFIG)) + .query(&[("website_token", self.website_token.as_str())]) + .send() + .await?, + ) + .await?; + + let update = ChatwootContactUpdate::new(device); + let contact: ChatwootContactResponse = self + .json( + self.client + .patch(self.widget_url(PATH_CONTACT_SET_USER)) + .query(&[("website_token", self.website_token.as_str())]) + .headers(self.auth_headers(&response.website_channel_config.auth_token)?) + .json(&update) + .send() + .await?, + ) + .await?; + + Ok(ChatwootSession { + auth_token: contact.widget_auth_token.unwrap_or(response.website_channel_config.auth_token), + }) + } + + pub async fn conversation(&self, session: &ChatwootSession) -> Result, ChatwootError> { + let conversation: ChatwootConversationResponse = self + .json( + self.client + .get(self.widget_url(PATH_CONVERSATIONS)) + .query(&[("website_token", self.website_token.as_str())]) + .headers(self.auth_headers(&session.auth_token)?) + .send() + .await?, + ) + .await?; + + let Some(id) = conversation.id else { + return Ok(None); + }; + + let messages = self.messages(session, None, None).await?; + let first_message = messages.iter().find(|message| message.sender.is_user()).map(|message| message.content.clone()); + let last_message = messages.last().map(|message| message.content.clone()); + let last_activity_at = messages + .last() + .map(|message| message.created_at) + .or_else(|| conversation.contact_last_seen_at.and_then(timestamp)) + .ok_or_else(|| ChatwootError::invalid_response("conversation has no activity timestamp"))?; + + Ok(Some(SupportConversation { + id: id.to_string(), + status: support_conversation_status(conversation.status.as_deref()), + first_message, + last_message, + last_activity_at, + unread_count: unread_count(&messages, conversation.contact_last_seen_at), + })) + } + + pub async fn messages(&self, session: &ChatwootSession, before: Option, after: Option) -> Result, ChatwootError> { + let mut query = vec![("website_token", self.website_token.clone())]; + if let Some(before) = before { + query.push(("before", before.to_string())); + } + if let Some(after) = after { + query.push(("after", after.to_string())); + } + + let response: ChatwootMessagesResponse = self + .json( + self.client + .get(self.widget_url(PATH_MESSAGES)) + .query(&query) + .headers(self.auth_headers(&session.auth_token)?) + .send() + .await?, + ) + .await?; + + Ok(support_messages(&response.payload)) + } + + pub async fn send_message(&self, session: &ChatwootSession, content: String) -> Result { + let message: Message = self + .json( + self.client + .post(self.widget_url(PATH_MESSAGES)) + .query(&[("website_token", self.website_token.as_str())]) + .headers(self.auth_headers(&session.auth_token)?) + .json(&ChatwootMessageInput::new(content)) + .send() + .await?, + ) + .await?; + + message + .support_message() + .ok_or_else(|| ChatwootError::invalid_response("message response is not a public text message")) + } + + pub async fn set_typing(&self, session: &ChatwootSession, status: SupportTypingStatus) -> Result { + self.empty( + self.client + .post(self.widget_url(PATH_TOGGLE_TYPING)) + .query(&[("website_token", self.website_token.as_str())]) + .headers(self.auth_headers(&session.auth_token)?) + .json(&ChatwootTypingInput::new(status)) + .send() + .await?, + ) + .await + } + + pub async fn update_last_seen(&self, session: &ChatwootSession) -> Result { + self.empty( + self.client + .post(self.widget_url(PATH_UPDATE_LAST_SEEN)) + .query(&[("website_token", self.website_token.as_str())]) + .headers(self.auth_headers(&session.auth_token)?) + .send() + .await?, + ) + .await + } + + fn widget_url(&self, path: &str) -> String { + format!("{}/api/v1/widget/{}", self.url, path) + } + + fn auth_headers(&self, token: &str) -> Result { + let value = HeaderValue::from_str(token).map_err(|error| ChatwootError::invalid_response(error.to_string()))?; + let mut headers = HeaderMap::new(); + headers.insert(HeaderName::from_static("x-auth-token"), value); + Ok(headers) + } + + async fn empty(&self, response: Response) -> Result { + self.check_status(response).await?; + Ok(true) + } + + async fn json(&self, response: Response) -> Result { + let response = self.check_status(response).await?; + Ok(response.json::().await?) + } + + async fn check_status(&self, response: Response) -> Result { + if response.status().is_success() { + return Ok(response); + } + let status = response.status().as_u16(); + let message = response.text().await?; + Err(ChatwootError::http(status, message)) + } +} diff --git a/crates/support/src/client.rs b/crates/support/src/client.rs index a97a3d47a..8788ea678 100644 --- a/crates/support/src/client.rs +++ b/crates/support/src/client.rs @@ -1,6 +1,9 @@ use crate::ChatwootWebhookPayload; +use cacher::CacherClient; use localizer::LanguageLocalizer; -use primitives::{Device, GorushNotification, PushNotification, PushNotificationTypes, push_notification::PushNotificationSupport}; +use primitives::{ + Device, GorushNotification, PushNotification, PushNotificationTypes, StreamEvent, SupportStreamEvent, device_stream_channel, push_notification::PushNotificationSupport, +}; use std::error::Error; use storage::database::devices::DevicesStore; use storage::{Database, OptionalExtension}; @@ -9,18 +12,23 @@ use streamer::{NotificationsPayload, StreamProducer, StreamProducerQueue}; pub struct SupportClient { database: Database, stream_producer: StreamProducer, + cacher: CacherClient, } impl SupportClient { - pub fn new(database: Database, stream_producer: StreamProducer) -> Self { - Self { database, stream_producer } + pub fn new(database: Database, stream_producer: StreamProducer, cacher: CacherClient) -> Self { + Self { + database, + stream_producer, + cacher, + } } pub fn get_device(&self, device_id: &str) -> Result, Box> { Ok(DevicesStore::get_device(&mut self.database.client()?, device_id).optional()?.map(|d| d.as_primitive())) } - pub async fn handle_message_created(&self, device: &Device, payload: &ChatwootWebhookPayload) -> Result> { + pub async fn handle_message_created(&self, device: &Device, payload: &ChatwootWebhookPayload) -> Result> { let notifications_count = if let Some(notification) = Self::build_notification(device, payload) { self.stream_producer.publish_notifications_support(NotificationsPayload::new(vec![notification])).await?; 1 @@ -28,11 +36,23 @@ impl SupportClient { 0 }; - Ok(notifications_count) + let stream_events_count = self.publish_stream_message(device, payload).await?; + + Ok(SupportProcessResult { + notifications: notifications_count, + stream_events: stream_events_count, + }) } - pub fn handle_conversation_updated(&self, _payload: &ChatwootWebhookPayload) -> Result<(), Box> { - Ok(()) + pub async fn handle_conversation_updated(&self, device: &Device, payload: &ChatwootWebhookPayload) -> Result> { + let stream_events = if let Some(conversation) = payload.support_conversation() { + self.publish_stream_event(device, SupportStreamEvent::Conversation(conversation)).await?; + 1 + } else { + 0 + }; + + Ok(SupportProcessResult { notifications: 0, stream_events }) } fn build_notification(device: &Device, payload: &ChatwootWebhookPayload) -> Option { @@ -49,6 +69,26 @@ impl SupportClient { GorushNotification::from_device(device.clone(), title, message, data) } + + async fn publish_stream_message(&self, device: &Device, payload: &ChatwootWebhookPayload) -> Result> { + let Some(message) = payload.support_message() else { + return Ok(0); + }; + + self.publish_stream_event(device, SupportStreamEvent::Message(message)).await?; + Ok(1) + } + + async fn publish_stream_event(&self, device: &Device, event: SupportStreamEvent) -> Result<(), Box> { + let channel = device_stream_channel(&device.id); + self.cacher.publish(&channel, &StreamEvent::Support(event)).await + } +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct SupportProcessResult { + pub notifications: usize, + pub stream_events: usize, } #[cfg(test)] diff --git a/crates/support/src/error.rs b/crates/support/src/error.rs new file mode 100644 index 000000000..22fee461d --- /dev/null +++ b/crates/support/src/error.rs @@ -0,0 +1,55 @@ +use reqwest::Error as ReqwestError; +use serde_json::Error as JsonError; +use std::error::Error; +use std::fmt::{Display, Formatter}; + +#[derive(Debug, Clone)] +pub enum ChatwootError { + Http { status: u16, message: String }, + Request(String), + InvalidResponse(String), +} + +impl ChatwootError { + pub fn http(status: u16, message: impl Into) -> Self { + Self::Http { status, message: message.into() } + } + + pub fn invalid_response(message: impl Into) -> Self { + Self::InvalidResponse(message.into()) + } + + pub fn is_session_error(&self) -> bool { + match self { + Self::Http { status, .. } => *status == 401 || *status == 404, + Self::Request(_) | Self::InvalidResponse(_) => false, + } + } +} + +impl Display for ChatwootError { + fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result { + match self { + ChatwootError::Http { status, message } => write!(f, "Chatwoot HTTP error {status}: {message}"), + ChatwootError::Request(message) => write!(f, "Chatwoot request error: {message}"), + ChatwootError::InvalidResponse(message) => write!(f, "Chatwoot invalid response: {message}"), + } + } +} + +impl Error for ChatwootError {} + +impl From for ChatwootError { + fn from(error: ReqwestError) -> Self { + match error.status() { + Some(status) => ChatwootError::http(status.as_u16(), error.to_string()), + None => ChatwootError::Request(error.to_string()), + } + } +} + +impl From for ChatwootError { + fn from(error: JsonError) -> Self { + ChatwootError::InvalidResponse(error.to_string()) + } +} diff --git a/crates/support/src/lib.rs b/crates/support/src/lib.rs index c7de07c3d..193faa58f 100644 --- a/crates/support/src/lib.rs +++ b/crates/support/src/lib.rs @@ -1,5 +1,9 @@ +mod chatwoot; mod client; +mod error; mod model; -pub use client::SupportClient; +pub use chatwoot::ChatwootClient; +pub use client::{SupportClient, SupportProcessResult}; +pub use error::ChatwootError; pub use model::*; diff --git a/crates/support/src/model.rs b/crates/support/src/model.rs index 5b0048982..bfd30329d 100644 --- a/crates/support/src/model.rs +++ b/crates/support/src/model.rs @@ -1,8 +1,19 @@ -use serde::{Deserialize, Serialize}; +use chrono::{DateTime, Utc}; +use primitives::{Device, SupportAgent, SupportConversation, SupportConversationStatus, SupportMessage, SupportMessageDeliveryStatus, SupportMessageSender, SupportTypingStatus}; +use serde::{Deserialize, Deserializer, Serialize, de::Error as DeError}; +use std::collections::HashMap; pub const EVENT_MESSAGE_CREATED: &str = "message_created"; pub const EVENT_CONVERSATION_STATUS_CHANGED: &str = "conversation_status_changed"; pub const EVENT_CONVERSATION_UPDATED: &str = "conversation_updated"; +pub const CHATWOOT_CONTENT_TYPE_TEXT: &str = "text"; +pub const CHATWOOT_STATUS_RESOLVED: &str = "resolved"; +pub const CHATWOOT_STATUS_OPEN: &str = "open"; +pub const CHATWOOT_STATUS_PENDING: &str = "pending"; +pub const CHATWOOT_STATUS_SNOOZED: &str = "snoozed"; +pub const CHATWOOT_DELIVERY_STATUS_SENT: &str = "sent"; +pub const CHATWOOT_DELIVERY_STATUS_DELIVERED: &str = "delivered"; +pub const CHATWOOT_DELIVERY_STATUS_READ: &str = "read"; #[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] #[serde(from = "i32", into = "i32")] @@ -37,6 +48,7 @@ pub struct Account { #[derive(Debug, Clone, Serialize, Deserialize)] pub struct ChatwootWebhookPayload { pub event: String, + pub id: Option, pub message_type: Option, pub private: Option, pub unread_count: Option, @@ -44,6 +56,13 @@ pub struct ChatwootWebhookPayload { pub account: Option, pub meta: Option, pub content: Option, + pub content_type: Option, + pub status: Option, + pub contact_last_seen_at: Option, + pub last_activity_at: Option, + #[serde(default, deserialize_with = "deserialize_optional_datetime")] + pub created_at: Option>, + pub sender: Option, #[serde(default)] pub messages: Vec, } @@ -52,7 +71,10 @@ pub struct ChatwootWebhookPayload { pub struct Conversation { pub id: Option, pub meta: Meta, + pub status: Option, pub unread_count: Option, + pub contact_last_seen_at: Option, + pub last_activity_at: Option, #[serde(default)] pub messages: Vec, } @@ -60,9 +82,13 @@ pub struct Conversation { #[derive(Debug, Clone, Serialize, Deserialize)] pub struct Message { pub id: i64, + pub conversation_id: Option, pub content: Option, pub message_type: MessageType, + pub content_type: Option, + pub status: Option, pub private: Option, + pub created_at: i64, pub sender: Option, } @@ -84,6 +110,9 @@ pub struct CustomAttributes { #[derive(Debug, Clone, Serialize, Deserialize)] pub struct Sender { + pub name: Option, + pub avatar_url: Option, + pub thumbnail: Option, pub custom_attributes: Option, } @@ -126,4 +155,243 @@ impl ChatwootWebhookPayload { &[] } } + + pub fn support_message(&self) -> Option { + if self.private != Some(false) || self.content_type.as_deref().is_some_and(|content_type| content_type != CHATWOOT_CONTENT_TYPE_TEXT) { + return None; + } + + let id = self.id?; + let conversation_id = self.get_conversation_id()?; + let content = self.content.clone()?; + let created_at = self.created_at?; + let sender = match self.message_type.as_deref()? { + "incoming" => SupportMessageSender::User, + "outgoing" => SupportMessageSender::Agent(self.sender.as_ref()?.support_agent()?), + _ => return None, + }; + + Some(SupportMessage { + id: id.to_string(), + conversation_id: conversation_id.to_string(), + content, + sender, + delivery_status: SupportMessageDeliveryStatus::Sent, + created_at, + }) + } + + pub fn support_conversation(&self) -> Option { + if let Some(conversation) = &self.conversation { + return conversation.support_conversation(); + } + + let id = self.id?; + let messages = support_messages(self.get_messages()); + let first_message = messages.iter().find(|message| message.sender.is_user()).map(|message| message.content.clone()); + let last_message = messages.last().map(|message| message.content.clone()); + let last_activity_at = self + .last_activity_at + .and_then(timestamp) + .or_else(|| messages.last().map(|message| message.created_at)) + .or(self.created_at)?; + + Some(SupportConversation { + id: id.to_string(), + status: support_conversation_status(self.status.as_deref()), + first_message, + last_message, + last_activity_at, + unread_count: self.unread_count.unwrap_or_else(|| unread_count(&messages, self.contact_last_seen_at)), + }) + } +} + +impl Conversation { + pub fn support_conversation(&self) -> Option { + let id = self.id?; + let messages = support_messages(&self.messages); + let first_message = messages.iter().find(|message| message.sender.is_user()).map(|message| message.content.clone()); + let last_message = messages.last().map(|message| message.content.clone()); + let last_activity_at = self.last_activity_at.and_then(timestamp).or_else(|| messages.last().map(|message| message.created_at))?; + + Some(SupportConversation { + id: id.to_string(), + status: support_conversation_status(self.status.as_deref()), + first_message, + last_message, + last_activity_at, + unread_count: self.unread_count.unwrap_or_else(|| unread_count(&messages, self.contact_last_seen_at)), + }) + } +} + +impl Message { + pub fn support_message(&self) -> Option { + if self.private != Some(false) || self.content_type.as_deref().is_some_and(|content_type| content_type != CHATWOOT_CONTENT_TYPE_TEXT) { + return None; + } + + let content = self.content.clone()?; + let sender = match &self.message_type { + MessageType::Incoming => SupportMessageSender::User, + MessageType::Outgoing => SupportMessageSender::Agent(self.sender.as_ref()?.support_agent()?), + }; + + Some(SupportMessage { + id: self.id.to_string(), + conversation_id: self.conversation_id?.to_string(), + content, + sender, + delivery_status: support_delivery_status(self.status.as_deref()), + created_at: timestamp(self.created_at)?, + }) + } +} + +impl Sender { + pub fn support_agent(&self) -> Option { + let name = self.name.clone()?; + Some(SupportAgent { + name, + avatar_url: self.avatar_url.clone().or_else(|| self.thumbnail.clone()).filter(|value| !value.is_empty()), + }) + } +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct ChatwootSession { + pub auth_token: String, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct ChatwootConfigResponse { + pub website_channel_config: ChatwootWebsiteChannelConfig, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct ChatwootWebsiteChannelConfig { + pub auth_token: String, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct ChatwootContactResponse { + pub widget_auth_token: Option, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct ChatwootMessagesResponse { + pub payload: Vec, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct ChatwootConversationResponse { + pub id: Option, + pub status: Option, + pub contact_last_seen_at: Option, +} + +#[derive(Debug, Clone, Serialize)] +pub struct ChatwootContactUpdate { + pub identifier: String, + pub name: String, + pub custom_attributes: HashMap, +} + +impl ChatwootContactUpdate { + pub fn new(device: &Device) -> Self { + Self { + identifier: device.id.clone(), + name: device.model.clone(), + custom_attributes: HashMap::from([ + ("device_id".to_string(), device.id.clone()), + ("platform".to_string(), device.platform.as_ref().to_string()), + ("os".to_string(), device.os.clone()), + ("device".to_string(), device.model.clone()), + ("app_version".to_string(), device.version.clone()), + ("currency".to_string(), device.currency.clone()), + ]), + } + } +} + +#[derive(Debug, Clone, Serialize)] +pub struct ChatwootMessageInput { + pub message: ChatwootMessageData, +} + +impl ChatwootMessageInput { + pub fn new(content: String) -> Self { + Self { + message: ChatwootMessageData { content }, + } + } +} + +#[derive(Debug, Clone, Serialize)] +pub struct ChatwootMessageData { + pub content: String, +} + +#[derive(Debug, Clone, Serialize)] +pub struct ChatwootTypingInput { + pub typing_status: String, +} + +impl ChatwootTypingInput { + pub fn new(status: SupportTypingStatus) -> Self { + let typing_status = match status { + SupportTypingStatus::On => "on", + SupportTypingStatus::Off => "off", + }; + Self { + typing_status: typing_status.to_string(), + } + } +} + +pub fn support_messages(messages: &[Message]) -> Vec { + messages.iter().filter_map(Message::support_message).collect() +} + +pub fn support_conversation_status(status: Option<&str>) -> SupportConversationStatus { + match status { + Some(CHATWOOT_STATUS_RESOLVED) => SupportConversationStatus::Resolved, + Some(CHATWOOT_STATUS_OPEN) | Some(CHATWOOT_STATUS_PENDING) | Some(CHATWOOT_STATUS_SNOOZED) | None => SupportConversationStatus::Open, + Some(_) => SupportConversationStatus::Open, + } +} + +pub fn support_delivery_status(status: Option<&str>) -> SupportMessageDeliveryStatus { + match status { + Some(CHATWOOT_DELIVERY_STATUS_SENT) | Some(CHATWOOT_DELIVERY_STATUS_DELIVERED) | Some(CHATWOOT_DELIVERY_STATUS_READ) | None => SupportMessageDeliveryStatus::Sent, + Some(_) => SupportMessageDeliveryStatus::Failed, + } +} + +pub fn unread_count(messages: &[SupportMessage], contact_last_seen_at: Option) -> i32 { + let Some(contact_last_seen_at) = contact_last_seen_at else { + return 0; + }; + messages + .iter() + .filter(|message| message.sender.is_agent() && message.created_at.timestamp() > contact_last_seen_at) + .count() as i32 +} + +pub fn timestamp(value: i64) -> Option> { + DateTime::::from_timestamp(value, 0) +} + +fn deserialize_optional_datetime<'de, D>(deserializer: D) -> Result>, D::Error> +where + D: Deserializer<'de>, +{ + let value = Option::::deserialize(deserializer)?; + match value { + Some(serde_json::Value::String(value)) => Ok(Some(value.parse::>().map_err(D::Error::custom)?)), + Some(serde_json::Value::Number(value)) => Ok(value.as_i64().and_then(timestamp)), + Some(_) | None => Ok(None), + } } diff --git a/crates/support/tests/model_tests.rs b/crates/support/tests/model_tests.rs index 190c49058..882b9ce01 100644 --- a/crates/support/tests/model_tests.rs +++ b/crates/support/tests/model_tests.rs @@ -1,3 +1,4 @@ +use primitives::{SupportAgent, SupportConversationStatus, SupportMessageDeliveryStatus, SupportMessageSender}; use support::ChatwootWebhookPayload; #[test] @@ -112,3 +113,28 @@ fn test_get_conversation_id() { let payload: ChatwootWebhookPayload = serde_json::from_str(r#"{"event": "test"}"#).unwrap(); assert_eq!(payload.get_conversation_id(), None); } + +#[test] +fn test_support_mapping() { + let payload: ChatwootWebhookPayload = serde_json::from_str(include_str!("testdata/chatwoot_message_created.json")).unwrap(); + let message = payload.support_message().unwrap(); + assert_eq!(message.id, "1"); + assert_eq!(message.conversation_id, "1"); + assert_eq!(message.content, "from agent"); + assert_eq!( + message.sender, + SupportMessageSender::Agent(SupportAgent { + name: "Test Agent".to_string(), + avatar_url: None, + }) + ); + assert_eq!(message.delivery_status, SupportMessageDeliveryStatus::Sent); + + let payload: ChatwootWebhookPayload = serde_json::from_str(include_str!("testdata/chatwoot_conversation_updated.json")).unwrap(); + let conversation = payload.support_conversation().unwrap(); + assert_eq!(conversation.id, "1"); + assert_eq!(conversation.status, SupportConversationStatus::Open); + assert_eq!(conversation.first_message, None); + assert_eq!(conversation.last_message, Some("Test message".to_string())); + assert_eq!(conversation.unread_count, 1); +} diff --git a/gemstone/src/api_client/mod.rs b/gemstone/src/api_client/mod.rs index 725717cf1..bc0c6d754 100644 --- a/gemstone/src/api_client/mod.rs +++ b/gemstone/src/api_client/mod.rs @@ -1,6 +1,9 @@ use crate::alien::{AlienProvider, AlienTarget}; -use primitives::{ScanTransaction, ScanTransactionPayload}; +use primitives::{ResponseResult, ScanTransaction, ScanTransactionPayload, SupportConversation, SupportMessage, SupportMessageInput, SupportTyping}; +use serde::de::DeserializeOwned; +use serde_json::{from_slice, json}; use std::sync::Arc; +use url::Url; #[derive(Debug, Clone)] pub struct GemApiClient { @@ -14,9 +17,68 @@ impl GemApiClient { } pub async fn scan_transaction(&self, payload: ScanTransactionPayload) -> Result { - let url = format!("{}/v1/scan/transaction", self.api_url); + let url = self.url("/v1/scan/transaction")?; let target = AlienTarget::post_json(&url, &payload); let response = self.provider.request(target).await.map_err(|e| e.to_string())?; - serde_json::from_slice(&response.data).map_err(|e| format!("Failed to parse response: {}", e)) + from_slice(&response.data).map_err(|e| format!("Failed to parse response: {}", e)) + } + + pub async fn support_conversation(&self) -> Result, String> { + self.request_json(AlienTarget::get(&self.url("/v1/support")?)).await + } + + pub async fn support_messages(&self, before: Option, after: Option) -> Result, String> { + let url = self.url_with_query( + "/v1/support/messages", + &[("before", before.map(|value| value.to_string())), ("after", after.map(|value| value.to_string()))], + )?; + self.request_json(AlienTarget::get(&url)).await + } + + pub async fn send_support_message(&self, input: SupportMessageInput) -> Result { + let url = self.url("/v1/support/messages")?; + self.request_json(AlienTarget::post_json(&url, &input)).await + } + + pub async fn set_support_typing(&self, typing: SupportTyping) -> Result { + let url = self.url("/v1/support/typing")?; + self.request_json(AlienTarget::post_json(&url, &typing)).await + } + + pub async fn update_support_last_seen(&self) -> Result { + let url = self.url("/v1/support/last_seen")?; + self.request_json(AlienTarget::post_json(&url, &json!({}))).await + } + + async fn request_json(&self, target: AlienTarget) -> Result { + let response = self.provider.request(target).await.map_err(|e| e.to_string())?; + if let Some(status) = response.status + && !(200..300).contains(&status) + { + return Err(format!("HTTP error: status {status}")); + } + + match from_slice::>(&response.data).map_err(|e| format!("Failed to parse response: {}", e))? { + ResponseResult::Success(value) => Ok(value), + ResponseResult::Error(error) => Err(error.error.message), + } + } + + fn url(&self, path: &str) -> Result { + let base = self.api_url.trim_end_matches('/'); + Ok(Url::parse(&format!("{base}{path}")).map_err(|error| error.to_string())?.to_string()) + } + + fn url_with_query(&self, path: &str, query: &[(&str, Option)]) -> Result { + let mut url = Url::parse(&self.url(path)?).map_err(|error| error.to_string())?; + { + let mut pairs = url.query_pairs_mut(); + for (key, value) in query { + if let Some(value) = value { + pairs.append_pair(key, value); + } + } + } + Ok(url.to_string()) } } diff --git a/gemstone/src/gateway/mod.rs b/gemstone/src/gateway/mod.rs index 34b84a71c..15ed2c40b 100644 --- a/gemstone/src/gateway/mod.rs +++ b/gemstone/src/gateway/mod.rs @@ -15,12 +15,13 @@ use crate::api_client::GemApiClient; use crate::models::*; use crate::transaction_state::StatusProvider; use chain_traits::ChainTraits; +use std::error::Error as StdError; use std::future::Future; use std::sync::Arc; use swapper::swapper::GemSwapper as Swapper; use yielder::Yielder; -use primitives::{AssetId, Chain, ChartPeriod, ScanAddressTarget, ScanTransactionPayload, TransactionPreloadInput}; +use primitives::{AssetId, Chain, ChartPeriod, ScanAddressTarget, ScanTransactionPayload, SupportMessageInput, TransactionPreloadInput}; #[uniffi::export(with_foreign)] #[async_trait::async_trait] @@ -47,7 +48,7 @@ impl GemGateway { async fn with_provider(&self, chain: Chain, call: F) -> Result where F: FnOnce(Arc) -> Fut, - Fut: Future>>, + Fut: Future>>, { let provider = self.chain_factory.create(chain).await?; call(provider).await.map_err(|e| GatewayError::NetworkError { msg: e.to_string() }) @@ -137,7 +138,7 @@ impl GemGateway { } pub async fn get_transaction_preload(&self, chain: Chain, input: GemTransactionPreloadInput) -> Result { - let preload_input: primitives::TransactionPreloadInput = input.into(); + let preload_input: TransactionPreloadInput = input.into(); let metadata = self .with_provider(chain, |provider| async move { provider.get_transaction_preload(preload_input).await }) .await?; @@ -167,6 +168,29 @@ impl GemGateway { self.api_client.scan_transaction(payload).await.map(Some).map_err(|e| GatewayError::NetworkError { msg: e }) } + pub async fn get_support_conversation(&self) -> Result, GatewayError> { + self.api_client.support_conversation().await.map_err(|msg| GatewayError::NetworkError { msg }) + } + + pub async fn get_support_messages(&self, before: Option, after: Option) -> Result, GatewayError> { + self.api_client.support_messages(before, after).await.map_err(|msg| GatewayError::NetworkError { msg }) + } + + pub async fn send_support_message(&self, content: String) -> Result { + self.api_client + .send_support_message(SupportMessageInput { content }) + .await + .map_err(|msg| GatewayError::NetworkError { msg }) + } + + pub async fn set_support_typing(&self, typing: GemSupportTyping) -> Result { + self.api_client.set_support_typing(typing).await.map_err(|msg| GatewayError::NetworkError { msg }) + } + + pub async fn update_support_last_seen(&self) -> Result { + self.api_client.update_support_last_seen().await.map_err(|msg| GatewayError::NetworkError { msg }) + } + pub async fn get_fee(&self, chain: Chain, input: GemTransactionLoadInput, provider: Arc) -> Result, GatewayError> { let fee = provider.get_fee(chain, input.clone()).await?; if let Some(fee) = fee { diff --git a/gemstone/src/models/mod.rs b/gemstone/src/models/mod.rs index 2cf7d42b6..65c55ebe7 100644 --- a/gemstone/src/models/mod.rs +++ b/gemstone/src/models/mod.rs @@ -9,6 +9,7 @@ pub mod portfolio; pub mod scan; pub mod simulation; pub mod stake; +pub mod support; pub mod swap; pub mod token; pub mod transaction; @@ -23,5 +24,6 @@ pub use portfolio::*; pub use scan::*; pub use simulation::*; pub use stake::*; +pub use support::*; pub use token::*; pub use transaction::*; diff --git a/gemstone/src/models/support.rs b/gemstone/src/models/support.rs new file mode 100644 index 000000000..28d1b7b44 --- /dev/null +++ b/gemstone/src/models/support.rs @@ -0,0 +1,76 @@ +use crate::models::custom_types::DateTimeUtc; +use primitives::{ + SupportAgent, SupportConversation, SupportConversationStatus, SupportMessage, SupportMessageDeliveryStatus, SupportMessageInput, SupportMessageSender, SupportTyping, + SupportTypingStatus, +}; + +pub type GemSupportAgent = SupportAgent; +pub type GemSupportConversation = SupportConversation; +pub type GemSupportConversationStatus = SupportConversationStatus; +pub type GemSupportMessage = SupportMessage; +pub type GemSupportMessageDeliveryStatus = SupportMessageDeliveryStatus; +pub type GemSupportMessageInput = SupportMessageInput; +pub type GemSupportMessageSender = SupportMessageSender; +pub type GemSupportTyping = SupportTyping; +pub type GemSupportTypingStatus = SupportTypingStatus; + +#[uniffi::remote(Enum)] +pub enum GemSupportConversationStatus { + Open, + Resolved, +} + +#[uniffi::remote(Enum)] +pub enum GemSupportMessageDeliveryStatus { + Sending, + Sent, + Failed, +} + +#[uniffi::remote(Record)] +pub struct GemSupportAgent { + pub name: String, + pub avatar_url: Option, +} + +#[uniffi::remote(Enum)] +pub enum GemSupportMessageSender { + User, + Agent(GemSupportAgent), +} + +#[uniffi::remote(Record)] +pub struct GemSupportConversation { + pub id: String, + pub status: GemSupportConversationStatus, + pub first_message: Option, + pub last_message: Option, + pub last_activity_at: DateTimeUtc, + pub unread_count: i32, +} + +#[uniffi::remote(Record)] +pub struct GemSupportMessage { + pub id: String, + pub conversation_id: String, + pub content: String, + pub sender: GemSupportMessageSender, + pub delivery_status: GemSupportMessageDeliveryStatus, + pub created_at: DateTimeUtc, +} + +#[uniffi::remote(Record)] +pub struct GemSupportMessageInput { + pub content: String, +} + +#[uniffi::remote(Enum)] +pub enum GemSupportTypingStatus { + On, + Off, +} + +#[uniffi::remote(Record)] +pub struct GemSupportTyping { + pub status: GemSupportTypingStatus, +}