basic zulip client for sending messages (#5913)

This commit is contained in:
Jędrzej Stuczyński
2025-07-24 16:22:35 +01:00
committed by GitHub
parent b975d08342
commit a4e674c98b
9 changed files with 577 additions and 2 deletions
Generated
+17
View File
@@ -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",
]
+1 -1
View File
@@ -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",
+1 -1
View File
@@ -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,
}
+31
View File
@@ -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
+151
View File
@@ -0,0 +1,151 @@
// Copyright 2025 - Nym Technologies SA <contact@nymtech.net>
// 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<String>,
pub server_url: Url,
}
pub struct Client {
server_url: Url,
api_key: Zeroizing<String>,
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<String>,
api_key: impl Into<String>,
server_url: impl Into<String>,
) -> Result<ClientBuilder, ZulipClientError> {
ClientBuilder::new(user_email, api_key, server_url)
}
pub fn new(config: ClientConfig) -> Result<Self, ZulipClientError> {
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<SendableMessage>,
) -> Result<SendMessageResponse, ZulipClientError> {
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<String>,
user_email: String,
server_url: Url,
user_agent: Option<String>,
}
impl ClientBuilder {
pub fn new(
user_email: impl Into<String>,
api_key: impl Into<String>,
server_url: impl Into<String>,
) -> Result<Self, ZulipClientError> {
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<String>) -> Self {
self.user_agent = Some(user_agent.into());
self
}
pub fn build(self) -> Result<Client, ZulipClientError> {
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 })?,
})
}
}
+22
View File
@@ -0,0 +1,22 @@
// Copyright 2025 - Nym Technologies SA <contact@nymtech.net>
// 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,
},
}
+11
View File
@@ -0,0 +1,11 @@
// Copyright 2025 - Nym Technologies SA <contact@nymtech.net>
// 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;
+215
View File
@@ -0,0 +1,215 @@
// Copyright 2025 - Nym Technologies SA <contact@nymtech.net>
// 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<i64>,
msg: String,
},
Error {
code: String,
msg: String,
stream: Option<String>,
},
}
#[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<String>,
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<String>,
/// 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<String>,
/// 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<SendableMessageContent>) -> 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<String>, local_id: impl Into<String>) -> 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<ToDirect>, content: impl Into<String>) -> Self {
DirectMessage {
to: to.into().to_string(),
content: content.into(),
}
}
}
pub type ChannelMessage = StreamMessage;
pub struct StreamMessage {
to: String,
topic: Option<String>,
content: String,
}
impl StreamMessage {
pub fn new(
to: impl Into<ToChannel>,
content: impl Into<String>,
topic: Option<String>,
) -> Self {
StreamMessage {
to: to.into().to_string(),
topic,
content: content.into(),
}
}
pub fn no_topic(to: impl Into<ToChannel>, content: impl Into<String>) -> Self {
Self::new(to, content, None)
}
#[must_use]
pub fn with_topic(mut self, topic: impl Into<String>) -> Self {
self.topic = Some(topic.into());
self
}
}
impl From<SendableMessageContent> for SendableMessage {
fn from(content: SendableMessageContent) -> Self {
SendableMessage::new(content)
}
}
impl From<DirectMessage> for SendableMessage {
fn from(msg: DirectMessage) -> Self {
SendableMessageContent::from(msg).into()
}
}
impl From<DirectMessage> for SendableMessageContent {
fn from(msg: DirectMessage) -> Self {
SendableMessageContent::Direct {
to: msg.to,
content: msg.content,
}
}
}
impl<T, S> From<(T, S)> for DirectMessage
where
T: Into<ToDirect>,
S: Into<String>,
{
fn from((to, content): (T, S)) -> Self {
DirectMessage::new(to, content)
}
}
impl<T, S> From<(T, S)> for SendableMessage
where
T: Into<ToDirect>,
S: Into<String>,
{
fn from((to, content): (T, S)) -> Self {
DirectMessage::new(to, content).into()
}
}
impl From<StreamMessage> for SendableMessage {
fn from(msg: StreamMessage) -> Self {
SendableMessageContent::from(msg).into()
}
}
impl From<StreamMessage> for SendableMessageContent {
fn from(msg: StreamMessage) -> Self {
SendableMessageContent::Stream {
to: msg.to,
topic: msg.topic,
content: msg.content,
}
}
}
impl<T, S> From<(T, S, Option<S>)> for StreamMessage
where
T: Into<ToChannel>,
S: Into<String>,
{
fn from((to, content, topic): (T, S, Option<S>)) -> Self {
StreamMessage::new(to, content, topic.map(Into::into))
}
}
impl<T, S> From<(T, S, Option<S>)> for SendableMessage
where
T: Into<ToChannel>,
S: Into<String>,
{
fn from(inner: (T, S, Option<S>)) -> Self {
StreamMessage::from(inner).into()
}
}
+128
View File
@@ -0,0 +1,128 @@
// Copyright 2025 - Nym Technologies SA <contact@nymtech.net>
// 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<Id>),
ByNames(Vec<String>),
}
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<Vec<String>> for ToDirect {
fn from(names: Vec<String>) -> 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::<Vec<_>>()
.into()
}
}
impl<const N: usize> From<&[&str; N]> for ToDirect {
fn from(names: &[&str; N]) -> Self {
names.as_slice().into()
}
}
impl From<Vec<&str>> for ToDirect {
fn from(names: Vec<&str>) -> Self {
names.as_slice().into()
}
}
impl From<String> 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<Id> 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<const N: usize> From<&[Id; N]> for ToDirect {
fn from(ids: &[Id; N]) -> Self {
ids.as_slice().into()
}
}
impl From<Vec<Id>> for ToDirect {
fn from(ids: Vec<Id>) -> 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<String> 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<Id> for ToChannel {
fn from(id: Id) -> Self {
ToChannel::ById(id)
}
}