From a4e674c98bbda0da11cf6810d2d8a7dded0dcbb0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?J=C4=99drzej=20Stuczy=C5=84ski?= Date: Thu, 24 Jul 2025 16:22:35 +0100 Subject: [PATCH] basic zulip client for sending messages (#5913) --- Cargo.lock | 17 ++ Cargo.toml | 2 +- common/http-api-client/src/user_agent.rs | 2 +- common/zulip-client/Cargo.toml | 31 ++++ common/zulip-client/src/client.rs | 151 ++++++++++++++++ common/zulip-client/src/error.rs | 22 +++ common/zulip-client/src/lib.rs | 11 ++ common/zulip-client/src/message/mod.rs | 215 +++++++++++++++++++++++ common/zulip-client/src/message/to.rs | 128 ++++++++++++++ 9 files changed, 577 insertions(+), 2 deletions(-) create mode 100644 common/zulip-client/Cargo.toml create mode 100644 common/zulip-client/src/client.rs create mode 100644 common/zulip-client/src/error.rs create mode 100644 common/zulip-client/src/lib.rs create mode 100644 common/zulip-client/src/message/mod.rs create mode 100644 common/zulip-client/src/message/to.rs diff --git a/Cargo.lock b/Cargo.lock index c7442fe8b7..e0046a0e4b 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -13219,3 +13219,20 @@ dependencies = [ "cc", "pkg-config", ] + +[[package]] +name = "zulip-client" +version = "0.1.0" +dependencies = [ + "itertools 0.14.0", + "nym-bin-common 0.6.0", + "nym-http-api-client 0.1.0", + "reqwest 0.12.22", + "serde", + "serde_json", + "thiserror 2.0.12", + "tokio", + "tracing", + "url", + "zeroize", +] diff --git a/Cargo.toml b/Cargo.toml index 73c26a7e27..d3e27c2fa3 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -99,7 +99,7 @@ members = [ "common/wasm/storage", "common/wasm/utils", "common/wireguard", - "common/wireguard-types", + "common/wireguard-types", "common/zulip-client", "documentation/autodoc", "gateway", "nym-api", diff --git a/common/http-api-client/src/user_agent.rs b/common/http-api-client/src/user_agent.rs index 54fe6ac24e..1543798a6a 100644 --- a/common/http-api-client/src/user_agent.rs +++ b/common/http-api-client/src/user_agent.rs @@ -16,7 +16,7 @@ pub struct UserAgent { pub version: String, /// client platform pub platform: String, - /// source commit version for the calling calling crate / subsystem + /// source commit version for the calling crate / subsystem pub git_commit: String, } diff --git a/common/zulip-client/Cargo.toml b/common/zulip-client/Cargo.toml new file mode 100644 index 0000000000..acfc84a540 --- /dev/null +++ b/common/zulip-client/Cargo.toml @@ -0,0 +1,31 @@ +[package] +name = "zulip-client" +version = "0.1.0" +authors.workspace = true +repository.workspace = true +homepage.workspace = true +documentation.workspace = true +edition.workspace = true +license.workspace = true +rust-version.workspace = true +readme.workspace = true + +[dependencies] +thiserror = { workspace = true } + +itertools = { workspace = true } +url = { workspace = true, features = ["serde"] } +serde = { workspace = true, features = ["derive"] } +zeroize = { workspace = true } + +nym-bin-common = { path = "../bin-common" } +nym-http-api-client = { path = "../http-api-client" } +reqwest = { workspace = true } +tracing = { workspace = true } + +[dev-dependencies] +tokio = { workspace = true, features = ["full"] } +serde_json = { workspace = true } + +[lints] +workspace = true diff --git a/common/zulip-client/src/client.rs b/common/zulip-client/src/client.rs new file mode 100644 index 0000000000..5de0a18b5c --- /dev/null +++ b/common/zulip-client/src/client.rs @@ -0,0 +1,151 @@ +// Copyright 2025 - Nym Technologies SA +// SPDX-License-Identifier: GPL-3.0-only + +//! An incomplete Zulip API Client +//! +//! Currently, it serves a single purpose: to send a message to a server, +//! however, it could very easily be extended with additional functionalities. +//! +//! ## Sending Direct Message +//! +//! ```rust +//! # use zulip_client::{Client, ZulipClientError}; +//! # use zulip_client::message::DirectMessage; +//! # async fn try_send() -> Result<(), ZulipClientError> { +//! let api_key = "your-api-key"; +//! let email = "associated-email-address"; +//! let server = "https://server-address.com"; +//! let client = Client::builder(email, api_key, server)?.build()?; +//! // send to userid 12 +//! client.send_message((12u32, "hello world")).await?; +//! // more concrete typing +//! client.send_message(DirectMessage::new(12, "hello world2")).await?; +//! # Ok(()) +//! # } +//! ``` + +use crate::error::ZulipClientError; +use crate::message::{SendMessageResponse, SendableMessage}; +use nym_bin_common::bin_info; +use nym_http_api_client::UserAgent; +use reqwest::{header, Method, RequestBuilder}; +use serde::{Deserialize, Serialize}; +use std::str::FromStr; +use tracing::trace; +use url::Url; +use zeroize::Zeroizing; + +#[derive(Serialize, Deserialize)] +pub struct ClientConfig { + pub user_email: String, + pub api_key: String, + // TODO: introduce validation + pub user_agent: Option, + pub server_url: Url, +} + +pub struct Client { + server_url: Url, + + api_key: Zeroizing, + user_email: String, + + inner_client: reqwest::Client, +} + +fn default_user_agent() -> String { + UserAgent::from(bin_info!()).to_string() +} + +impl Client { + const MESSAGES_ENDPOINT: &'static str = "/api/v1/messages"; + + pub fn builder( + user_email: impl Into, + api_key: impl Into, + server_url: impl Into, + ) -> Result { + ClientBuilder::new(user_email, api_key, server_url) + } + + pub fn new(config: ClientConfig) -> Result { + let builder = ClientBuilder::new(config.user_email, config.api_key, config.server_url)?; + match config.user_agent { + Some(user_agent) => builder.user_agent(user_agent).build(), + None => builder.build(), + } + } + + pub async fn send_message( + &self, + msg: impl Into, + ) -> Result { + let url = format!("{}{}", self.server_url, Self::MESSAGES_ENDPOINT); + + self.build_request(Method::POST, Self::MESSAGES_ENDPOINT) + .form(&msg.into()) + .send() + .await + .map_err(|source| ZulipClientError::RequestSendingFailure { source, url })? + .json() + .await + .map_err(|source| ZulipClientError::RequestDecodeFailure { source }) + } + + fn build_request(&self, method: Method, endpoint: &'static str) -> RequestBuilder { + let url = format!("{}{endpoint}", self.server_url); + trace!("posting to {url}"); + + self.inner_client + .request(method, url) + .basic_auth(&self.user_email, Some(self.api_key.to_string())) + .header(header::CONTENT_TYPE, "application/x-www-form-urlencoded") + } +} + +pub struct ClientBuilder { + api_key: Zeroizing, + user_email: String, + server_url: Url, + user_agent: Option, +} + +impl ClientBuilder { + pub fn new( + user_email: impl Into, + api_key: impl Into, + server_url: impl Into, + ) -> Result { + let server_url = server_url.into(); + let parsed_url = + Url::from_str(&server_url).map_err(|source| ZulipClientError::MalformedServerUrl { + raw: server_url, + source, + })?; + Ok(ClientBuilder { + api_key: Zeroizing::new(api_key.into()), + user_email: user_email.into(), + server_url: parsed_url, + user_agent: None, + }) + } + + #[must_use] + pub fn user_agent(mut self, user_agent: impl Into) -> Self { + self.user_agent = Some(user_agent.into()); + self + } + + pub fn build(self) -> Result { + let user_agent = self.user_agent.unwrap_or_else(default_user_agent); + Ok(Client { + api_key: self.api_key, + server_url: self.server_url, + user_email: self.user_email, + inner_client: reqwest::ClientBuilder::new() + .user_agent(user_agent) + .build() + .map_err(|source| ZulipClientError::ClientBuildFailure { source })?, + }) + } +} diff --git a/common/zulip-client/src/error.rs b/common/zulip-client/src/error.rs new file mode 100644 index 0000000000..8556833855 --- /dev/null +++ b/common/zulip-client/src/error.rs @@ -0,0 +1,22 @@ +// Copyright 2025 - Nym Technologies SA +// SPDX-License-Identifier: GPL-3.0-only + +use thiserror::Error; + +#[derive(Error, Debug)] +pub enum ZulipClientError { + #[error("failed to send request to {url}: {source}")] + RequestSendingFailure { url: String, source: reqwest::Error }, + + #[error("failed to decode received response: {source}")] + RequestDecodeFailure { source: reqwest::Error }, + + #[error("failed to build internal client: {source}")] + ClientBuildFailure { source: reqwest::Error }, + + #[error("provided url ({raw}) is malformed: {source}")] + MalformedServerUrl { + raw: String, + source: url::ParseError, + }, +} diff --git a/common/zulip-client/src/lib.rs b/common/zulip-client/src/lib.rs new file mode 100644 index 0000000000..a7c25f7c52 --- /dev/null +++ b/common/zulip-client/src/lib.rs @@ -0,0 +1,11 @@ +// Copyright 2025 - Nym Technologies SA +// SPDX-License-Identifier: GPL-3.0-only + +pub mod client; +pub mod error; +pub mod message; + +pub type Id = u32; + +pub use client::{Client, ClientBuilder}; +pub use error::ZulipClientError; diff --git a/common/zulip-client/src/message/mod.rs b/common/zulip-client/src/message/mod.rs new file mode 100644 index 0000000000..307ef0222c --- /dev/null +++ b/common/zulip-client/src/message/mod.rs @@ -0,0 +1,215 @@ +// Copyright 2025 - Nym Technologies SA +// SPDX-License-Identifier: GPL-3.0-only + +use crate::message::to::{ToChannel, ToDirect}; +use serde::{Deserialize, Serialize}; + +pub mod to; + +#[derive(Serialize, Deserialize, Debug)] +#[serde(tag = "result")] +#[serde(rename_all = "snake_case")] +pub enum SendMessageResponse { + Success { + id: i64, + automatic_new_visibility_policy: Option, + msg: String, + }, + Error { + code: String, + msg: String, + stream: Option, + }, +} + +#[derive(Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +#[serde(tag = "type")] +pub enum SendableMessageContent { + // old name: 'private' + Direct { + // internally this is a list + to: String, + content: String, + }, + // alternative name: 'channel' + Stream { + to: String, + topic: Option, + content: String, + }, +} + +#[derive(Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub struct SendableMessage { + #[serde(flatten)] + content: SendableMessageContent, + + /// For clients supporting local echo, the event queue ID for the client. + /// If passed, `local_id` is required. If the message is successfully sent, + /// the server will include `local_id` in the message event that the client with this `queue_id` + /// will receive notifying it of the new message via `GET /events`. + /// This lets the client know unambiguously that it should replace the locally echoed message, + /// rather than adding this new message + /// (which would be correct if the user had sent the new message from another device). + /// example: "fb67bf8a-c031-47cc-84cf-ed80accacda8" + queue_id: Option, + + /// For clients supporting local echo, a unique string-format identifier chosen freely by the client; + /// the server will pass it back to the client without inspecting it, as described in the `queue_id` description. + /// example: "100.01" + local_id: Option, + + /// Whether the message should be initially marked read by its sender. + /// If unspecified, the server uses a heuristic based on the client name. + read_by_sender: bool, +} + +impl SendableMessage { + pub fn new(content: impl Into) -> Self { + SendableMessage { + content: content.into(), + queue_id: None, + local_id: None, + read_by_sender: false, + } + } + + #[must_use] + pub fn with_queue(mut self, queue_id: impl Into, local_id: impl Into) -> Self { + self.queue_id = Some(queue_id.into()); + self.local_id = Some(local_id.into()); + self + } + + #[must_use] + pub fn read_by_sender(mut self, read_by_sender: bool) -> Self { + self.read_by_sender = read_by_sender; + self + } +} + +pub type PrivateMessage = DirectMessage; + +pub struct DirectMessage { + to: String, + content: String, +} + +impl DirectMessage { + pub fn new(to: impl Into, content: impl Into) -> Self { + DirectMessage { + to: to.into().to_string(), + content: content.into(), + } + } +} + +pub type ChannelMessage = StreamMessage; +pub struct StreamMessage { + to: String, + topic: Option, + content: String, +} + +impl StreamMessage { + pub fn new( + to: impl Into, + content: impl Into, + topic: Option, + ) -> Self { + StreamMessage { + to: to.into().to_string(), + topic, + content: content.into(), + } + } + + pub fn no_topic(to: impl Into, content: impl Into) -> Self { + Self::new(to, content, None) + } + + #[must_use] + pub fn with_topic(mut self, topic: impl Into) -> Self { + self.topic = Some(topic.into()); + self + } +} + +impl From for SendableMessage { + fn from(content: SendableMessageContent) -> Self { + SendableMessage::new(content) + } +} + +impl From for SendableMessage { + fn from(msg: DirectMessage) -> Self { + SendableMessageContent::from(msg).into() + } +} + +impl From for SendableMessageContent { + fn from(msg: DirectMessage) -> Self { + SendableMessageContent::Direct { + to: msg.to, + content: msg.content, + } + } +} + +impl From<(T, S)> for DirectMessage +where + T: Into, + S: Into, +{ + fn from((to, content): (T, S)) -> Self { + DirectMessage::new(to, content) + } +} + +impl From<(T, S)> for SendableMessage +where + T: Into, + S: Into, +{ + fn from((to, content): (T, S)) -> Self { + DirectMessage::new(to, content).into() + } +} + +impl From for SendableMessage { + fn from(msg: StreamMessage) -> Self { + SendableMessageContent::from(msg).into() + } +} + +impl From for SendableMessageContent { + fn from(msg: StreamMessage) -> Self { + SendableMessageContent::Stream { + to: msg.to, + topic: msg.topic, + content: msg.content, + } + } +} + +impl From<(T, S, Option)> for StreamMessage +where + T: Into, + S: Into, +{ + fn from((to, content, topic): (T, S, Option)) -> Self { + StreamMessage::new(to, content, topic.map(Into::into)) + } +} + +impl From<(T, S, Option)> for SendableMessage +where + T: Into, + S: Into, +{ + fn from(inner: (T, S, Option)) -> Self { + StreamMessage::from(inner).into() + } +} diff --git a/common/zulip-client/src/message/to.rs b/common/zulip-client/src/message/to.rs new file mode 100644 index 0000000000..0bafc3fe11 --- /dev/null +++ b/common/zulip-client/src/message/to.rs @@ -0,0 +1,128 @@ +// Copyright 2025 - Nym Technologies SA +// SPDX-License-Identifier: GPL-3.0-only + +use crate::Id; +use itertools::Itertools; +use std::fmt::Display; + +// from the docs: +// For channel messages, either the name or integer ID of the channel. +// For direct messages, either a list containing integer user IDs +// or a list containing string Zulip API email addresses. +pub enum ToDirect { + ByIds(Vec), + ByNames(Vec), +} + +impl Display for ToDirect { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + ToDirect::ByIds(ids) => write!(f, "[{}]", ids.iter().join(",")), + ToDirect::ByNames(names) => { + write!(f, "[{}]", names.join(",")) + } + } + } +} + +impl From> for ToDirect { + fn from(names: Vec) -> Self { + ToDirect::ByNames(names) + } +} + +impl From<&[String]> for ToDirect { + fn from(names: &[String]) -> Self { + names.to_vec().into() + } +} + +impl From<&[&str]> for ToDirect { + fn from(names: &[&str]) -> Self { + names + .iter() + .map(|s| s.to_string()) + .collect::>() + .into() + } +} + +impl From<&[&str; N]> for ToDirect { + fn from(names: &[&str; N]) -> Self { + names.as_slice().into() + } +} + +impl From> for ToDirect { + fn from(names: Vec<&str>) -> Self { + names.as_slice().into() + } +} + +impl From for ToDirect { + fn from(name: String) -> Self { + ToDirect::ByNames(vec![name]) + } +} + +impl From<&str> for ToDirect { + fn from(name: &str) -> Self { + name.to_string().into() + } +} + +impl From for ToDirect { + fn from(id: Id) -> Self { + ToDirect::ByIds(vec![id]) + } +} + +impl From<&[Id]> for ToDirect { + fn from(ids: &[Id]) -> Self { + ids.to_vec().into() + } +} + +impl From<&[Id; N]> for ToDirect { + fn from(ids: &[Id; N]) -> Self { + ids.as_slice().into() + } +} + +impl From> for ToDirect { + fn from(ids: Vec) -> Self { + ToDirect::ByIds(ids) + } +} + +pub enum ToChannel { + ByName(String), + ById(Id), +} + +impl Display for ToChannel { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + ToChannel::ByName(name) => name.fmt(f), + ToChannel::ById(id) => id.fmt(f), + } + } +} + +impl From for ToChannel { + fn from(name: String) -> Self { + ToChannel::ByName(name) + } +} + +impl From<&str> for ToChannel { + fn from(name: &str) -> Self { + name.to_string().into() + } +} + +impl From for ToChannel { + fn from(id: Id) -> Self { + ToChannel::ById(id) + } +}