diff --git a/.envrc b/.envrc new file mode 100644 index 0000000..fe14aab --- /dev/null +++ b/.envrc @@ -0,0 +1,7 @@ +use flake + +watch_file flake.nix +watch_file flake.lock +watch_file rust-toolchain.toml + +dotenv_if_exists .env diff --git a/.gitignore b/.gitignore index 2323153..a43dfa6 100644 --- a/.gitignore +++ b/.gitignore @@ -1,6 +1,5 @@ # Generated files /target/ -# The library shouldn't decide about the exact versions of -# its dependencies, but let the downstream crate decide. -Cargo.lock \ No newline at end of file +Cargo.lock +.claude/settings.local.json diff --git a/.ignore b/.ignore new file mode 100644 index 0000000..92b2793 --- /dev/null +++ b/.ignore @@ -0,0 +1 @@ +.direnv diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..fddfa89 --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,61 @@ +# CLAUDE.md + +This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository. + +## Project Overview + +This is `webex-rust`, an asynchronous Rust library providing a minimal interface to Webex Teams APIs. It's designed primarily for building bots but supports general API interactions. + +## Commands + +### Build and Test +- `cargo build` - Build the library +- `cargo test` - Run unit tests +- `cargo clippy` - Run linter (note: very strict clippy rules enabled) +- `cargo fmt` - Format code +- `cargo doc` - Generate documentation + +### Examples +- `cargo run --example hello-world` - Basic message sending example +- `cargo run --example auto-reply` - Bot that automatically replies to messages +- `cargo run --example adaptivecard` - Demonstrates AdaptiveCard usage +- `cargo run --example device-authentication` - Shows device authentication flow + +### Development +- `cargo test --lib` - Run library tests only +- `cargo clippy --all-targets --all-features` - Full clippy check +- `cargo build --all-targets` - Build everything including examples + +## Architecture + +### Core Components + +- **`Webex` struct** (`src/lib.rs:92-100`) - Main API client with token-based authentication +- **`WebexEventStream`** (`src/lib.rs:102-108`) - WebSocket event stream handler for real-time events +- **`RestClient`** (`src/lib.rs:247-251`) - Low-level HTTP client wrapper +- **Types module** (`src/types.rs`) - All API data structures and serialization +- **AdaptiveCard module** (`src/adaptive_card.rs`) - Support for interactive cards +- **Auth module** (`src/auth.rs`) - Device authentication flows +- **Error module** (`src/error.rs`) - Comprehensive error handling + +### Key Patterns + +- **Generic API methods**: `get()`, `list()`, `delete()` work with any `Gettable` type +- **Device registration**: Automatic device setup and caching for WebSocket connections +- **Message handling**: Supports both direct messages and room messages with threading +- **Event streaming**: WebSocket-based real-time event processing with automatic reconnection + +### Authentication Flow + +1. Token-based authentication for REST API calls +2. Device registration with Webex for WebSocket connections +3. Mercury URL discovery for optimal WebSocket endpoint +4. Automatic device cleanup and recreation as needed + +## Important Notes + +- Uses Rust 1.76 toolchain (see `rust-toolchain.toml`) +- Very strict clippy configuration with pedantic and nursery lints enabled +- All public APIs must have documentation (`#![deny(missing_docs)]`) +- WebSocket connections require device registration and token authentication +- Mercury URL caching reduces API calls for device discovery \ No newline at end of file diff --git a/CLAUDE.md b/CLAUDE.md new file mode 120000 index 0000000..47dc3e3 --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1 @@ +AGENTS.md \ No newline at end of file diff --git a/Cargo.toml b/Cargo.toml index d46a532..eef5c19 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -50,3 +50,5 @@ features = ["v4"] [dev-dependencies] env_logger = "0.11.5" +mockito = "1.5.0" +tokio-test = "0.4.4" diff --git a/examples/adaptivecard.rs b/examples/adaptivecard.rs index 2323a54..440bff4 100644 --- a/examples/adaptivecard.rs +++ b/examples/adaptivecard.rs @@ -34,7 +34,7 @@ async fn main() { let event = match eventstream.next().await { Ok(event) => event, Err(e) => { - println!("Eventstream failed: {}", e); + println!("Eventstream failed: {e}"); continue; } }; @@ -57,16 +57,13 @@ async fn handle_adaptive_card(webex: &webex::Webex, event: &webex::Event) { match webex.get(&event.try_global_id().unwrap()).await { Ok(a) => a, Err(e) => { - println!("Error: {}", e); + println!("Error: {e}"); return; } }; let which_card = actions.inputs.as_ref().and_then(|inputs| inputs.get("id")); match which_card { - None => println!( - "ERROR: expected card to have both inputs and id, got {:?}", - actions - ), + None => println!("ERROR: expected card to have both inputs and id, got {actions:?}"), Some(s) => match s.as_str() { // s is serde::Value so we have to check if it's actually a string (as_str produces an // Option) @@ -87,10 +84,7 @@ async fn handle_adaptive_card_init(webex: &webex::Webex, actions: &webex::Attach .as_ref() .and_then(|inputs| inputs.get("input2")); if let (Some(input1), Some(input2)) = (input1, input2) { - println!( - "Recieved initial adaptive card, inputs {} and {}", - input1, input2 - ); + println!("Recieved initial adaptive card, inputs {input1} and {input2}"); return; } @@ -113,7 +107,7 @@ async fn respond_to_message(webex: &webex::Webex, config: &Config, event: &webex let message: webex::Message = match webex.get(&event.try_global_id().unwrap()).await { Ok(msg) => msg, Err(e) => { - println!("Failed to get message: {}", e); + println!("Failed to get message: {e}"); return; } }; diff --git a/examples/auto-reply.rs b/examples/auto-reply.rs index 0b8aa54..23c5884 100644 --- a/examples/auto-reply.rs +++ b/examples/auto-reply.rs @@ -26,9 +26,9 @@ const BOT_EMAIL: &str = "BOT_EMAIL"; #[tokio::main] async fn main() { let token = env::var(BOT_ACCESS_TOKEN) - .unwrap_or_else(|_| panic!("{} not specified in environment", BOT_ACCESS_TOKEN)); - let bot_email = env::var(BOT_EMAIL) - .unwrap_or_else(|_| panic!("{} not specified in environment", BOT_EMAIL)); + .unwrap_or_else(|_| panic!("{BOT_ACCESS_TOKEN} not specified in environment")); + let bot_email = + env::var(BOT_EMAIL).unwrap_or_else(|_| panic!("{BOT_EMAIL} not specified in environment")); let webex = webex::Webex::new(token.as_str()).await; let mut event_stream = webex.event_stream().await.expect("event stream"); diff --git a/examples/device-authentication.rs b/examples/device-authentication.rs index 33cc0e3..2088f0e 100644 --- a/examples/device-authentication.rs +++ b/examples/device-authentication.rs @@ -7,9 +7,9 @@ const INTEGRATION_CLIENT_SECRET: &str = "INTEGRATION_CLIENT_SECRET"; #[tokio::main] async fn main() { let client_id = env::var(INTEGRATION_CLIENT_ID) - .unwrap_or_else(|_| panic!("{} not specified in environment", INTEGRATION_CLIENT_ID)); + .unwrap_or_else(|_| panic!("{INTEGRATION_CLIENT_ID} not specified in environment")); let client_secret = env::var(INTEGRATION_CLIENT_SECRET) - .unwrap_or_else(|_| panic!("{} not specified in environment", INTEGRATION_CLIENT_SECRET)); + .unwrap_or_else(|_| panic!("{INTEGRATION_CLIENT_SECRET} not specified in environment")); let authenticator = DeviceAuthenticator::new(&client_id, &client_secret); diff --git a/examples/hello-world.rs b/examples/hello-world.rs index 5dbef4f..146afd2 100644 --- a/examples/hello-world.rs +++ b/examples/hello-world.rs @@ -24,12 +24,12 @@ const DEST_EMAIL: &str = "DEST_EMAIL"; #[tokio::main] async fn main() { let token = env::var(BOT_ACCESS_TOKEN) - .unwrap_or_else(|_| panic!("{} not specified in environment", BOT_ACCESS_TOKEN)); + .unwrap_or_else(|_| panic!("{BOT_ACCESS_TOKEN} not specified in environment")); let to_email = env::var(DEST_EMAIL) - .unwrap_or_else(|_| panic!("{} not specified in environment", DEST_EMAIL)); + .unwrap_or_else(|_| panic!("{DEST_EMAIL} not specified in environment")); let webex = webex::Webex::new(token.as_str()).await; - let text = format!("Hello, {}", to_email); + let text = format!("Hello, {to_email}"); let msg_to_send = webex::types::MessageOut { to_person_email: Some(to_email), diff --git a/flake.lock b/flake.lock new file mode 100644 index 0000000..d28ad16 --- /dev/null +++ b/flake.lock @@ -0,0 +1,100 @@ +{ + "nodes": { + "fenix": { + "inputs": { + "nixpkgs": [ + "nixpkgs" + ], + "rust-analyzer-src": "rust-analyzer-src" + }, + "locked": { + "lastModified": 1754462708, + "narHash": "sha256-82koJGbd4Z2fkoKRahRrSQY/lHjrXuMvsyUC65+cphU=", + "owner": "nix-community", + "repo": "fenix", + "rev": "411d129fad840043f724f98719706be39aa7de9c", + "type": "github" + }, + "original": { + "owner": "nix-community", + "repo": "fenix", + "type": "github" + } + }, + "flake-utils": { + "inputs": { + "systems": "systems" + }, + "locked": { + "lastModified": 1731533236, + "narHash": "sha256-l0KFg5HjrsfsO/JpG+r7fRrqm12kzFHyUHqHCVpMMbI=", + "owner": "numtide", + "repo": "flake-utils", + "rev": "11707dc2f618dd54ca8739b309ec4fc024de578b", + "type": "github" + }, + "original": { + "owner": "numtide", + "repo": "flake-utils", + "type": "github" + } + }, + "nixpkgs": { + "locked": { + "lastModified": 1735563628, + "narHash": "sha256-OnSAY7XDSx7CtDoqNh8jwVwh4xNL/2HaJxGjryLWzX8=", + "owner": "nixos", + "repo": "nixpkgs", + "rev": "b134951a4c9f3c995fd7be05f3243f8ecd65d798", + "type": "github" + }, + "original": { + "owner": "nixos", + "ref": "nixos-24.05", + "repo": "nixpkgs", + "type": "github" + } + }, + "root": { + "inputs": { + "fenix": "fenix", + "flake-utils": "flake-utils", + "nixpkgs": "nixpkgs" + } + }, + "rust-analyzer-src": { + "flake": false, + "locked": { + "lastModified": 1754428884, + "narHash": "sha256-a+hFq6zFCDhGrW7yK9le4Do80dbQIaYS0ijpOY53i7A=", + "owner": "rust-lang", + "repo": "rust-analyzer", + "rev": "75436532df916b370552652b4df601273e518025", + "type": "github" + }, + "original": { + "owner": "rust-lang", + "ref": "nightly", + "repo": "rust-analyzer", + "type": "github" + } + }, + "systems": { + "locked": { + "lastModified": 1681028828, + "narHash": "sha256-Vy1rq5AaRuLzOxct8nz4T6wlgyUR7zLU309k9mBC768=", + "owner": "nix-systems", + "repo": "default", + "rev": "da67096a3b9bf56a91d16901293e51ba5b49a27e", + "type": "github" + }, + "original": { + "owner": "nix-systems", + "repo": "default", + "type": "github" + } + } + }, + "root": "root", + "version": 7 +} diff --git a/flake.nix b/flake.nix new file mode 100644 index 0000000..f560aca --- /dev/null +++ b/flake.nix @@ -0,0 +1,66 @@ +{ + description = "Rust dev using fenix"; + + inputs = { + nixpkgs.url = "github:nixos/nixpkgs/nixos-24.05"; + fenix = { + url = "github:nix-community/fenix"; + inputs.nixpkgs.follows = "nixpkgs"; + }; + flake-utils = { + url = "github:numtide/flake-utils"; + inputs.nixpkgs.follows = "nixpkgs"; + }; + }; + + outputs = { + self, + nixpkgs, + fenix, + flake-utils, + ... + }: + flake-utils.lib.eachDefaultSystem ( + system: let + pkgs = import nixpkgs { + inherit system; + overlays = [ + fenix.overlays.default + ]; + }; + + # get Rust version from toolchain file + toolchain = with fenix.packages.${system}; + fromToolchainFile { + file = ./rust-toolchain.toml; + sha256 = "sha256-Qxt8XAuaUR2OMdKbN4u8dBJOhSHxS+uS06Wl9+flVEk="; + }; + in { + devShells.default = pkgs.mkShell { + # build environment + nativeBuildInputs = with pkgs; [ + clang + openssl.dev + pkg-config + toolchain + ]; + + # runtime environment + buildInputs = with pkgs; + [ + bacon + # git-cliff + rust-analyzer + toolchain + ] + ++ lib.optionals pkgs.stdenv.isDarwin [ + # linking will fail if clang is not in nativeBuildInputs + pkgs.darwin.apple_sdk.frameworks.CoreServices + pkgs.darwin.apple_sdk.frameworks.Security + pkgs.darwin.apple_sdk.frameworks.SystemConfiguration + pkgs.libiconv + ]; + }; + } + ); +} diff --git a/launch.json b/launch.json new file mode 100644 index 0000000..ab3af8e --- /dev/null +++ b/launch.json @@ -0,0 +1,146 @@ +{ + "version": "0.2.0", + "configurations": [ + { + "type": "lldb", + "request": "launch", + "name": "Debug unit tests in library 'webex'", + "cargo": { + "args": ["test", "--no-run", "--lib", "--package=webex"], + "filter": { + "name": "webex", + "kind": "lib" + } + }, + "args": [], + "cwd": "${workspaceFolder}" + }, + { + "type": "lldb", + "request": "launch", + "name": "Debug example 'device-authentication'", + "cargo": { + "args": ["build", "--example=device-authentication", "--package=webex"], + "filter": { + "name": "device-authentication", + "kind": "example" + } + }, + "args": [], + "cwd": "${workspaceFolder}" + }, + { + "type": "lldb", + "request": "launch", + "name": "Debug unit tests in example 'device-authentication'", + "cargo": { + "args": [ + "test", + "--no-run", + "--example=device-authentication", + "--package=webex" + ], + "filter": { + "name": "device-authentication", + "kind": "example" + } + }, + "args": [], + "cwd": "${workspaceFolder}" + }, + { + "type": "lldb", + "request": "launch", + "name": "Debug example 'adaptivecard'", + "cargo": { + "args": ["build", "--example=adaptivecard", "--package=webex"], + "filter": { + "name": "adaptivecard", + "kind": "example" + } + }, + "args": [], + "cwd": "${workspaceFolder}" + }, + { + "type": "lldb", + "request": "launch", + "name": "Debug unit tests in example 'adaptivecard'", + "cargo": { + "args": [ + "test", + "--no-run", + "--example=adaptivecard", + "--package=webex" + ], + "filter": { + "name": "adaptivecard", + "kind": "example" + } + }, + "args": [], + "cwd": "${workspaceFolder}" + }, + { + "type": "lldb", + "request": "launch", + "name": "Debug example 'auto-reply'", + "cargo": { + "args": ["build", "--example=auto-reply", "--package=webex"], + "filter": { + "name": "auto-reply", + "kind": "example" + } + }, + "args": [], + "cwd": "${workspaceFolder}" + }, + { + "type": "lldb", + "request": "launch", + "name": "Debug unit tests in example 'auto-reply'", + "cargo": { + "args": ["test", "--no-run", "--example=auto-reply", "--package=webex"], + "filter": { + "name": "auto-reply", + "kind": "example" + } + }, + "args": [], + "cwd": "${workspaceFolder}" + }, + { + "type": "lldb", + "request": "launch", + "name": "Debug example 'hello-world'", + "cargo": { + "args": ["build", "--example=hello-world", "--package=webex"], + "filter": { + "name": "hello-world", + "kind": "example" + } + }, + "args": [], + "cwd": "${workspaceFolder}" + }, + { + "type": "lldb", + "request": "launch", + "name": "Debug unit tests in example 'hello-world'", + "cargo": { + "args": [ + "test", + "--no-run", + "--example=hello-world", + "--package=webex" + ], + "filter": { + "name": "hello-world", + "kind": "example" + } + }, + "args": [], + "cwd": "${workspaceFolder}" + } + ] +} diff --git a/rust-toolchain.toml b/rust-toolchain.toml new file mode 100644 index 0000000..d61a253 --- /dev/null +++ b/rust-toolchain.toml @@ -0,0 +1,4 @@ +[toolchain] +# The default profile includes rustc, rust-std, cargo, rust-docs, rustfmt and clippy. +profile = "default" +channel = "1.88" diff --git a/src/adaptive_card.rs b/src/adaptive_card.rs index a8f1e12..40b0f7a 100644 --- a/src/adaptive_card.rs +++ b/src/adaptive_card.rs @@ -102,14 +102,12 @@ impl AdaptiveCard { } impl From<&Self> for AdaptiveCard { - #[must_use] fn from(item: &Self) -> Self { item.clone() } } impl From<&mut Self> for AdaptiveCard { - #[must_use] fn from(item: &mut Self) -> Self { item.clone() } @@ -476,14 +474,12 @@ pub enum CardElement { } impl From<&Self> for CardElement { - #[must_use] fn from(item: &Self) -> Self { item.clone() } } impl From<&mut Self> for CardElement { - #[must_use] fn from(item: &mut Self) -> Self { item.clone() } @@ -859,14 +855,12 @@ pub struct Column { } impl From<&Self> for Column { - #[must_use] fn from(item: &Self) -> Self { item.clone() } } impl From<&mut Self> for Column { - #[must_use] fn from(item: &mut Self) -> Self { item.clone() } @@ -1034,10 +1028,15 @@ pub enum FontType { #[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)] #[serde(rename_all = "camelCase")] pub enum Size { + #[serde(alias = "Default")] Default, + #[serde(alias = "Small")] Small, + #[serde(alias = "Medium")] Medium, + #[serde(alias = "Large")] Large, + #[serde(alias = "ExtraLarge")] ExtraLarge, } @@ -1046,10 +1045,15 @@ pub enum Size { #[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)] #[serde(rename_all = "camelCase")] pub enum ImageSize { + #[serde(alias = "Auto")] Auto, + #[serde(alias = "Stretch")] Stretch, + #[serde(alias = "Small")] Small, + #[serde(alias = "Medium")] Medium, + #[serde(alias = "Large")] Large, } diff --git a/src/error.rs b/src/error.rs index d29057c..b2ab08c 100644 --- a/src/error.rs +++ b/src/error.rs @@ -25,13 +25,16 @@ pub enum Error { #[error("{0} Retry in: '{1:?}'")] Limited(StatusCode, Option), #[error("{0} {1}")] - Tungstenite(tokio_tungstenite::tungstenite::Error, String), + Tungstenite(Box, String), #[error("Webex API changed: {0}")] Api(&'static str), #[error("Authentication error")] Authentication, + #[error("{0}")] + UserError(String), + // catch-all #[error("Unknown error: {0}")] Other(String), diff --git a/src/lib.rs b/src/lib.rs index b706c15..d01c3d2 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -53,7 +53,7 @@ use serde::{de::DeserializeOwned, Serialize}; use std::{ collections::{hash_map::DefaultHasher, HashMap}, hash::{self, Hasher}, - sync::Mutex, + sync::{Arc, Mutex}, time::Duration, }; use tokio::net::TcpStream; @@ -97,6 +97,8 @@ pub struct Webex { token: String, /// Webex Device Information used for device registration pub device: DeviceData, + /// Cached user ID to avoid repeated /people/me calls + user_id: Arc>>, } /// Webex Event Stream handler @@ -131,7 +133,7 @@ impl WebexEventStream { } // Didn't time out Ok(next_result) => match next_result { - None => continue, + None => {} Some(msg) => match msg { Ok(msg) => { if let Some(h_msg) = self.handle_message(msg)? { @@ -147,7 +149,10 @@ impl WebexEventStream { return Err(msg.unwrap_err().to_string().into()); } Err(e) => { - return Err(Error::Tungstenite(e, "Error getting next_result".into())) + return Err(Error::Tungstenite( + Box::new(e), + "Error getting next_result".into(), + )) } }, }, @@ -168,7 +173,7 @@ impl WebexEventStream { } } TMessage::Text(t) => { - debug!("text: {}", t); + debug!("text: {t}"); Ok(None) } TMessage::Ping(_) => { @@ -176,7 +181,7 @@ impl WebexEventStream { Ok(None) } TMessage::Close(t) => { - debug!("close: {:?}", t); + debug!("close: {t:?}"); self.is_open = false; Err(Error::Closed("Web Socket Closed".to_string())) } @@ -220,7 +225,7 @@ impl WebexEventStream { } } Err(e) => Err(Error::Tungstenite( - e, + Box::new(e), "failed to send authentication".to_string(), )), } @@ -264,38 +269,58 @@ impl RestClient { * high-level calls like "get_message" ******************************************************************/ - async fn api_get<'a, T: DeserializeOwned>( + async fn api_get( &self, rest_method: &str, params: Option, - auth: AuthorizationType<'a>, + auth: AuthorizationType<'_>, ) -> Result { self.rest_api(reqwest::Method::GET, rest_method, auth, params, BODY_NONE) .await } - async fn api_delete<'a>( + async fn api_delete( &self, rest_method: &str, params: Option, - auth: AuthorizationType<'a>, + auth: AuthorizationType<'_>, ) -> Result<(), Error> { - self.rest_api( - reqwest::Method::DELETE, - rest_method, - auth, - params, - BODY_NONE, - ) - .await + let url_trimmed = rest_method.split('?').next().unwrap_or(rest_method); + let prefix = self + .host_prefix + .get(url_trimmed) + .map_or(REST_HOST_PREFIX, String::as_str); + let url = format!("{prefix}/{rest_method}"); + let mut request_builder = self.web_client.request(reqwest::Method::DELETE, url); + if let Some(params) = params { + request_builder = request_builder.query(¶ms); + } + match auth { + AuthorizationType::None => {} + AuthorizationType::Bearer(token) => { + request_builder = request_builder.bearer_auth(token); + } + AuthorizationType::Basic { username, password } => { + request_builder = request_builder.basic_auth(username, Some(password)); + } + } + let res = request_builder.send().await?; + + // Check for success status codes (200-299) - DELETE often returns 204 No Content + if res.status().is_success() { + Ok(()) + } else { + // Convert non-success responses to errors + Err(Error::from(res.error_for_status().unwrap_err())) + } } - async fn api_post<'a, T: DeserializeOwned>( + async fn api_post( &self, rest_method: &str, body: impl Serialize, params: Option, - auth: AuthorizationType<'a>, + auth: AuthorizationType<'_>, ) -> Result where { self.rest_api( @@ -308,12 +333,12 @@ where { .await } - async fn api_post_form_urlencoded<'a, T: DeserializeOwned>( + async fn api_post_form_urlencoded( &self, rest_method: &str, body: impl Serialize, params: Option, - auth: AuthorizationType<'a>, + auth: AuthorizationType<'_>, ) -> Result { self.rest_api( reqwest::Method::POST, @@ -325,12 +350,12 @@ where { .await } - async fn api_put<'a, T: DeserializeOwned>( + async fn api_put( &self, rest_method: &str, body: impl Serialize, params: Option, - auth: AuthorizationType<'a>, + auth: AuthorizationType<'_>, ) -> Result { self.rest_api( reqwest::Method::PUT, @@ -355,8 +380,8 @@ where { .host_prefix .get(url_trimmed) .map_or(REST_HOST_PREFIX, String::as_str); - let url = format!("{prefix}/{url}"); - let mut request_builder = self.web_client.request(http_method, url); + let full_url = format!("{prefix}/{url}"); + let mut request_builder = self.web_client.request(http_method, &full_url); if let Some(params) = params { request_builder = request_builder.query(¶ms); } @@ -379,7 +404,90 @@ where { } } let res = request_builder.send().await?; - Ok(res.json().await?) + + // Check HTTP status first + let status = res.status(); + if !status.is_success() { + let error_text = res.text().await?; + + // Try to parse as JSON error response first + if let Ok(json_error) = serde_json::from_str::(&error_text) { + if let Some(message) = json_error.get("message").and_then(|m| m.as_str()) { + // Team 404 errors are expected when user doesn't have access - log as debug + if status == StatusCode::NOT_FOUND + && full_url.contains("/teams/") + && message.contains("Could not find teams") + { + debug!( + "HTTP {} error for {}: {} (expected when not a team member)", + status.as_u16(), + full_url, + message + ); + } else { + warn!( + "HTTP {} error for {}: {}", + status.as_u16(), + full_url, + message + ); + } + return Err(Error::StatusText(status, message.to_string())); + } + } + + // Handle HTML error pages (like 403 from device endpoints) + if error_text.starts_with("") && error_text.contains("") { + // Extract title from HTML + let start = error_text.find("").unwrap() + 7; + let end = error_text.find("").unwrap(); + error_text[start..end].to_string() + } else { + format!("HTTP {} - HTML error page returned", status.as_u16()) + }; + debug!( + "HTTP {} error for {}: {}", + status.as_u16(), + full_url, + clean_error + ); + return Err(Error::StatusText(status, clean_error)); + } + + // Fallback to generic HTTP error + // Device/mercury endpoints returning 403 indicate missing OAuth scopes + if status.as_u16() == 403 + && (full_url.contains("u2c.wbx2.com") || full_url.contains("wdm")) + { + error!( + "HTTP 403 for {full_url}: {error_text} - likely missing required OAuth scopes" + ); + } else { + error!( + "HTTP {} error for {}: {}", + status.as_u16(), + full_url, + error_text + ); + } + return Err(Error::StatusText(status, error_text)); + } + + // Get response text for successful responses + let response_text = res.text().await?; + debug!("API Response for {full_url}: {response_text}"); + + // Parse the response + match serde_json::from_str(&response_text) { + Ok(parsed) => Ok(parsed), + Err(e) => { + error!("Failed to parse API response for {full_url}: {e}"); + error!("Raw response: {response_text}"); + Err(e.into()) + } + } } } @@ -423,16 +531,17 @@ impl Webex { system_version: Some(CRATE_VERSION.to_string()), ..DeviceData::default() }, + user_id: Arc::new(Mutex::new(None)), }; let devices_url = match webex.get_mercury_url().await { Ok(url) => { - trace!("Fetched mercury url {}", url); + trace!("Fetched mercury url {url}"); url } Err(e) => { debug!("Failed to fetch devices url, falling back to default"); - debug!("Error: {:?}", e); + debug!("Error: {e:?}"); DEFAULT_REGISTRATION_HOST_PREFIX.to_string() } }; @@ -456,10 +565,10 @@ impl Webex { }; let url = url::Url::parse(ws_url.as_str()) .map_err(|_| Error::from("Failed to parse ws_url"))?; - debug!("Connecting to {:?}", url); + debug!("Connecting to {url:?}"); match connect_async(url.as_str()).await { Ok((mut ws_stream, _response)) => { - debug!("Connected to {}", url); + debug!("Connected to {url}"); WebexEventStream::auth(&mut ws_stream, &s.token).await?; debug!("Authenticated"); let timeout = Duration::from_secs(20); @@ -470,9 +579,9 @@ impl Webex { }) } Err(e) => { - warn!("Failed to connect to {:?}: {:?}", url, e); + warn!("Failed to connect to {url:?}: {e:?}"); Err(Error::Tungstenite( - e, + Box::new(e), "Failed to connect to ws_url".to_string(), )) } @@ -486,7 +595,7 @@ impl Webex { .await? .iter() .filter(|d| d.name == self.device.name) - .inspect(|d| trace!("Kept device: {}", d)) + .inspect(|d| trace!("Kept device: {d}")) .cloned() .collect(); @@ -506,15 +615,26 @@ impl Webex { } // Failed to connect to any existing devices, creating new one - connect_device(self, self.setup_devices().await?).await + match self.setup_devices().await { + Ok(device) => connect_device(self, device).await, + Err(e) => match &e { + Error::StatusText(status, _) if *status == StatusCode::FORBIDDEN => { + error!("Device creation failed with 403 - event stream REQUIRES spark:devices_write and spark:devices_read scopes in your Webex integration"); + Err(e) + } + _ => { + error!("Failed to setup devices: {e}"); + Err(e) + } + }, + } } async fn get_mercury_url(&self) -> Result> { // Bit of a hacky workaround, error::Error does not implement clone // TODO: this can be fixed by returning a Result - lazy_static::lazy_static! { - static ref MERCURY_CACHE: Mutex>> = Mutex::new(HashMap::new()); - } + static MERCURY_CACHE: std::sync::LazyLock>>> = + std::sync::LazyLock::new(|| Mutex::new(HashMap::new())); if let Ok(Some(result)) = MERCURY_CACHE .lock() .map(|cache| cache.get(&self.id).cloned()) @@ -542,7 +662,21 @@ impl Webex { // // 4. Add caching because this doesn't change, and it can be slow - let orgs = self.list::().await?; + let orgs = match self.list::().await { + Ok(orgs) => orgs, + Err(e) => { + let error_msg = e.to_string(); + if error_msg.contains("missing required scopes") + || error_msg.contains("missing required roles") + { + debug!("Insufficient permissions to list organizations, falling back to default mercury URL"); + return Err( + "Can't get mercury URL with insufficient organization permissions".into(), + ); + } + return Err(e); + } + }; if orgs.is_empty() { return Err("Can't get mercury URL with no orgs".into()); } @@ -623,7 +757,7 @@ impl Webex { .collect(); let teams_rooms = try_join_all(futures).await?; for room in teams_rooms { - all_rooms.extend(room.items); + all_rooms.extend(room.items.or(room.devices).unwrap_or_else(Vec::new)); } Ok(all_rooms) } @@ -736,7 +870,7 @@ impl Webex { AuthorizationType::Bearer(&self.token), ) .await - .map(|result| result.items) + .map(|result| result.items.or(result.devices).unwrap_or_default()) } /// List resources of a type, with parameters @@ -751,7 +885,110 @@ impl Webex { AuthorizationType::Bearer(&self.token), ) .await - .map(|result| result.items) + .map(|result| result.items.or(result.devices).unwrap_or_default()) + } + + /// Get the current user's ID, caching it for future calls + /// + /// # Errors + /// * [`Error::Limited`] - returned on HTTP 423/429 with an optional Retry-After. + /// * [`Error::Status`] | [`Error::StatusText`] - returned when the request results in a non-200 code. + /// * [`Error::Json`] - returned when input/output cannot be serialized/deserialized. + /// * [`Error::UTF8`] - returned when the request returns non-UTF8 code. + async fn get_user_id(&self) -> Result { + // Check if we already have the user ID cached + if let Ok(guard) = self.user_id.lock() { + if let Some(cached_id) = guard.as_ref() { + return Ok(cached_id.clone()); + } + } + + // Fetch the user ID from the API + let me_global_id = types::GlobalId::new_with_cluster_unchecked( + types::GlobalIdType::Person, + "me".to_string(), + None, + ); + let me = self.get::(&me_global_id).await?; + + // Cache it for future use + if let Ok(mut guard) = self.user_id.lock() { + *guard = Some(me.id.clone()); + } + + debug!("Cached user ID: {}", me.id); + Ok(me.id) + } + + /// Leave a room by deleting the current user's membership + /// + /// # Arguments + /// * `room_id`: The ID of the room to leave + /// + /// # Errors + /// * [`Error::UserError`] - returned when attempting to leave a 1:1 direct room (not supported by Webex API) + /// * [`Error::Limited`] - returned on HTTP 423/429 with an optional Retry-After. + /// * [`Error::Status`] | [`Error::StatusText`] - returned when the request results in a non-200 code. + /// * [`Error::Json`] - returned when input/output cannot be serialized/deserialized. + /// * [`Error::UTF8`] - returned when the request returns non-UTF8 code. + /// + /// # Note + /// The Webex API does not support leaving or deleting 1:1 direct message rooms. + /// This function will return an error for direct rooms. Only group rooms can be left. + pub async fn leave_room(&self, room_id: &types::GlobalId) -> Result<(), Error> { + debug!("Leaving room: {}", room_id.id()); + + // First, get the room details to check if it's a direct room + let room = self.get::(room_id).await?; + + // Check if this is a 1:1 direct room - these cannot be left via API + if room.room_type == "direct" { + return Err(error::Error::UserError( + "Cannot leave a 1:1 direct message room. The Webex API does not support leaving or hiding direct rooms. Only group rooms can be left.".to_string() + )); + } + + // Get the current user ID (cached after first call) + let my_user_id = self.get_user_id().await?; + debug!("Current user ID: {my_user_id}"); + + // Get memberships in this room - we can use personId filter to get just our membership + let membership_params = types::MembershipListParams { + room_id: Some(room_id.id()), + person_id: Some(&my_user_id), + ..Default::default() + }; + + debug!("Fetching membership for user {my_user_id} in room"); + let memberships = self + .list_with_params::(membership_params) + .await?; + + debug!("Found {} matching memberships", memberships.len()); + + let membership = memberships.into_iter().next().ok_or_else(|| { + error!("Could not find membership for user '{my_user_id}' in room"); + error!( + "This usually means you are not a member of this room, or membership data is stale" + ); + error::Error::UserError("User is not a member of this room".to_string()) + })?; + + debug!("Found membership with ID: {}", membership.id); + let membership_id = + types::GlobalId::new(types::GlobalIdType::Membership, membership.id.clone())?; + let rest_method = format!("memberships/{}", membership_id.id()); + + self.client + .api_delete( + &rest_method, + None::<()>, + AuthorizationType::Bearer(&self.token), + ) + .await?; + debug!("Successfully left room: {}", room_id.id()); + + Ok(()) } async fn get_devices(&self) -> Result, Error> { @@ -770,19 +1007,80 @@ impl Webex { debug!("Chaining one-time device setup from devices query"); self.setup_devices().await.map(|device| vec![device]) } - Err(e) => match e { - Error::Status(s) | Error::StatusText(s, _) => { - if s == StatusCode::NOT_FOUND { - debug!("No devices found, creating new one"); - self.setup_devices().await.map(|device| vec![device]) - } else { - Err(e) - } - } - Error::Limited(_, _) => Err(e), - _ => Err(format!("Can't decode devices reply: {e}").into()), - }, + Err(e) => self.handle_get_devices_error(e).await, + } + } + + async fn handle_get_devices_error(&self, e: Error) -> Result, Error> { + match e { + Error::Status(s) => self.handle_status_error(s).await, + Error::StatusText(s, msg) => self.handle_status_text_error(s, &msg).await, + Error::Limited(_, _) => Err(e), + _ => { + error!("Can't decode devices reply: {e}"); + Err(format!("Can't decode devices reply: {e}").into()) + } + } + } + + async fn handle_status_error(&self, status: StatusCode) -> Result, Error> { + if status == StatusCode::NOT_FOUND { + debug!("No devices found (404), will create new device"); + self.setup_devices().await.map(|device| vec![device]) + } else if status == StatusCode::FORBIDDEN { + self.handle_forbidden_error(None).await + } else { + error!("Unexpected HTTP status {status} when listing devices"); + Err(Error::Status(status)) + } + } + + async fn handle_status_text_error( + &self, + status: StatusCode, + msg: &str, + ) -> Result, Error> { + if status == StatusCode::NOT_FOUND { + debug!("No devices found (404), will create new device"); + self.setup_devices().await.map(|device| vec![device]) + } else if status == StatusCode::FORBIDDEN { + self.handle_forbidden_error(Some(msg)).await + } else { + error!("Unexpected HTTP status {status} when listing devices: {msg}"); + Err(Error::StatusText(status, msg.to_string())) + } + } + + async fn handle_forbidden_error( + &self, + details: Option<&str>, + ) -> Result, Error> { + Self::log_forbidden_error(details); + match self.setup_devices().await { + Ok(device) => { + debug!("Surprisingly, device creation succeeded despite 403 on list"); + Ok(vec![device]) + } + Err(setup_err) => { + error!("Device creation also failed (expected): {setup_err}"); + error!("Cannot proceed without device access"); + Err(Error::Status(StatusCode::FORBIDDEN)) + } + } + } + + fn log_forbidden_error(details: Option<&str>) { + error!("========================================================================"); + error!("Device endpoint returned 403 Forbidden"); + error!("========================================================================"); + error!(" Your Webex integration token is missing required OAuth scopes:"); + error!(" - spark:devices_write (required to register device)"); + error!(" - spark:devices_read (required to list devices)"); + if let Some(msg) = details { + error!(""); + error!(" Error details: {msg}"); } + error!("========================================================================"); } async fn setup_devices(&self) -> Result { @@ -868,3 +1166,359 @@ impl MessageOut { self } } + +#[cfg(test)] +#[allow(clippy::significant_drop_tightening)] +mod tests { + use super::*; + use mockito::ServerGuard; + use serde_json::json; + use std::sync::atomic::{AtomicU64, Ordering}; + + static COUNTER: AtomicU64 = AtomicU64::new(0); + + /// Helper function to create a test Webex client with mocked `RestClient` + fn create_test_webex_client(server: &ServerGuard) -> Webex { + let mut host_prefix = HashMap::new(); + host_prefix.insert("people/me".to_string(), server.url()); + host_prefix.insert( + "rooms/Y2lzY29zcGFyazovL3VzL1JPT00vMTIzNDU2NzgtMTIzNC0xMjM0LTEyMzQtMTIzNDU2Nzg5MDEy" + .to_string(), + server.url(), + ); + host_prefix.insert("memberships".to_string(), server.url()); + host_prefix.insert("memberships/Y2lzY29zcGFyazovL3VzL01FTUJFUlNISVAvODc2NTQzMjEtNDMyMS00MzIxLTQzMjEtMjEwOTg3NjU0MzIx".to_string(), server.url()); + + let rest_client = RestClient { + host_prefix, + web_client: reqwest::Client::new(), + }; + + let device = DeviceData { + url: Some("test_url".to_string()), + ws_url: Some("ws://test".to_string()), + device_name: Some("test_device".to_string()), + device_type: Some("DESKTOP".to_string()), + localized_model: Some("rust-sdk-test".to_string()), + modification_time: Some(chrono::Utc::now()), + model: Some("rust-sdk-test".to_string()), + name: Some(format!( + "rust-sdk-test-{}", + COUNTER.fetch_add(1, Ordering::SeqCst) + )), + system_name: Some("rust-sdk-test".to_string()), + system_version: Some("0.1.0".to_string()), + }; + + Webex { + id: 1, + client: rest_client, + token: "test_token".to_string(), + device, + user_id: Arc::new(Mutex::new(None)), + } + } + + #[tokio::test] + async fn test_leave_room_success() { + let mut server = mockito::Server::new_async().await; + + // Mock the GET /rooms/{id} API call to check room type + let room_mock = server + .mock("GET", "/rooms/Y2lzY29zcGFyazovL3VzL1JPT00vMTIzNDU2NzgtMTIzNC0xMjM0LTEyMzQtMTIzNDU2Nzg5MDEy") + .match_header("authorization", "Bearer test_token") + .with_status(200) + .with_header("content-type", "application/json") + .with_body(json!({ + "id": "Y2lzY29zcGFyazovL3VzL1JPT00vMTIzNDU2NzgtMTIzNC0xMjM0LTEyMzQtMTIzNDU2Nzg5MDEy", + "title": "Test Room", + "type": "group", + "isLocked": false, + "lastActivity": "2024-01-01T00:00:00.000Z", + "creatorId": "test_person_id", + "created": "2024-01-01T00:00:00.000Z" + }).to_string()) + .create_async() + .await; + + // Mock the people/me API call + let people_mock = server + .mock("GET", "/people/me") + .match_header("authorization", "Bearer test_token") + .with_status(200) + .with_header("content-type", "application/json") + .with_body( + json!({ + "id": "test_person_id", + "emails": ["test@example.com"], + "displayName": "Test User", + "orgId": "test_org_id", + "created": "2024-01-01T00:00:00.000Z", + "lastActivity": "2024-01-01T00:00:00.000Z", + "status": "active", + "type": "person" + }) + .to_string(), + ) + .create_async() + .await; + + // Mock the membership list API call + let membership_mock = server + .mock("GET", "/memberships") + .match_header("authorization", "Bearer test_token") + .match_query(mockito::Matcher::UrlEncoded( + "roomId".into(), + "Y2lzY29zcGFyazovL3VzL1JPT00vMTIzNDU2NzgtMTIzNC0xMjM0LTEyMzQtMTIzNDU2Nzg5MDEy" + .into(), + )) + .with_status(200) + .with_header("content-type", "application/json") + .with_body( + r#"{ + "items": [{ + "id": "87654321-4321-4321-4321-210987654321", + "roomId": "test_room_id", + "personId": "test_person_id", + "personEmail": "test@example.com", + "personDisplayName": "Test User", + "personOrgId": "test_org_id", + "isModerator": false, + "isMonitor": false, + "created": "2024-01-01T00:00:00.000Z" + }] + }"#, + ) + .create_async() + .await; + + // Mock the membership deletion API call + let delete_mock = server + .mock("DELETE", "/memberships/Y2lzY29zcGFyazovL3VzL01FTUJFUlNISVAvODc2NTQzMjEtNDMyMS00MzIxLTQzMjEtMjEwOTg3NjU0MzIx") + .match_header("authorization", "Bearer test_token") + .with_status(204) + .with_body("") + .create_async() + .await; + + let webex_client = create_test_webex_client(&server); + let room_id = types::GlobalId::new( + types::GlobalIdType::Room, + "12345678-1234-1234-1234-123456789012".to_string(), + ) + .unwrap(); + + let result = webex_client.leave_room(&room_id).await; + + if let Err(e) = &result { + eprintln!("Error: {e}"); + } + assert!(result.is_ok()); + room_mock.assert_async().await; + people_mock.assert_async().await; + membership_mock.assert_async().await; + delete_mock.assert_async().await; + } + + #[tokio::test] + async fn test_leave_room_user_not_member() { + let mut server = mockito::Server::new_async().await; + + // Mock the GET /rooms/{id} API call to check room type + let room_mock = server + .mock("GET", "/rooms/Y2lzY29zcGFyazovL3VzL1JPT00vMTIzNDU2NzgtMTIzNC0xMjM0LTEyMzQtMTIzNDU2Nzg5MDEy") + .match_header("authorization", "Bearer test_token") + .with_status(200) + .with_header("content-type", "application/json") + .with_body(json!({ + "id": "Y2lzY29zcGFyazovL3VzL1JPT00vMTIzNDU2NzgtMTIzNC0xMjM0LTEyMzQtMTIzNDU2Nzg5MDEy", + "title": "Test Room", + "type": "group", + "isLocked": false, + "lastActivity": "2024-01-01T00:00:00.000Z", + "creatorId": "test_person_id", + "created": "2024-01-01T00:00:00.000Z" + }).to_string()) + .create_async() + .await; + + // Mock the people/me API call + let people_mock = server + .mock("GET", "/people/me") + .match_header("authorization", "Bearer test_token") + .with_status(200) + .with_header("content-type", "application/json") + .with_body( + json!({ + "id": "test_person_id", + "emails": ["test@example.com"], + "displayName": "Test User", + "orgId": "test_org_id", + "created": "2024-01-01T00:00:00.000Z", + "lastActivity": "2024-01-01T00:00:00.000Z", + "status": "active", + "type": "person" + }) + .to_string(), + ) + .create_async() + .await; + + // Mock the membership list API call returning empty list + let membership_mock = server + .mock("GET", "/memberships") + .match_query(mockito::Matcher::UrlEncoded( + "roomId".into(), + "Y2lzY29zcGFyazovL3VzL1JPT00vMTIzNDU2NzgtMTIzNC0xMjM0LTEyMzQtMTIzNDU2Nzg5MDEy" + .into(), + )) + .match_header("authorization", "Bearer test_token") + .with_status(200) + .with_header("content-type", "application/json") + .with_body( + json!({ + "items": [] + }) + .to_string(), + ) + .create_async() + .await; + + let webex_client = create_test_webex_client(&server); + let room_id = types::GlobalId::new( + types::GlobalIdType::Room, + "12345678-1234-1234-1234-123456789012".to_string(), + ) + .unwrap(); + + let result = webex_client.leave_room(&room_id).await; + + assert!(result.is_err()); + if let Err(error) = result { + assert_eq!(error.to_string(), "User is not a member of this room"); + } + room_mock.assert_async().await; + people_mock.assert_async().await; + membership_mock.assert_async().await; + } + + #[tokio::test] + async fn test_leave_room_api_error() { + let mut server = mockito::Server::new_async().await; + + // Mock the GET /rooms/{id} API call to check room type + let room_mock = server + .mock("GET", "/rooms/Y2lzY29zcGFyazovL3VzL1JPT00vMTIzNDU2NzgtMTIzNC0xMjM0LTEyMzQtMTIzNDU2Nzg5MDEy") + .match_header("authorization", "Bearer test_token") + .with_status(200) + .with_header("content-type", "application/json") + .with_body(json!({ + "id": "Y2lzY29zcGFyazovL3VzL1JPT00vMTIzNDU2NzgtMTIzNC0xMjM0LTEyMzQtMTIzNDU2Nzg5MDEy", + "title": "Test Room", + "type": "group", + "isLocked": false, + "lastActivity": "2024-01-01T00:00:00.000Z", + "creatorId": "test_person_id", + "created": "2024-01-01T00:00:00.000Z" + }).to_string()) + .create_async() + .await; + + // Mock the people/me API call + let people_mock = server + .mock("GET", "/people/me") + .match_header("authorization", "Bearer test_token") + .with_status(200) + .with_header("content-type", "application/json") + .with_body( + json!({ + "id": "test_person_id", + "emails": ["test@example.com"], + "displayName": "Test User", + "orgId": "test_org_id", + "created": "2024-01-01T00:00:00.000Z", + "lastActivity": "2024-01-01T00:00:00.000Z", + "status": "active", + "type": "person" + }) + .to_string(), + ) + .create_async() + .await; + + // Mock the membership list API call returning error + let membership_mock = server + .mock("GET", "/memberships") + .match_query(mockito::Matcher::UrlEncoded( + "roomId".into(), + "Y2lzY29zcGFyazovL3VzL1JPT00vMTIzNDU2NzgtMTIzNC0xMjM0LTEyMzQtMTIzNDU2Nzg5MDEy" + .into(), + )) + .match_header("authorization", "Bearer test_token") + .with_status(403) + .with_header("content-type", "application/json") + .with_body( + json!({ + "message": "Access denied", + "errors": [] + }) + .to_string(), + ) + .create_async() + .await; + + let webex_client = create_test_webex_client(&server); + let room_id = types::GlobalId::new( + types::GlobalIdType::Room, + "12345678-1234-1234-1234-123456789012".to_string(), + ) + .unwrap(); + + let result = webex_client.leave_room(&room_id).await; + + assert!(result.is_err()); + room_mock.assert_async().await; + people_mock.assert_async().await; + membership_mock.assert_async().await; + } + + #[tokio::test] + async fn test_leave_room_direct_room_error() { + let mut server = mockito::Server::new_async().await; + + // Mock the GET /rooms/{id} API call - return a direct room + let room_mock = server + .mock("GET", "/rooms/Y2lzY29zcGFyazovL3VzL1JPT00vMTIzNDU2NzgtMTIzNC0xMjM0LTEyMzQtMTIzNDU2Nzg5MDEy") + .match_header("authorization", "Bearer test_token") + .with_status(200) + .with_header("content-type", "application/json") + .with_body(json!({ + "id": "Y2lzY29zcGFyazovL3VzL1JPT00vMTIzNDU2NzgtMTIzNC0xMjM0LTEyMzQtMTIzNDU2Nzg5MDEy", + "title": "Direct Chat", + "type": "direct", + "isLocked": false, + "lastActivity": "2024-01-01T00:00:00.000Z", + "creatorId": "test_person_id", + "created": "2024-01-01T00:00:00.000Z" + }).to_string()) + .create_async() + .await; + + let webex_client = create_test_webex_client(&server); + let room_id = types::GlobalId::new( + types::GlobalIdType::Room, + "12345678-1234-1234-1234-123456789012".to_string(), + ) + .unwrap(); + + let result = webex_client.leave_room(&room_id).await; + + assert!(result.is_err()); + if let Err(error) = result { + assert!(error + .to_string() + .contains("Cannot leave a 1:1 direct message room")); + } + room_mock.assert_async().await; + } +} diff --git a/src/types.rs b/src/types.rs index 9bd0d66..236eb8a 100644 --- a/src/types.rs +++ b/src/types.rs @@ -3,6 +3,7 @@ use crate::{adaptive_card::AdaptiveCard, error}; use base64::Engine; + use serde::{Deserialize, Serialize}; use serde_with::skip_serializing_none; use std::convert::TryFrom; @@ -14,8 +15,8 @@ pub(crate) use api::{Gettable, ListResult}; mod api { //! Private crate to hold all types that the user shouldn't have to interact with. use super::{ - AttachmentAction, Message, MessageListParams, Organization, Person, Room, RoomListParams, - Team, + AttachmentAction, Membership, MembershipListParams, Message, MessageListParams, + Organization, Person, Room, RoomListParams, Team, }; /// Trait for API types. Has to be public due to trait bounds limitations on webex API, but hidden @@ -60,9 +61,22 @@ mod api { type ListParams<'a> = Option; } + impl Gettable for Membership { + const API_ENDPOINT: &'static str = "memberships"; + type ListParams<'a> = MembershipListParams<'a>; + } + #[derive(crate::types::Deserialize)] + #[serde(rename_all = "camelCase")] pub struct ListResult { - pub items: Vec, + pub items: Option>, + // Some API endpoints might return different field names + pub devices: Option>, + // Handle error cases - allow dead_code since these are for future API error handling + #[allow(dead_code)] + pub message: Option, + #[allow(dead_code)] + pub errors: Option>, } } @@ -154,6 +168,52 @@ pub struct Team { pub description: Option, } +/// Webex Teams membership information +#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)] +pub struct Membership { + /// A unique identifier for the membership. + pub id: String, + /// The room ID associated with this membership. + #[serde(default, rename = "roomId")] + pub room_id: String, + /// The person ID associated with this membership. + #[serde(default, rename = "personId")] + pub person_id: String, + /// The email address of the person. + #[serde(rename = "personEmail")] + pub person_email: Option, + /// The display name of the person. + #[serde(rename = "personDisplayName")] + pub person_display_name: Option, + /// The organization ID of the person. + #[serde(rename = "personOrgId")] + pub person_org_id: Option, + /// Whether or not the participant is a moderator of the room. + #[serde(rename = "isModerator")] + pub is_moderator: bool, + /// Whether or not the participant is a monitor of the room. + #[serde(rename = "isMonitor")] + pub is_monitor: bool, + /// The date and time when the membership was created. + pub created: String, +} + +#[skip_serializing_none] +#[derive(Clone, Debug, Default, Eq, PartialEq, Serialize)] +#[serde(rename_all = "camelCase")] +/// Parameters for listing memberships +pub struct MembershipListParams<'a> { + /// List memberships for a room, by ID. + pub room_id: Option<&'a str>, + /// List memberships for a person, by ID. + pub person_id: Option<&'a str>, + /// List memberships for a person, by email address. + pub person_email: Option<&'a str>, + /// Limit the maximum number of memberships in the response. + /// Default: 100 + pub max: Option, +} + #[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)] #[serde(rename_all = "camelCase")] pub(crate) struct CatalogReply { @@ -336,6 +396,7 @@ pub struct MessageEditParams<'a> { } #[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)] +#[allow(dead_code)] pub(crate) struct EmptyReply {} /// API Error @@ -612,8 +673,7 @@ impl Event { ActivityType::Space(type_) } else { log::error!( - "Unknown activity type `{}`, returning Unknown", - activity_type + "Unknown activity type `{activity_type}`, returning Unknown" ); ActivityType::Unknown(format!("conversation.activity.{activity_type}")) } @@ -626,7 +686,7 @@ impl Event { "janus.user_sessions" => ActivityType::Janus, //"apheleia.subscription_update" ?? e => { - log::debug!("Unknown data.event_type `{}`, returning Unknown", e); + log::debug!("Unknown data.event_type `{e}`, returning Unknown"); ActivityType::Unknown(e.to_string()) } } @@ -668,8 +728,8 @@ impl Event { ActivityType::Space(SpaceActivity::Created) => self.room_id_of_space_created_event()?, ActivityType::Space( SpaceActivity::Changed | SpaceActivity::Joined | SpaceActivity::Left, - ) => Self::target_global_id(activity)?, - ActivityType::Message(MessageActivity::Deleted) => Self::target_global_id(activity)?, + ) + | ActivityType::Message(MessageActivity::Deleted) => Self::target_global_id(activity)?, _ => activity.id.clone(), }; Ok(GlobalId::new_with_cluster_unchecked( @@ -742,6 +802,8 @@ pub enum GlobalIdType { Team, /// Retrieves a specific attachment AttachmentAction, + /// Corresponds to the ID of a membership + Membership, /// This `GlobalId` represents the ID of something not currently recognised, any API requests /// with this `GlobalId` will produce an error. Unknown, @@ -759,10 +821,7 @@ impl From for GlobalIdType { ) => Self::Room, ActivityType::Unknown(_) => Self::Unknown, a => { - log::error!( - "Failed to convert {:?} to GlobalIdType, this may cause errors later", - a - ); + log::error!("Failed to convert {a:?} to GlobalIdType, this may cause errors later"); Self::Unknown } } @@ -779,6 +838,7 @@ impl std::fmt::Display for GlobalIdType { Self::Room => "ROOM", Self::Team => "TEAM", Self::AttachmentAction => "ATTACHMENT_ACTION", + Self::Membership => "MEMBERSHIP", Self::Unknown => "", } ) @@ -951,6 +1011,7 @@ pub struct MiscItem { } /// Alerting specified in received events. +/// /// TODO: may be missing some enum variants. /// ALSO TODO: figure out what this does. Best guess, it refers to what alerts (e.g. a /// notification) an event will generate. @@ -1032,18 +1093,20 @@ pub struct Person { /// The email addresses of the person. pub emails: Vec, /// Phone numbers for the person. - pub phone_numbers: Vec, + pub phone_numbers: Option>, /// The full name of the person. + #[serde(rename = "displayName")] pub display_name: String, /// The nickname of the person if configured. If no nickname is configured for the person, this field will not be present. - pub nick_name: String, + pub nick_name: Option, /// The first name of the person. - pub first_name: String, + pub first_name: Option, /// The last name of the person. - pub last_name: String, + pub last_name: Option, /// The URL to the person's avatar in PNG format. - pub avatar: String, + pub avatar: Option, /// The ID of the organization to which this person belongs. + #[serde(rename = "orgId")] pub org_id: String, /// The date and time the person was created. pub created: String, @@ -1161,7 +1224,7 @@ mod tests { event.room_id_of_space_created_event().unwrap(), "1ab849e0-9ab4-11ee-a70f-d9b57e49f8bf" ); - // invalid UUID (assumed base64) should return itself unmodified + // invalid UUID (assumed base64) should not be changed event.data.activity = Some(Activity { verb: "create".to_string(), id: "bogus".to_string(),