basic zulip client for sending messages (#5913)
This commit is contained in:
committed by
GitHub
parent
b975d08342
commit
a4e674c98b
Generated
+17
@@ -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
@@ -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",
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
@@ -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 })?,
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
},
|
||||
}
|
||||
@@ -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;
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user