diff --git a/.github/workflows/rust.yml b/.github/workflows/rust.yml index 9f37814..1a3758c 100644 --- a/.github/workflows/rust.yml +++ b/.github/workflows/rust.yml @@ -8,15 +8,47 @@ on: env: CARGO_TERM_COLOR: always + RUST_BACKTRACE: 1 jobs: - build: + test: + name: Test + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - name: Run tests + run: cargo test --verbose + + clippy: + name: Clippy + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - name: Run clippy (warnings only) + run: cargo clippy --all-targets --all-features + fmt: + name: Format runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - name: Check formatting + run: cargo fmt -- --check + build: + name: Build + runs-on: ubuntu-latest steps: - - uses: actions/checkout@v3 + - uses: actions/checkout@v4 - name: Build crate run: cargo build --verbose - - name: Buld examples - run: cargo build --examples \ No newline at end of file + - name: Build examples + run: cargo build --examples --verbose + + doc: + name: Documentation + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - name: Check documentation + run: cargo doc --no-deps --document-private-items diff --git a/.gitignore b/.gitignore index a43dfa6..841f71f 100644 --- a/.gitignore +++ b/.gitignore @@ -3,3 +3,6 @@ Cargo.lock .claude/settings.local.json + +# IDE +.idea/ diff --git a/AGENTS.md b/AGENTS.md index fddfa89..bd53dde 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -26,16 +26,21 @@ This is `webex-rust`, an asynchronous Rust library providing a minimal interface - `cargo clippy --all-targets --all-features` - Full clippy check - `cargo build --all-targets` - Build everything including examples +### Git Hooks +- `./hooks/install.sh` - Install pre-commit hooks that automatically run cargo fmt +- Pre-commit hook ensures code is formatted before each commit + ## 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 +- **`Webex` struct** (`src/client/mod.rs`) - Main API client with token-based authentication +- **`WebexEventStream`** (`src/client/websocket.rs`) - WebSocket event stream handler for real-time events +- **`RestClient`** (`src/client/rest.rs`) - Low-level HTTP client wrapper with flexible authentication +- **Client module** (`src/client/`) - Client implementation split into modular components +- **Types module** (`src/types/`) - All API data structures organized by resource type +- **AdaptiveCard module** (`src/adaptive_card/`) - Support for interactive cards with builders +- **Auth module** (`src/auth.rs`) - Device authentication flows (OAuth device grant) - **Error module** (`src/error.rs`) - Comprehensive error handling ### Key Patterns @@ -54,8 +59,21 @@ This is `webex-rust`, an asynchronous Rust library providing a minimal interface ## Important Notes -- Uses Rust 1.76 toolchain (see `rust-toolchain.toml`) +- Uses Rust 1.92 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 +- Mercury URL caching reduces API calls for device discovery +- Comprehensive CI workflow with tests, clippy, fmt, build, and doc checks +- Git pre-commit hooks available in `hooks/` directory to auto-format code + +## Recent Refactoring (v0.11.0) + +- **Module organization**: Refactored large files into logical modules + - `src/lib.rs` reduced from 1532 lines to 54 lines (thin orchestrator) + - `src/client/` module split into `mod.rs`, `rest.rs`, and `websocket.rs` + - `src/types/` module organized by resource type (message, room, person, etc.) + - `src/adaptive_card/` module split into elements, containers, and styles +- **Backward compatibility**: All public APIs maintained, including Clone trait on Webex struct +- **Test coverage**: 37 unit tests ensuring functionality after refactoring +- **Documentation**: Fixed broken doc links, cargo doc builds with zero warnings \ No newline at end of file diff --git a/Cargo.toml b/Cargo.toml index eef5c19..f9e632e 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "webex" -version = "0.10.0" +version = "0.11.0" authors = [ "Scott Hutton ", "Milan Stastny ", @@ -20,12 +20,11 @@ futures = "0.3.30" futures-util = "0.3.30" log = "0.4" serde_json = "1.0" -tungstenite = "0.23.0" +tungstenite = "0.28" url = "2.5" -lazy_static = "1.5.0" serde_html_form = "0.2.6" serde_with = { version = "3.9.0", features = ["macros"] } -thiserror = "1.0.63" +thiserror = "2.0" reqwest = { version = "0.12.5", features = ["json"] } [dependencies.chrono] @@ -38,10 +37,10 @@ features = ["derive"] [dependencies.tokio] version = "1.39" -features = ["full"] +features = ["macros", "net", "time", "rt-multi-thread"] [dependencies.tokio-tungstenite] -version = "0.23.1" +version = "0.28" features = ["connect", "native-tls"] [dependencies.uuid] diff --git a/examples/adaptivecard.rs b/examples/adaptivecard.rs index 440bff4..176656a 100644 --- a/examples/adaptivecard.rs +++ b/examples/adaptivecard.rs @@ -134,10 +134,10 @@ async fn respond_to_message(webex: &webex::Webex, config: &Config, event: &webex // Send event card reply.text = Some("Welcome to Adaptivecard Tester Bot".into()); let mut body = CardElement::container(); - body.add_element(CardElement::text_block( + let _ = body.add_element(CardElement::text_block( "Welcome to Adaptivecard Tester Bot!", )); - body.add_element( + let _ = body.add_element( CardElement::column_set() .add_column( webex::adaptive_card::Column::new() @@ -154,7 +154,7 @@ async fn respond_to_message(webex: &webex::Webex, config: &Config, event: &webex .add_element(CardElement::input_text("input2", None::<&'static str>)), ), ); - body.add_element(CardElement::action_set().add_action_to_set( + let _ = body.add_element(CardElement::action_set().add_action_to_set( webex::adaptive_card::Action::Submit { data: Some(HashMap::from([("id".into(), "init".into())])), title: Some("Submit".into()), diff --git a/hooks/README.md b/hooks/README.md new file mode 100644 index 0000000..48209ac --- /dev/null +++ b/hooks/README.md @@ -0,0 +1,50 @@ +# Git Hooks for webex-rust + +This directory contains git hooks to maintain code quality and consistency. + +## Available Hooks + +### pre-commit + +Automatically runs `cargo fmt` before each commit to ensure all code is properly formatted. + +**What it does:** +- Checks if code formatting is required +- Runs `cargo fmt --all` if needed +- Automatically adds formatted files to the commit +- Prevents commits with formatting issues + +## Installation + +To install the hooks, run: + +```bash +./hooks/install.sh +``` + +Or manually: + +```bash +cp hooks/pre-commit .git/hooks/pre-commit +chmod +x .git/hooks/pre-commit +``` + +## Uninstalling + +To remove the hooks: + +```bash +rm .git/hooks/pre-commit +``` + +## Bypassing Hooks + +If you need to bypass the hooks temporarily (not recommended): + +```bash +git commit --no-verify +``` + +## CI/CD + +The CI pipeline in `.github/workflows/` runs the same checks, so even if hooks are bypassed locally, the CI will catch formatting issues. diff --git a/hooks/install.sh b/hooks/install.sh new file mode 100755 index 0000000..cc1fc08 --- /dev/null +++ b/hooks/install.sh @@ -0,0 +1,23 @@ +#!/bin/sh +# Install git hooks for webex-rust development + +set -e + +HOOKS_DIR="$(cd "$(dirname "$0")" && pwd)" +GIT_HOOKS_DIR="$(git rev-parse --git-dir)/hooks" + +echo "Installing git hooks..." + +# Install pre-commit hook +if [ -f "$HOOKS_DIR/pre-commit" ]; then + cp "$HOOKS_DIR/pre-commit" "$GIT_HOOKS_DIR/pre-commit" + chmod +x "$GIT_HOOKS_DIR/pre-commit" + echo "✓ Installed pre-commit hook" +else + echo "✗ pre-commit hook not found" + exit 1 +fi + +echo "" +echo "Git hooks installed successfully!" +echo "The pre-commit hook will automatically format your code with 'cargo fmt' before each commit." diff --git a/hooks/pre-commit b/hooks/pre-commit new file mode 100755 index 0000000..60511de --- /dev/null +++ b/hooks/pre-commit @@ -0,0 +1,21 @@ +#!/bin/sh +# Pre-commit hook for webex-rust +# Automatically formats Rust code with cargo fmt before committing + +set -e + +echo "Running cargo fmt..." + +# Run cargo fmt on all Rust files +if ! cargo fmt --all --check &>/dev/null; then + echo "Code formatting issues found. Running cargo fmt..." + cargo fmt --all + + # Add the formatted files to the commit + git add -u + + echo "✓ Code formatted successfully" +fi + +echo "✓ Pre-commit checks passed" +exit 0 diff --git a/rust-toolchain.toml b/rust-toolchain.toml index d61a253..203c03e 100644 --- a/rust-toolchain.toml +++ b/rust-toolchain.toml @@ -1,4 +1,4 @@ [toolchain] # The default profile includes rustc, rust-std, cargo, rust-docs, rustfmt and clippy. profile = "default" -channel = "1.88" +channel = "stable" diff --git a/src/adaptive_card/containers.rs b/src/adaptive_card/containers.rs new file mode 100644 index 0000000..82917fc --- /dev/null +++ b/src/adaptive_card/containers.rs @@ -0,0 +1,119 @@ +//! Container structures for organizing Adaptive Card content. + +use serde::{Deserialize, Serialize}; + +use super::elements::CardElement; +use super::{Action, ContainerStyle, Spacing, VerticalContentAlignment}; + +/// Describes a choice for use in a `ChoiceSet`. +#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)] +pub struct Choice { + /// Text to display. + pub title: String, + /// The raw value for the choice. **NOTE:** do not use a , in the value, since a `ChoiceSet` with isMultiSelect set to true returns a comma-delimited string of choice values. + pub value: String, +} + +/// Describes a Fact in a `FactSet` as a key/value pair. +#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)] +pub struct Fact { + /// The title of the fact. + pub title: String, + /// The value of the fact. + pub value: String, +} + +/// Column in a `ColumnSet` +#[derive(Clone, Debug, Deserialize, PartialEq, Serialize)] +pub struct Column { + /// The card elements to render inside the Column. + #[serde(default)] + pub items: Vec, + /// An Action that will be invoked when the Column is tapped or selected. + #[serde(rename = "selectAction", skip_serializing_if = "Option::is_none")] + select_action: Option, + /// Style hint for Column. + #[serde(skip_serializing_if = "Option::is_none")] + style: Option, + /// Defines how the content should be aligned vertically within the column. + #[serde( + rename = "verticalContentAlignment", + skip_serializing_if = "Option::is_none" + )] + vertical_content_alignment: Option, + /// When true, draw a separating line between this column and the previous column. + #[serde(skip_serializing_if = "Option::is_none")] + separator: Option, + /// Controls the amount of spacing between this column and the preceding column. + #[serde(skip_serializing_if = "Option::is_none")] + spacing: Option, + /// "auto", "stretch", a number representing relative width of the column in the column group, or in version 1.1 and higher, a specific pixel width, like "50px". + #[serde(skip_serializing_if = "Option::is_none")] + width: Option, + /// A unique identifier associated with the item. + #[serde(skip_serializing_if = "Option::is_none")] + id: Option, +} + +impl From<&Self> for Column { + fn from(item: &Self) -> Self { + item.clone() + } +} + +impl From<&mut Self> for Column { + fn from(item: &mut Self) -> Self { + item.clone() + } +} + +impl Column { + /// Creates new Column + #[must_use] + pub const fn new() -> Self { + Self { + items: vec![], + select_action: None, + style: None, + vertical_content_alignment: None, + separator: None, + spacing: None, + width: None, + id: None, + } + } + + /// Adds element to column + #[must_use] + pub fn add_element(&mut self, item: CardElement) -> Self { + self.items.push(item); + self.into() + } + + /// Sets separator + #[must_use] + pub fn set_separator(&mut self, s: bool) -> Self { + self.separator = Some(s); + self.into() + } + + /// Sets `VerticalContentAlignment` + #[must_use] + pub fn set_vertical_alignment(&mut self, s: VerticalContentAlignment) -> Self { + self.vertical_content_alignment = Some(s); + self.into() + } + + /// Sets width + #[must_use] + pub fn set_width>(&mut self, s: T) -> Self { + self.width = Some(serde_json::Value::String(s.into())); + self.into() + } +} + +impl Default for Column { + fn default() -> Self { + Self::new() + } +} diff --git a/src/adaptive_card.rs b/src/adaptive_card/elements.rs similarity index 67% rename from src/adaptive_card.rs rename to src/adaptive_card/elements.rs index 40b0f7a..c2abd94 100644 --- a/src/adaptive_card.rs +++ b/src/adaptive_card/elements.rs @@ -1,119 +1,15 @@ -#![deny(missing_docs)] -#![allow(clippy::return_self_not_must_use)] -//! Adaptive Card implementation -//! -//! [Webex Teams currently supports only version 1.1](https://developer.webex.com/docs/cards) -//! -//! More info about the schema can be found [here](https://adaptivecards.io/explorer/) +//! Card elements for building Adaptive Card content. use serde::{Deserialize, Serialize}; -use std::collections::HashMap; -/// Adaptive Card structure for message attachment -#[derive(Clone, Debug, Default, Deserialize, PartialEq, Serialize)] -pub struct AdaptiveCard { - /// Must be "`AdaptiveCard`" - #[serde(rename = "type")] - pub card_type: String, - /// Schema version that this card requires. If a client is lower than this version, the fallbackText will be rendered. - /// Maximum version is 1.1 - #[serde(default = "default_version")] // Workaround for Webex not always providing it :/ - pub version: String, - /// The card elements to show in the primary card region. - #[serde(skip_serializing_if = "Option::is_none")] - pub body: Option>, - /// Actions available for this card - #[serde(skip_serializing_if = "Option::is_none")] - pub actions: Option>, - /// An Action that will be invoked when the card is tapped or selected. - #[serde(rename = "selectAction", skip_serializing_if = "Option::is_none")] - pub select_action: Option>, - /// Text shown when the client doesn’t support the version specified (may contain markdown). - #[serde(rename = "fallbackText", skip_serializing_if = "Option::is_none")] - pub fallback_text: Option, - /// Specifies the minimum height of the card. - #[serde(rename = "minHeight", skip_serializing_if = "Option::is_none")] - pub min_height: Option, - /// The 2-letter ISO-639-1 language used in the card. Used to localize any date/time functions. - #[serde(skip_serializing_if = "Option::is_none")] - pub lang: Option, - /// The Adaptive Card schema. - /// - #[serde(rename = "$schema")] - #[serde(skip_serializing_if = "Option::is_none")] - pub schema: Option, -} - -impl AdaptiveCard { - /// Create new adaptive card with mandatory defaults - #[must_use] - pub fn new() -> Self { - Self { - card_type: "AdaptiveCard".to_string(), - version: "1.1".to_string(), - body: None, - actions: None, - select_action: None, - fallback_text: None, - min_height: None, - lang: None, - schema: Some("http://adaptivecards.io/schemas/adaptive-card.json".to_string()), - } - } - - /// Adds Element to body - /// - /// # Arguments - /// - /// * `card` - `CardElement` to add - pub fn add_body>(&mut self, card: T) -> Self { - //self.body = self.body.map_or_else(|| Some(vec![card.into()]), |body| body.push(card.into())); - //self.body = Some(self.body.unwrap_or_default().push(card.into())); - // TODO: improve this - can we use take()? - self.body = Some(match self.body.clone() { - None => { - vec![card.into()] - } - Some(mut body) => { - body.push(card.into()); - body - } - }); - self.into() - } - - /// Adds Actions - /// - /// # Arguments - /// - /// * `action` - Action to add - pub fn add_action>(&mut self, a: T) -> Self { - self.actions = Some(match self.actions.clone() { - None => { - vec![a.into()] - } - Some(mut action) => { - action.push(a.into()); - action - } - }); - self.into() - } -} +use super::containers::{Choice, Column, Fact}; +use super::styles::{ + ChoiceInputStyle, Color, ContainerStyle, FontType, Height, HorizontalAlignment, ImageSize, + ImageStyle, Size, Spacing, TextInputStyle, VerticalContentAlignment, Weight, +}; +use super::Action; -impl From<&Self> for AdaptiveCard { - fn from(item: &Self) -> Self { - item.clone() - } -} - -impl From<&mut Self> for AdaptiveCard { - fn from(item: &mut Self) -> Self { - item.clone() - } -} - -/// Card element types +/// Represents the various types of elements that can be included in an Adaptive Card. #[derive(Clone, Debug, Deserialize, PartialEq, Serialize)] #[serde(tag = "type")] pub enum CardElement { @@ -168,7 +64,7 @@ pub enum CardElement { /// The `FactSet` element displays a series of facts (i.e. name/value pairs) in a tabular form. FactSet { - /// The array of Fact‘s. + /// The array of Fact's. facts: Vec, /// Specifies the height of the element. #[serde(skip_serializing_if = "Option::is_none")] @@ -261,10 +157,10 @@ pub enum CardElement { /// hex value of a color (e.g. #982374) #[serde(rename = "backgroundColor", skip_serializing_if = "Option::is_none")] background_color: Option, - /// The desired on-screen width of the image, ending in ‘px’. E.g., 50px. This overrides the size property. + /// The desired on-screen width of the image, ending in 'px'. E.g., 50px. This overrides the size property. #[serde(skip_serializing_if = "Option::is_none")] width: Option, - /// The desired height of the image. If specified as a pixel value, ending in ‘px’, E.g., 50px, the image will distort to fit that exact height. This overrides the size property. + /// The desired height of the image. If specified as a pixel value, ending in 'px', E.g., 50px, the image will distort to fit that exact height. This overrides the size property. #[serde(skip_serializing_if = "Option::is_none")] height: Option, /// Controls how this element is horizontally positioned within its parent. @@ -413,7 +309,7 @@ pub enum CardElement { InputToggle { /// Unique identifier for the value. Used to identify collected input when the Submit action is performed. id: String, - /// The initial selected value. If you want the toggle to be initially on, set this to the value of valueOn‘s value. + /// The initial selected value. If you want the toggle to be initially on, set this to the value of valueOn's value. #[serde(skip_serializing_if = "Option::is_none")] value: Option, /// The value when toggle is off @@ -470,6 +366,18 @@ pub enum CardElement { /// Specifies the height of the element. #[serde(skip_serializing_if = "Option::is_none")] height: Option, + /// Controls the horizontal text alignment. + #[serde( + rename = "HorizontalAlignment", + skip_serializing_if = "Option::is_none" + )] + horizontal_alignment: Option, + /// When true, draw a separating line at the top of the element. + #[serde(skip_serializing_if = "Option::is_none")] + separator: Option, + /// Controls the amount of spacing between this element and the preceding element. + #[serde(skip_serializing_if = "Option::is_none")] + spacing: Option, }, } @@ -503,6 +411,7 @@ impl CardElement { } /// Add element to Container + #[must_use] pub fn add_element>(&mut self, element: T) -> Self { if let Self::Container { items, .. } = self { items.push(element.into()); @@ -511,6 +420,7 @@ impl CardElement { } /// Set Container Style + #[must_use] pub fn set_container_style(&mut self, s: ContainerStyle) -> Self { if let Self::Container { style, .. } = self { *style = Some(s); @@ -519,6 +429,7 @@ impl CardElement { } /// Set container contents vertical alignment + #[must_use] pub fn set_vertical_alignment(&mut self, align: VerticalContentAlignment) -> Self { if let Self::Container { vertical_content_alignment, @@ -548,6 +459,7 @@ impl CardElement { } /// Set Text Input Multiline + #[must_use] pub fn set_multiline(&mut self, s: bool) -> Self { if let Self::InputText { is_multiline, .. } = self { *is_multiline = Some(s); @@ -586,6 +498,7 @@ impl CardElement { } /// Set choiceSet Style + #[must_use] pub fn set_style(&mut self, s: ChoiceInputStyle) -> Self { if let Self::InputChoiceSet { style, .. } = self { *style = Some(s); @@ -594,6 +507,7 @@ impl CardElement { } /// Set title Style + #[must_use] pub fn set_title(&mut self, s: String) -> Self { if let Self::InputToggle { title, .. } = self { *title = Some(s); @@ -602,6 +516,7 @@ impl CardElement { } /// Set choiceSet Style + #[must_use] pub fn set_multiselect(&mut self, b: bool) -> Self { if let Self::InputChoiceSet { is_multi_select, .. @@ -637,6 +552,7 @@ impl CardElement { } /// Set Text Weight + #[must_use] pub fn set_weight(&mut self, w: Weight) -> Self { if let Self::TextBlock { weight, .. } = self { *weight = Some(w); @@ -645,6 +561,7 @@ impl CardElement { } /// Set Text Font Type + #[must_use] pub fn set_font(&mut self, f: FontType) -> Self { if let Self::TextBlock { font_type, .. } = self { *font_type = Some(f); @@ -653,6 +570,7 @@ impl CardElement { } /// Set Text Size + #[must_use] pub fn set_size(&mut self, s: Size) -> Self { if let Self::TextBlock { size, .. } = self { *size = Some(s); @@ -661,6 +579,7 @@ impl CardElement { } /// Set Text Color + #[must_use] pub fn set_color(&mut self, c: Color) -> Self { if let Self::TextBlock { color, .. } = self { *color = Some(c); @@ -669,6 +588,7 @@ impl CardElement { } /// Set Text wrap + #[must_use] pub fn set_wrap(&mut self, w: bool) -> Self { if let Self::TextBlock { wrap, .. } = self { *wrap = Some(w); @@ -677,6 +597,7 @@ impl CardElement { } /// Set Text subtle + #[must_use] pub fn set_subtle(&mut self, s: bool) -> Self { if let Self::TextBlock { is_subtle, .. } = self { *is_subtle = Some(s); @@ -715,6 +636,7 @@ impl CardElement { } /// Add fact to factSet + #[must_use] pub fn add_key_value, S: Into>(&mut self, title: T, value: S) -> Self { match self { Self::FactSet { facts, .. } => facts.push(Fact { @@ -745,6 +667,7 @@ impl CardElement { } /// Add column to columnSet + #[must_use] pub fn add_column(&mut self, column: Column) -> Self { if let Self::ColumnSet { columns, .. } = self { columns.push(column); @@ -753,6 +676,7 @@ impl CardElement { } /// Set Separator + #[must_use] pub fn set_separator(&mut self, s: bool) -> Self { match self { Self::TextBlock { separator, .. } @@ -760,6 +684,7 @@ impl CardElement { | Self::ColumnSet { separator, .. } | Self::Image { separator, .. } | Self::InputChoiceSet { separator, .. } + | Self::ActionSet { separator, .. } | Self::InputText { separator, .. } | Self::InputToggle { separator, .. } => { *separator = Some(s); @@ -772,6 +697,7 @@ impl CardElement { } /// Set Placeholder + #[must_use] pub fn set_placeholder(&mut self, s: Option) -> Self { match self { Self::InputText { placeholder, .. } @@ -786,7 +712,33 @@ impl CardElement { self.into() } + /// Set Horizontal Alignment + #[must_use] + pub fn set_horizontal_alignment(&mut self, alignment: HorizontalAlignment) -> Self { + match self { + Self::TextBlock { + horizontal_alignment, + .. + } + | Self::Image { + horizontal_alignment, + .. + } + | Self::ActionSet { + horizontal_alignment, + .. + } => { + *horizontal_alignment = Some(alignment); + } + _ => { + log::warn!("Card does not have horizontal alignment field"); + } + } + self.into() + } + /// Set Spacing + #[must_use] pub fn set_spacing(&mut self, s: Spacing) -> Self { match self { Self::TextBlock { spacing, .. } @@ -794,6 +746,7 @@ impl CardElement { | Self::ColumnSet { spacing, .. } | Self::Image { spacing, .. } | Self::InputChoiceSet { spacing, .. } + | Self::ActionSet { spacing, .. } | Self::InputText { spacing, .. } => { *spacing = Some(s); } @@ -810,10 +763,14 @@ impl CardElement { Self::ActionSet { actions: vec![], height: None, + horizontal_alignment: None, + separator: None, + spacing: None, } } /// Add action to actionSet + #[must_use] pub fn add_action_to_set(&mut self, action: Action) -> Self { if let Self::ActionSet { actions, .. } = self { actions.push(action); @@ -821,316 +778,3 @@ impl CardElement { self.into() } } - -/// Defines a container that is part of a `ColumnSet`. -#[derive(Clone, Debug, Default, Deserialize, PartialEq, Serialize)] -pub struct Column { - /// The card elements to render inside the Column. - #[serde(default)] - items: Vec, - /// An Action that will be invoked when the Column is tapped or selected. - #[serde(rename = "selectAction", skip_serializing_if = "Option::is_none")] - select_action: Option, - /// Style hint for Column. - #[serde(skip_serializing_if = "Option::is_none")] - style: Option, - /// Defines how the content should be aligned vertically within the column. - #[serde( - rename = "verticalContentAlignment", - skip_serializing_if = "Option::is_none" - )] - vertical_content_alignment: Option, - /// When true, draw a separating line between this column and the previous column. - #[serde(skip_serializing_if = "Option::is_none")] - separator: Option, - /// Controls the amount of spacing between this column and the preceding column. - #[serde(skip_serializing_if = "Option::is_none")] - spacing: Option, - /// "auto", "stretch", a number representing relative width of the column in the column group, or in version 1.1 and higher, a specific pixel width, like "50px". - #[serde(skip_serializing_if = "Option::is_none")] - width: Option, - /// A unique identifier associated with the item. - #[serde(skip_serializing_if = "Option::is_none")] - id: Option, -} - -impl From<&Self> for Column { - fn from(item: &Self) -> Self { - item.clone() - } -} - -impl From<&mut Self> for Column { - fn from(item: &mut Self) -> Self { - item.clone() - } -} - -impl Column { - /// Creates new Column - #[must_use] - pub const fn new() -> Self { - Self { - items: vec![], - select_action: None, - style: None, - vertical_content_alignment: None, - separator: None, - spacing: None, - width: None, - id: None, - } - } - - /// Adds element to column - pub fn add_element(&mut self, item: CardElement) -> Self { - self.items.push(item); - self.into() - } - - /// Sets separator - pub fn set_separator(&mut self, s: bool) -> Self { - self.separator = Some(s); - self.into() - } - - /// Sets `VerticalContentAlignment` - pub fn set_vertical_alignment(&mut self, s: VerticalContentAlignment) -> Self { - self.vertical_content_alignment = Some(s); - self.into() - } - - /// Sets width - pub fn set_width>(&mut self, s: T) -> Self { - self.width = Some(serde_json::Value::String(s.into())); - self.into() - } -} - -/// Describes a Fact in a `FactSet` as a key/value pair. -#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)] -pub struct Fact { - /// The title of the fact. - title: String, - /// The value of the fact. - value: String, -} - -/// Available color options -#[allow(missing_docs)] -#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)] -pub enum Color { - Default, - Dark, - Light, - Accent, - Good, - Warning, - Attention, -} - -/// Container Styles -#[allow(missing_docs)] -#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)] -pub enum ContainerStyle { - Default, - Emphasis, - Good, - Attention, - Warning, - Accent, -} - -/// Spacing options -#[allow(missing_docs)] -#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)] -pub enum Spacing { - #[serde(alias = "default")] - Default, - #[serde(alias = "none")] - None, - #[serde(alias = "small")] - Small, - #[serde(alias = "medium")] - Medium, - #[serde(alias = "large")] - Large, - #[serde(alias = "extraLarge")] - ExtraLarge, - #[serde(alias = "padding")] - Padding, -} - -/// Choice Input Style -#[allow(missing_docs)] -#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)] -pub enum ChoiceInputStyle { - Compact, - Expanded, -} - -/// Vertical alignment of content -#[allow(missing_docs)] -#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)] -#[serde(rename_all = "camelCase")] -pub enum VerticalContentAlignment { - Top, - Center, - Bottom, -} - -/// Text Input Style -#[allow(missing_docs)] -#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)] -pub enum TextInputStyle { - Text, - Tel, - Url, - Email, -} - -/// Height -#[allow(missing_docs)] -#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)] -#[serde(rename_all = "camelCase")] -pub enum Height { - Auto, - Stretch, -} - -/// Image Style -#[allow(missing_docs)] -#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)] -pub enum ImageStyle { - Default, - Person, -} - -/// Text Weight -#[allow(missing_docs)] -#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)] -pub enum Weight { - Default, - Lighter, - Bolder, -} - -/// Type of font to use for rendering -#[allow(missing_docs)] -#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)] -pub enum FontType { - Default, - Monospace, -} - -/// Text Size -#[allow(missing_docs)] -#[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, -} - -/// Image Size -#[allow(missing_docs)] -#[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, -} - -/// Controls how this element is horizontally positioned within its parent. -#[allow(missing_docs)] -#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)] -pub enum HorizontalAlignment { - Left, - Center, - Right, -} - -/// Available Card Actions -#[derive(Clone, Debug, Deserialize, PartialEq, Serialize)] -#[serde(tag = "type")] -pub enum Action { - /// Gathers input fields, merges with optional data field, and sends an event to the client. It is up to the client to determine how this data is processed. For example: With `BotFramework` bots, the client would send an activity through the messaging medium to the bot. - #[serde(rename = "Action.Submit")] - Submit { - /// Initial data that input fields will be combined with. These are essentially ‘hidden’ properties. - #[serde(skip_serializing_if = "Option::is_none")] - data: Option>, - /// Label for button or link that represents this action. - #[serde(skip_serializing_if = "Option::is_none")] - title: Option, - /// Controls the style of an Action, which influences how the action is displayed, spoken, etc. - #[serde(skip_serializing_if = "Option::is_none")] - style: Option, - }, - /// When invoked, show the given url either by launching it in an external web browser or showing within an embedded web browser. - #[serde(rename = "Action.OpenUrl")] - OpenUrl { - /// The URL to open. - url: String, - /// Label for button or link that represents this action. - #[serde(skip_serializing_if = "Option::is_none")] - title: Option, - /// Controls the style of an Action, which influences how the action is displayed, spoken, etc. - #[serde(skip_serializing_if = "Option::is_none")] - style: Option, - }, - /// Defines an `AdaptiveCard` which is shown to the user when the button or link is clicked. - #[serde(rename = "Action.ShowCard")] - ShowCard { - /// The Adaptive Card to show. - card: AdaptiveCard, - /// Label for button or link that represents this action. - #[serde(skip_serializing_if = "Option::is_none")] - title: Option, - /// Controls the style of an Action, which influences how the action is displayed, spoken, etc. - #[serde(skip_serializing_if = "Option::is_none")] - style: Option, - }, -} - -/// Controls the style of an Action, which influences how the action is displayed, spoken, etc. -#[allow(missing_docs)] -#[derive(Clone, Debug, Default, Deserialize, Eq, PartialEq, Serialize)] -pub enum ActionStyle { - /// Action is displayed as normal - #[default] - Default, - /// Action is displayed with a positive style (typically the button becomes accent color) - Positive, - /// Action is displayed with a destructive style (typically the button becomes red) - Destructive, -} - -/// Describes a choice for use in a `ChoiceSet`. -#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)] -pub struct Choice { - /// Text to display. - pub title: String, - /// The raw value for the choice. **NOTE:** do not use a , in the value, since a `ChoiceSet` with isMultiSelect set to true returns a comma-delimited string of choice values. - pub value: String, -} - -fn default_version() -> String { - "1.1".to_string() -} diff --git a/src/adaptive_card/mod.rs b/src/adaptive_card/mod.rs new file mode 100644 index 0000000..b298dd2 --- /dev/null +++ b/src/adaptive_card/mod.rs @@ -0,0 +1,355 @@ +//! Support for [Adaptive Cards](https://adaptivecards.io/) in Webex messages. +//! +//! Adaptive Cards are a way to create rich, interactive content that can be sent in messages. +//! They consist of various elements like text blocks, images, input fields, and actions. +//! +//! # Example +//! ```rust,no_run +//! use webex::adaptive_card::{AdaptiveCard, CardElement}; +//! +//! let mut card = AdaptiveCard::new(); +//! card.add_body(CardElement::text_block("Hello, World!")); +//! ``` +//! +//! More info about the schema can be found [here](https://adaptivecards.io/explorer/) + +use serde::{Deserialize, Serialize}; +use std::collections::HashMap; + +// Submodules +pub mod containers; +pub mod elements; +pub mod styles; + +// Re-export main types +pub use containers::{Choice, Column, Fact}; +pub use elements::CardElement; +pub use styles::*; + +/// An Adaptive Card is the top-level object that describes a card. +#[derive(Clone, Debug, Deserialize, PartialEq, Serialize)] +pub struct AdaptiveCard { + /// Must be "`AdaptiveCard`" + #[serde(rename = "type")] + pub card_type: String, + /// Schema version that this card requires. If a client is lower than this version, the fallbackText will be rendered. + /// Maximum version is 1.1 + #[serde(default = "default_version")] // Workaround for Webex not always providing it :/ + pub version: String, + /// The card elements to show in the primary card region. + #[serde(skip_serializing_if = "Option::is_none")] + pub body: Option>, + /// Actions available for this card + #[serde(skip_serializing_if = "Option::is_none")] + pub actions: Option>, + /// An Action that will be invoked when the card is tapped or selected. + #[serde(rename = "selectAction", skip_serializing_if = "Option::is_none")] + pub select_action: Option>, + /// Text shown when the client doesn't support the version specified (may contain markdown). + #[serde(rename = "fallbackText", skip_serializing_if = "Option::is_none")] + pub fallback_text: Option, + /// Specifies the minimum height of the card. + #[serde(rename = "minHeight", skip_serializing_if = "Option::is_none")] + pub min_height: Option, + /// The 2-letter ISO-639-1 language used in the card. Used to localize any date/time functions. + #[serde(skip_serializing_if = "Option::is_none")] + pub lang: Option, + /// The Adaptive Card schema. + /// + #[serde(rename = "$schema")] + #[serde(skip_serializing_if = "Option::is_none")] + pub schema: Option, +} + +impl AdaptiveCard { + /// Create new adaptive card with mandatory defaults + #[must_use] + pub fn new() -> Self { + Self { + card_type: "AdaptiveCard".to_string(), + version: "1.1".to_string(), + body: None, + actions: None, + select_action: None, + fallback_text: None, + min_height: None, + lang: None, + schema: Some("http://adaptivecards.io/schemas/adaptive-card.json".to_string()), + } + } + + /// Adds Element to body + /// + /// # Arguments + /// + /// * `card` - `CardElement` to add + #[must_use] + pub fn add_body>(&mut self, card: T) -> Self { + match self.body.take() { + None => { + self.body = Some(vec![card.into()]); + } + Some(mut body) => { + body.push(card.into()); + self.body = Some(body); + } + } + self.into() + } + + /// Adds Actions + /// + /// # Arguments + /// + /// * `action` - Action to add + #[must_use] + pub fn add_action>(&mut self, a: T) -> Self { + match self.actions.take() { + None => { + self.actions = Some(vec![a.into()]); + } + Some(mut actions) => { + actions.push(a.into()); + self.actions = Some(actions); + } + } + self.into() + } +} + +impl Default for AdaptiveCard { + fn default() -> Self { + Self::new() + } +} + +impl From<&Self> for AdaptiveCard { + fn from(item: &Self) -> Self { + item.clone() + } +} + +impl From<&mut Self> for AdaptiveCard { + fn from(item: &mut Self) -> Self { + item.clone() + } +} + +/// Actions that can be triggered by user interaction with an Adaptive Card. +#[derive(Clone, Debug, Deserialize, PartialEq, Serialize)] +#[serde(tag = "type")] +pub enum Action { + /// Gathers input fields, merges with optional data field, and sends an event to the client. It is up to the client to determine how this data is processed. For example: With `BotFramework` bots, the client would send an activity through the messaging medium to the bot. + #[serde(rename = "Action.Submit")] + Submit { + /// Initial data that input fields will be combined with. These are essentially 'hidden' properties. + #[serde(skip_serializing_if = "Option::is_none")] + data: Option>, + /// Label for button or link that represents this action. + #[serde(skip_serializing_if = "Option::is_none")] + title: Option, + /// Controls the style of an Action, which influences how the action is displayed, spoken, etc. + #[serde(skip_serializing_if = "Option::is_none")] + style: Option, + }, + /// When invoked, show the given url either by launching it in an external web browser or showing within an embedded web browser. + #[serde(rename = "Action.OpenUrl")] + OpenUrl { + /// The URL to open. + url: String, + /// Label for button or link that represents this action. + #[serde(skip_serializing_if = "Option::is_none")] + title: Option, + /// Controls the style of an Action, which influences how the action is displayed, spoken, etc. + #[serde(skip_serializing_if = "Option::is_none")] + style: Option, + }, + /// Defines an `AdaptiveCard` which is shown to the user when the button or link is clicked. + #[serde(rename = "Action.ShowCard")] + ShowCard { + /// The Adaptive Card to show. + card: AdaptiveCard, + /// Label for button or link that represents this action. + #[serde(skip_serializing_if = "Option::is_none")] + title: Option, + /// Controls the style of an Action, which influences how the action is displayed, spoken, etc. + #[serde(skip_serializing_if = "Option::is_none")] + style: Option, + }, +} + +/// Controls the style of an Action, which influences how the action is displayed, spoken, etc. +#[allow(missing_docs)] +#[derive(Clone, Debug, Default, Deserialize, Eq, PartialEq, Serialize)] +pub enum ActionStyle { + /// Action is displayed as normal + #[default] + Default, + /// Action is displayed with a positive style (typically the button becomes accent color) + Positive, + /// Action is displayed with a destructive style (typically the button becomes red) + Destructive, +} + +fn default_version() -> String { + "1.1".to_string() +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_adaptive_card_new() { + let card = AdaptiveCard::new(); + assert_eq!( + card.schema, + Some("http://adaptivecards.io/schemas/adaptive-card.json".to_string()) + ); + assert_eq!(card.version, "1.1"); + assert_eq!(card.card_type, "AdaptiveCard"); + assert!(card.body.is_none()); + assert!(card.actions.is_none()); + } + + #[test] + fn test_adaptive_card_add_body() { + let mut card = AdaptiveCard::new(); + let text_block = CardElement::text_block("Hello World"); + let _ = card.add_body(text_block); + + assert!(card.body.is_some()); + assert_eq!(card.body.as_ref().unwrap().len(), 1); + } + + #[test] + fn test_adaptive_card_add_multiple_body_elements() { + let mut card = AdaptiveCard::new(); + let _ = card.add_body(CardElement::text_block("First")); + let _ = card.add_body(CardElement::text_block("Second")); + let _ = card.add_body(CardElement::text_block("Third")); + + assert_eq!(card.body.as_ref().unwrap().len(), 3); + } + + #[test] + fn test_adaptive_card_add_action() { + let mut card = AdaptiveCard::new(); + let action = Action::ShowCard { + title: Some("Show More".to_string()), + card: AdaptiveCard::new(), + style: None, + }; + let _ = card.add_action(action); + + assert!(card.actions.is_some()); + assert_eq!(card.actions.as_ref().unwrap().len(), 1); + } + + #[test] + fn test_adaptive_card_add_multiple_actions() { + let mut card = AdaptiveCard::new(); + let _ = card.add_action(Action::ShowCard { + title: Some("First".to_string()), + card: AdaptiveCard::new(), + style: None, + }); + let _ = card.add_action(Action::ShowCard { + title: Some("Second".to_string()), + card: AdaptiveCard::new(), + style: None, + }); + + assert_eq!(card.actions.as_ref().unwrap().len(), 2); + } + + #[test] + fn test_card_element_text_block() { + let element = CardElement::text_block("Test text"); + match element { + CardElement::TextBlock { text, .. } => { + assert_eq!(text, "Test text"); + } + _ => panic!("Expected TextBlock"), + } + } + + #[test] + fn test_card_element_set_separator() { + let mut element = CardElement::text_block("Test"); + let _ = element.set_separator(true); + + match element { + CardElement::TextBlock { separator, .. } => { + assert_eq!(separator, Some(true)); + } + _ => panic!("Expected TextBlock"), + } + } + + #[test] + fn test_card_element_set_spacing() { + let mut element = CardElement::text_block("Test"); + let _ = element.set_spacing(Spacing::Large); + + match element { + CardElement::TextBlock { spacing, .. } => { + assert_eq!(spacing, Some(Spacing::Large)); + } + _ => panic!("Expected TextBlock"), + } + } + + #[test] + fn test_card_element_action_set() { + let action_set = CardElement::action_set(); + match action_set { + CardElement::ActionSet { + actions, + horizontal_alignment, + separator, + spacing, + .. + } => { + assert_eq!(actions.len(), 0); + assert_eq!(horizontal_alignment, None); + assert_eq!(separator, None); + assert_eq!(spacing, None); + } + _ => panic!("Expected ActionSet"), + } + } + + #[test] + fn test_card_element_set_horizontal_alignment() { + let mut element = CardElement::text_block("Test"); + let _ = element.set_horizontal_alignment(HorizontalAlignment::Center); + + match element { + CardElement::TextBlock { + horizontal_alignment, + .. + } => { + assert_eq!(horizontal_alignment, Some(HorizontalAlignment::Center)); + } + _ => panic!("Expected TextBlock"), + } + } + + #[test] + fn test_card_element_container() { + let container = CardElement::container(); + match container { + CardElement::Container { items, .. } => { + assert_eq!(items.len(), 0); + } + _ => panic!("Expected Container"), + } + } + + #[test] + fn test_column_new() { + let column = Column::new(); + assert_eq!(column.items.len(), 0); + } +} diff --git a/src/adaptive_card/styles.rs b/src/adaptive_card/styles.rs new file mode 100644 index 0000000..f853036 --- /dev/null +++ b/src/adaptive_card/styles.rs @@ -0,0 +1,145 @@ +//! Style types for Adaptive Cards including colors, spacing, weights, and alignment options. + +use serde::{Deserialize, Serialize}; + +/// Color for text +#[allow(missing_docs)] +#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)] +#[serde(rename_all = "PascalCase")] +pub enum Color { + Default, + Dark, + Light, + Accent, + Good, + Warning, + Attention, +} + +/// Style hint for Container. +#[allow(missing_docs)] +#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)] +#[serde(rename_all = "camelCase")] +pub enum ContainerStyle { + Default, + Emphasis, + Good, + Attention, + Warning, + Accent, +} + +/// Controls the amount of spacing between this element and the preceding element. +#[allow(missing_docs)] +#[derive(Clone, Copy, Debug, Deserialize, Eq, PartialEq, Serialize)] +#[serde(rename_all = "PascalCase")] +pub enum Spacing { + Default, + None, + Small, + Medium, + Large, + ExtraLarge, + Padding, +} + +/// Style for Input.ChoiceSet +#[allow(missing_docs)] +#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)] +#[serde(rename_all = "camelCase")] +pub enum ChoiceInputStyle { + Compact, + Expanded, +} + +/// Vertical content alignment +#[allow(missing_docs)] +#[derive(Clone, Copy, Debug, Deserialize, Eq, PartialEq, Serialize)] +#[serde(rename_all = "PascalCase")] +pub enum VerticalContentAlignment { + Top, + Center, + Bottom, +} + +/// Text input style +#[allow(missing_docs)] +#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)] +#[serde(rename_all = "camelCase")] +pub enum TextInputStyle { + Text, + Tel, + Url, + Email, +} + +/// Height of element +#[allow(missing_docs)] +#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)] +#[serde(rename_all = "lowercase")] +pub enum Height { + Auto, + Stretch, +} + +/// Style hint for Image. +#[allow(missing_docs)] +#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)] +#[serde(rename_all = "camelCase")] +pub enum ImageStyle { + Default, + Person, +} + +/// Weight of text +#[allow(missing_docs)] +#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)] +#[serde(rename_all = "PascalCase")] +pub enum Weight { + Default, + Lighter, + Bolder, +} + +/// Font type +#[allow(missing_docs)] +#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)] +#[serde(rename_all = "PascalCase")] +pub enum FontType { + Default, + Monospace, +} + +/// Size of text +#[allow(missing_docs)] +#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)] +#[serde(rename_all = "PascalCase")] +pub enum Size { + Default, + Small, + Medium, + Large, + ExtraLarge, +} + +/// Horizontal alignment +#[allow(missing_docs)] +#[derive(Clone, Copy, Debug, Deserialize, Eq, PartialEq, Serialize)] +#[serde(rename_all = "PascalCase")] +pub enum HorizontalAlignment { + Left, + Center, + Right, +} + +/// Size of image (pixel width) +#[allow(missing_docs)] +#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)] +#[serde(rename_all = "PascalCase")] +pub enum ImageSize { + Auto, + Stretch, + Small, + Medium, + Large, +} diff --git a/src/auth.rs b/src/auth.rs index 58ef277..3d8e69c 100644 --- a/src/auth.rs +++ b/src/auth.rs @@ -1,7 +1,8 @@ #![deny(missing_docs)] //! Ways to authenticate with the Webex API -use crate::{AuthorizationType, RestClient}; +use crate::client::{AuthorizationType, RestClient}; +use crate::error::Error; use reqwest::StatusCode; use serde::Deserialize; use tokio::time::{self, Duration, Instant}; @@ -64,11 +65,11 @@ impl DeviceAuthenticator { /// First step of device authentication. Returns a [`VerificationToken`] /// containing the codes and URLs that can be entered and navigated to /// on a different device. - pub async fn verify(&self) -> Result { + pub async fn verify(&self) -> Result { let params = &[("client_id", self.client_id.as_str()), ("scope", SCOPE)]; let verification_token = self .client - .api_post_form_urlencoded::( + .api_post_form_urlencoded::( "device/authorize", params, None::<()>, @@ -84,7 +85,7 @@ impl DeviceAuthenticator { pub async fn wait_for_authentication( &self, verification_token: &VerificationToken, - ) -> Result { + ) -> Result { let params = [ ("grant_type", GRANT_TYPE), ("device_code", &verification_token.device_code), @@ -101,7 +102,7 @@ impl DeviceAuthenticator { match self .client - .api_post_form_urlencoded::( + .api_post_form_urlencoded::( "device/token", params, None::<()>, @@ -114,13 +115,13 @@ impl DeviceAuthenticator { { Ok(token) => return Ok(token.access_token), Err(e) => match e { - crate::error::Error::StatusText(http_status, _) => { + Error::StatusText(http_status, _) => { if http_status != StatusCode::PRECONDITION_REQUIRED { - return Err(crate::Error::Authentication); + return Err(Error::Authentication); } } _ => { - return Err(crate::Error::Authentication); + return Err(Error::Authentication); } }, } diff --git a/src/client/mod.rs b/src/client/mod.rs new file mode 100644 index 0000000..eccdd8e --- /dev/null +++ b/src/client/mod.rs @@ -0,0 +1,1091 @@ +//! Main Webex client implementation for interacting with the Webex Teams API. + +use crate::adaptive_card::AdaptiveCard; +use crate::error::Error; +use crate::types::{ + Attachment, AttachmentAction, CatalogReply, DeviceData, DevicesReply, Gettable, GlobalId, + GlobalIdType, ListResult, Membership, MembershipListParams, Message, MessageEditParams, + MessageOut, Organization, Person, Room, RoomType, Team, +}; +use futures::{future::try_join_all, try_join}; +use log::{debug, error, trace, warn}; +use reqwest::StatusCode; +use serde::de::DeserializeOwned; +use std::{ + collections::{hash_map::DefaultHasher, HashMap}, + hash::{self, Hasher}, + sync::{Arc, Mutex}, + time::Duration, +}; +use tokio_tungstenite::connect_async; + +mod rest; +mod websocket; + +pub use rest::{AuthorizationType, RestClient}; +pub use websocket::{WStream, WebexEventStream}; + +// Re-export constants from parent +use super::{ + CRATE_VERSION, DEFAULT_DEVICE_NAME, DEFAULT_REGISTRATION_HOST_PREFIX, DEVICE_SYSTEM_NAME, + REST_HOST_PREFIX, U2C_HOST_PREFIX, +}; + +/// Main client for interacting with the Webex Teams API. +/// +/// This client handles authentication, REST API requests, and WebSocket event streams. +/// Create a new client using [`Webex::new`] with an API token. +/// +/// # Example +/// ```no_run +/// # async fn example() -> Result<(), Box> { +/// let webex = webex::Webex::new("YOUR_API_TOKEN").await; +/// let rooms = webex.list::().await?; +/// # Ok(()) +/// # } +/// ``` +#[derive(Clone)] +pub struct Webex { + id: u64, + client: RestClient, + 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 +impl Webex { + /// Constructs a new Webex Teams context from a token + /// Tokens can be obtained when creating a bot, see for + /// more information and to create your own Webex bots. + pub async fn new(token: &str) -> Self { + Self::new_with_device_name(DEFAULT_DEVICE_NAME, token).await + } + + /// Constructs a new Webex Teams context from a token and a chosen name + /// The name is used to identify the device/client with Webex api + pub async fn new_with_device_name(device_name: &str, token: &str) -> Self { + let mut client: RestClient = RestClient { + host_prefix: HashMap::new(), + web_client: reqwest::Client::new(), + }; + + let mut hasher = DefaultHasher::new(); + hash::Hash::hash_slice(token.as_bytes(), &mut hasher); + let id = hasher.finish(); + + // Have to insert this before calling get_mercury_url() since it uses U2C for the catalog + // request. + client + .host_prefix + .insert("limited/catalog".to_string(), U2C_HOST_PREFIX.to_string()); + + let mut webex = Self { + id, + client, + token: token.to_string(), + device: DeviceData { + device_name: Some(DEFAULT_DEVICE_NAME.to_string()), + device_type: Some("DESKTOP".to_string()), + localized_model: Some("rust".to_string()), + model: Some(format!("rust-v{CRATE_VERSION}")), + name: Some(device_name.to_owned()), + system_name: Some(DEVICE_SYSTEM_NAME.to_string()), + 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}"); + url + } + Err(e) => { + debug!("Failed to fetch devices url, falling back to default"); + debug!("Error: {e:?}"); + DEFAULT_REGISTRATION_HOST_PREFIX.to_string() + } + }; + webex + .client + .host_prefix + .insert("devices".to_string(), devices_url); + + webex + } + + /// Get an event stream handle + pub async fn event_stream(&self) -> Result { + // Helper function to connect to a device + // refactored out to make it easier to loop through all devices and also lazily create a + // new one if needed + async fn connect_device(s: &Webex, device: DeviceData) -> Result { + trace!("Attempting connection with device named {:?}", device.name); + let Some(ws_url) = device.ws_url else { + return Err("Device has no ws_url".into()); + }; + let url = url::Url::parse(ws_url.as_str()) + .map_err(|_| Error::from("Failed to parse ws_url"))?; + debug!("Connecting to {url:?}"); + match connect_async(url.as_str()).await { + Ok((mut ws_stream, _response)) => { + debug!("Connected to {url}"); + WebexEventStream::auth(&mut ws_stream, &s.token).await?; + debug!("Authenticated"); + let timeout = Duration::from_secs(20); + Ok(WebexEventStream::new(ws_stream, timeout)) + } + Err(e) => { + warn!("Failed to connect to {url:?}: {e:?}"); + Err(Error::Tungstenite( + Box::new(e), + "Failed to connect to ws_url".to_string(), + )) + } + } + } + + // get_devices automatically tries to set up devices if the get fails. + // Keep only devices named DEVICE_NAME to avoid conflicts with other clients + let mut devices: Vec = self + .get_devices() + .await? + .iter() + .filter(|d| d.name == self.device.name) + .inspect(|d| trace!("Kept device: {d}")) + .cloned() + .collect(); + + // Sort devices in descending order by modification time, meaning latest created device + // first. Use current time as fallback for devices without modification_time. + let now = chrono::Utc::now(); + devices.sort_by(|a: &DeviceData, b: &DeviceData| { + b.modification_time + .unwrap_or(now) + .cmp(&a.modification_time.unwrap_or(now)) + }); + + for device in devices { + if let Ok(event_stream) = connect_device(self, device).await { + trace!("Successfully connected to device."); + return Ok(event_stream); + } + } + + // Failed to connect to any existing devices, creating new one + 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 OAuth scopes: \ + spark:devices_write, spark:devices_read" + ); + 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 + 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()) + { + trace!("Found mercury URL in cache!"); + return result.map_err(|()| None); + } + + let mercury_url = self.get_mercury_url_uncached().await; + + if let Ok(mut cache) = MERCURY_CACHE.lock() { + let result = mercury_url.as_ref().map_or(Err(()), |url| Ok(url.clone())); + trace!("Saving mercury url to cache: {}=>{:?}", self.id, &result); + cache.insert(self.id, result); + } + + mercury_url.map_err(Some) + } + + async fn get_mercury_url_uncached(&self) -> Result { + // Steps: + // 1. Get org id by GET /v1/organizations + // 2. Get urls json from https://u2c.wbx2.com/u2c/api/v1/limited/catalog?orgId=[org id] + // 3. mercury url is urls["serviceLinks"]["wdm"] + // + // 4. Add caching because this doesn't change, and it can be slow + + 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()); + } + let org_id = &orgs[0].id; + let api_url = "limited/catalog"; + let params = [("format", "hostmap"), ("orgId", org_id.as_str())]; + let catalogs = self + .client + .api_get::( + api_url, + Some(params), + AuthorizationType::Bearer(&self.token), + ) + .await?; + let mercury_url = catalogs.service_links.wdm; + + Ok(mercury_url) + } + + /// Get list of organizations + #[deprecated( + since = "0.6.3", + note = "Please use `webex::list::()` instead" + )] + pub async fn get_orgs(&self) -> Result, Error> { + self.list().await + } + /// Get attachment action + /// Retrieves the attachment for the given ID. This can be used to + /// retrieve data from an `AdaptiveCard` submission + #[deprecated( + since = "0.6.3", + note = "Please use `webex::get::(id)` instead" + )] + pub async fn get_attachment_action(&self, id: &GlobalId) -> Result { + self.get(id).await + } + + /// Get a message by ID + #[deprecated( + since = "0.6.3", + note = "Please use `webex::get::(id)` instead" + )] + pub async fn get_message(&self, id: &GlobalId) -> Result { + self.get(id).await + } + + /// Delete a message by ID + #[deprecated( + since = "0.6.3", + note = "Please use `webex::delete::(id)` instead" + )] + pub async fn delete_message(&self, id: &GlobalId) -> Result<(), Error> { + self.delete::(id).await + } + + /// Get available rooms + #[deprecated(since = "0.6.3", note = "Please use `webex::list::()` instead")] + pub async fn get_rooms(&self) -> Result, Error> { + self.list().await + } + + /// Get all rooms from all organizations that the client belongs to. + /// Will be slow as does multiple API calls (one to get teamless rooms, one to get teams, then + /// one per team). + pub async fn get_all_rooms(&self) -> Result, Error> { + let (mut all_rooms, teams) = try_join!(self.list(), self.list::())?; + let futures: Vec<_> = teams + .into_iter() + .map(|team| { + let params = [("teamId", team.id)]; + self.client.api_get::>( + Room::API_ENDPOINT, + Some(params), + AuthorizationType::Bearer(&self.token), + ) + }) + .collect(); + let teams_rooms = try_join_all(futures).await?; + for room in teams_rooms { + all_rooms.extend(room.items.or(room.devices).unwrap_or_else(Vec::new)); + } + Ok(all_rooms) + } + + /// Get available room + #[deprecated(since = "0.6.3", note = "Please use `webex::get::(id)` instead")] + pub async fn get_room(&self, id: &GlobalId) -> Result { + self.get(id).await + } + + /// Get information about person + #[deprecated( + since = "0.6.3", + note = "Please use `webex::get::(id)` instead" + )] + pub async fn get_person(&self, id: &GlobalId) -> Result { + self.get(id).await + } + + /// Send a message to a user or room + /// + /// # Arguments + /// * `message`: [`MessageOut`] - the message to send, including one of `room_id`, + /// `to_person_id` or `to_person_email`. + /// + /// # Errors + /// Types of errors returned: + /// * [`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 your input object cannot be serialized, or the return + /// value cannot be deserialised. (If this happens, this is a library bug and should be + /// reported.) + /// * [`Error::UTF8`] - returned when the request returns non-UTF8 code. + pub async fn send_message(&self, message: &MessageOut) -> Result { + self.client + .api_post( + "messages", + message, + None::<()>, + AuthorizationType::Bearer(&self.token), + ) + .await + } + + /// Edit an existing message + /// + /// # Arguments + /// * `params`: [`MessageEditParams`] - the message to edit, including the message ID and the room ID, + /// as well as the new message text. + /// + /// # Errors + /// Types of errors returned: + /// * [`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 your input object cannot be serialized, or the return + /// value cannot be deserialised. (If this happens, this is a library bug and should be reported). + pub async fn edit_message( + &self, + message_id: &GlobalId, + params: &MessageEditParams<'_>, + ) -> Result { + let rest_method = format!("messages/{}", message_id.id()); + self.client + .api_put( + &rest_method, + params, + None::<()>, + AuthorizationType::Bearer(&self.token), + ) + .await + } + + /// Get a resource from an ID + /// # 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 your input object cannot be serialized, or the return + /// value cannot be deserialised. (If this happens, this is a library bug and should be + /// reported.) + /// * [`Error::UTF8`] - returned when the request returns non-UTF8 code. + pub async fn get(&self, id: &GlobalId) -> Result { + let rest_method = format!("{}/{}", T::API_ENDPOINT, id.id()); + self.client + .api_get::( + rest_method.as_str(), + None::<()>, + AuthorizationType::Bearer(&self.token), + ) + .await + } + + /// Delete a resource from an ID + pub async fn delete(&self, id: &GlobalId) -> Result<(), Error> { + let rest_method = format!("{}/{}", T::API_ENDPOINT, id.id()); + self.client + .api_delete( + rest_method.as_str(), + None::<()>, + AuthorizationType::Bearer(&self.token), + ) + .await + } + + /// List resources of a type + pub async fn list(&self) -> Result, Error> { + self.client + .api_get::>( + T::API_ENDPOINT, + None::<()>, + AuthorizationType::Bearer(&self.token), + ) + .await + .map(|result| result.items.or(result.devices).unwrap_or_default()) + } + + /// List resources of a type, with parameters + pub async fn list_with_params( + &self, + list_params: T::ListParams<'_>, + ) -> Result, Error> { + self.client + .api_get::>( + T::API_ENDPOINT, + Some(list_params), + AuthorizationType::Bearer(&self.token), + ) + .await + .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 = + GlobalId::new_with_cluster_unchecked(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: &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::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 = 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. \ + User may not be a member or membership data is stale." + ); + Error::UserError("User is not a member of this room".to_string()) + })?; + + debug!("Found membership with ID: {}", membership.id); + let membership_id = GlobalId::new(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> { + match self + .client + .api_get::( + "devices", + None::<()>, + AuthorizationType::Bearer(&self.token), + ) + .await + { + #[rustfmt::skip] + Ok(DevicesReply { devices: Some(devices), .. }) => Ok(devices), + Ok(DevicesReply { devices: None, .. }) => { + debug!("Chaining one-time device setup from devices query"); + self.setup_devices().await.map(|device| vec![device]) + } + Err(e) => self.handle_get_devices_error(e).await, + } + } + + /// Handle errors when getting devices, with automatic fallback to device creation. + /// + /// This method implements the following logic: + /// - 404 Not Found → Create a new device + /// - 403 Forbidden → Log detailed OAuth scope error, attempt device creation + /// - 429 Rate Limited → Pass through the error + /// - Other errors → Log and return error + async fn handle_get_devices_error(&self, e: Error) -> Result, Error> { + match e { + Error::Status(status) | Error::StatusText(status, _) => { + self.handle_device_status_error(status, e).await + } + Error::Limited(_, _) => Err(e), + _ => { + error!("Can't decode devices reply: {e}"); + Err(format!("Can't decode devices reply: {e}").into()) + } + } + } + + /// Handle HTTP status code errors when accessing device endpoints. + async fn handle_device_status_error( + &self, + status: StatusCode, + original_error: Error, + ) -> Result, Error> { + match status { + StatusCode::NOT_FOUND => { + debug!("No devices found (404), will create new device"); + self.setup_devices().await.map(|device| vec![device]) + } + StatusCode::FORBIDDEN => self.handle_device_forbidden_error(&original_error).await, + _ => { + error!("Unexpected HTTP status {status} when listing devices"); + Err(original_error) + } + } + } + + /// Handle 403 Forbidden errors on device endpoints with detailed OAuth scope guidance. + async fn handle_device_forbidden_error( + &self, + original_error: &Error, + ) -> Result, Error> { + // Extract error details if available + let details = match original_error { + Error::StatusText(_, msg) => Some(msg.as_str()), + _ => None, + }; + + // Log detailed error message with OAuth scope requirements + let scope_info = if let Some(msg) = details { + format!( + "Device endpoint returned 403 Forbidden: {msg}. \ + Token missing required OAuth scopes: spark:devices_write, spark:devices_read" + ) + } else { + "Device endpoint returned 403 Forbidden. \ + Token missing required OAuth scopes: spark:devices_write, spark:devices_read" + .to_string() + }; + error!("{scope_info}"); + + // Attempt device creation anyway (sometimes list fails but create succeeds) + 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 failed: {setup_err}. Cannot proceed without device access." + ); + Err(Error::Status(StatusCode::FORBIDDEN)) + } + } + } + + async fn setup_devices(&self) -> Result { + trace!("Setting up new device: {}", &self.device); + self.client + .api_post( + "devices", + &self.device, + None::<()>, + AuthorizationType::Bearer(&self.token), + ) + .await + } +} + +impl From<&AttachmentAction> for MessageOut { + fn from(action: &AttachmentAction) -> Self { + Self { + room_id: action.room_id.clone(), + ..Self::default() + } + } +} + +impl From<&Message> for MessageOut { + fn from(msg: &Message) -> Self { + let mut new_msg = Self::default(); + + if msg.room_type == Some(RoomType::Group) { + new_msg.room_id.clone_from(&msg.room_id); + } else if let Some(_person_id) = &msg.person_id { + new_msg.to_person_id.clone_from(&msg.person_id); + } else { + new_msg.to_person_email.clone_from(&msg.person_email); + } + + new_msg + } +} + +impl Message { + /// Reply to a message. + /// Posts the reply in the same chain as the replied-to message. + /// Contrast with [`MessageOut::from()`] which only replies in the same room. + #[must_use] + pub fn reply(&self) -> MessageOut { + MessageOut { + room_id: self.room_id.clone(), + parent_id: self + .parent_id + .as_deref() + .or(self.id.as_deref()) + .map(ToOwned::to_owned), + ..Default::default() + } + } +} + +impl MessageOut { + /// Generates a new outgoing message from an existing message + /// + /// # Arguments + /// + /// * `msg` - the template message + /// + /// Use `from_msg` to create a reply from a received message. + #[deprecated(since = "0.2.0", note = "Please use the from instead")] + #[must_use] + pub fn from_msg(msg: &Message) -> Self { + Self::from(msg) + } + + /// Add attachment to an existing message + /// + /// # Arguments + /// + /// * `card` - Adaptive Card to attach + pub fn add_attachment(&mut self, card: AdaptiveCard) -> &Self { + self.attachments = Some(vec![Attachment { + content_type: "application/vnd.microsoft.card.adaptive".to_string(), + content: card, + }]); + 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 = GlobalId::new( + 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 = GlobalId::new( + 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 = GlobalId::new( + 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 = GlobalId::new( + 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/client/rest.rs b/src/client/rest.rs new file mode 100644 index 0000000..13715d5 --- /dev/null +++ b/src/client/rest.rs @@ -0,0 +1,491 @@ +//! Low-level REST client for Webex API requests. + +use crate::error::Error; +use crate::types::{EmptyReply, Gettable, ListResult}; +use log::{error, trace}; +use reqwest::StatusCode; +use serde::{de::DeserializeOwned, Serialize}; +use serde_json; +use std::collections::HashMap; + +/// Authorization type for REST requests. +#[derive(Clone, Copy)] +pub enum AuthorizationType<'a> { + /// No authorization + None, + /// Bearer token authorization + Bearer(&'a str), + /// Basic authentication + Basic { + /// Username + username: &'a str, + /// Password + password: &'a str, + }, +} + +/// Body type for REST requests. +enum Body { + Json(T), + UrlEncoded(T), +} + +const BODY_NONE: Option> = None; + +/// Implements low level REST requests to be used internally by the library. +#[derive(Clone)] +pub struct RestClient { + /// Host prefix mapping for different API endpoints + pub host_prefix: HashMap, + /// Underlying HTTP client + pub web_client: reqwest::Client, +} + +impl RestClient { + /// Creates a new `RestClient`. + #[must_use] + pub fn new() -> Self { + Self { + host_prefix: HashMap::new(), + web_client: reqwest::Client::new(), + } + } + + /// Creates a `RestClient` with existing `host_prefix` and `web_client`. + #[must_use] + pub const fn new_with( + host_prefix: HashMap, + web_client: reqwest::Client, + ) -> Self { + Self { + host_prefix, + web_client, + } + } + + /// Performs a GET request and returns the resource as JSON. + /// + /// # Arguments + /// + /// * `token` - Authorization token + /// * `full_url` - Full URL to GET (e.g. `) + pub async fn get_full_url( + &self, + token: &str, + full_url: &str, + ) -> Result { + self.get_with_url(token, full_url).await + } + + /// Performs a GET request with custom headers and returns the resource as JSON. + /// + /// # Arguments + /// + /// * `token` - Authorization token + /// * `url` - Resource path (e.g. "rooms") + /// * `params` - Query parameters + /// * `host` - Optional custom host prefix + pub async fn get_with_params( + &self, + token: &str, + url: &str, + params: &P, + host: Option<&str>, + ) -> Result { + let host_prefix = self.get_host_prefix(host, "rest"); + let full_url = format!("{host_prefix}/{url}"); + self.request_with_query( + "GET", + AuthorizationType::Bearer(token), + &full_url, + Some(params), + BODY_NONE, + ) + .await + } + + /// Performs a GET request. + async fn get_with_url( + &self, + token: &str, + full_url: &str, + ) -> Result { + self.request("GET", AuthorizationType::Bearer(token), full_url, BODY_NONE) + .await + } + + /// Performs a POST request with JSON body. + pub async fn post( + &self, + token: &str, + url: &str, + data: &D, + host: Option<&str>, + ) -> Result { + let host_prefix = self.get_host_prefix(host, "rest"); + let full_url = format!("{host_prefix}/{url}"); + self.request( + "POST", + AuthorizationType::Bearer(token), + &full_url, + Some(Body::Json(data)), + ) + .await + } + + /// Performs a PUT request with JSON body. + pub async fn put( + &self, + token: &str, + url: &str, + data: &D, + host: Option<&str>, + ) -> Result { + let host_prefix = self.get_host_prefix(host, "rest"); + let full_url = format!("{host_prefix}/{url}"); + self.request( + "PUT", + AuthorizationType::Bearer(token), + &full_url, + Some(Body::Json(data)), + ) + .await + } + + /// Performs a DELETE request. + pub async fn delete( + &self, + token: &str, + id: &str, + host: Option<&str>, + ) -> Result<(), Error> { + let host_prefix = self.get_host_prefix(host, "rest"); + let full_url = format!("{host_prefix}/{}/{id}", T::API_ENDPOINT); + let _: EmptyReply = self + .request( + "DELETE", + AuthorizationType::Bearer(token), + &full_url, + BODY_NONE, + ) + .await?; + Ok(()) + } + + /// Performs a POST request with URL-encoded form body. + /// Used primarily for OAuth authentication flows. + pub async fn api_post_form_urlencoded( + &self, + rest_method: &str, + body: B, + _params: Option, + auth: AuthorizationType<'_>, + ) -> Result { + // Get the host prefix for the URL + let url_trimmed = rest_method.split('?').next().unwrap_or(rest_method); + let prefix = self + .host_prefix + .get(url_trimmed) + .map_or(super::REST_HOST_PREFIX, String::as_str); + let full_url = format!("{prefix}/{rest_method}"); + + // params are not currently used but kept for API compatibility + self.request("POST", auth, &full_url, Some(Body::UrlEncoded(body))) + .await + } + + /// Gets the host prefix for a given host key. + fn get_host_prefix(&self, host: Option<&str>, default_key: &str) -> String { + host.map_or_else( + || { + self.host_prefix + .get(default_key) + .map_or_else(|| super::REST_HOST_PREFIX.to_string(), Clone::clone) + }, + ToString::to_string, + ) + } + + /// Performs an HTTP request with query parameters and optional body. + async fn request_with_query( + &self, + method: &str, + auth: AuthorizationType<'_>, + url: &str, + query: Option<&Q>, + body: Option>, + ) -> Result { + trace!("{method} {url}"); + let mut req = self + .web_client + .request(method.parse().unwrap(), url) + .header("User-Agent", format!("webex-rust/{}", super::CRATE_VERSION)); + + // Add query parameters if provided + if let Some(params) = query { + req = req.query(params); + } + + // Apply authorization + req = match auth { + AuthorizationType::None => req, + AuthorizationType::Bearer(token) => req.bearer_auth(token), + AuthorizationType::Basic { username, password } => { + req.basic_auth(username, Some(password)) + } + }; + + req = match body { + Some(Body::Json(data)) => req.json(&data), + Some(Body::UrlEncoded(data)) => req.form(&data), + None => req, + }; + + let response = req.send().await?; + let status = response.status(); + let response_text = response.text().await?; + + if status.is_success() { + trace!("Response: {response_text}"); + + // Handle empty responses (like 204 No Content) + if response_text.is_empty() { + Ok(serde_json::from_str("{}")?) + } else { + Ok(serde_json::from_str(&response_text)?) + } + } else { + // Log errors with appropriate level based on context + Self::log_error(status, url, &response_text); + Err(Self::handle_error_response(status, response_text)) + } + } + + /// Performs an HTTP request with the given method, URL, and optional body. + async fn request( + &self, + method: &str, + auth: AuthorizationType<'_>, + url: &str, + body: Option>, + ) -> Result { + trace!("{method} {url}"); + let mut req = self + .web_client + .request(method.parse().unwrap(), url) + .header("User-Agent", format!("webex-rust/{}", super::CRATE_VERSION)); + + // Apply authorization + req = match auth { + AuthorizationType::None => req, + AuthorizationType::Bearer(token) => req.bearer_auth(token), + AuthorizationType::Basic { username, password } => { + req.basic_auth(username, Some(password)) + } + }; + + req = match body { + Some(Body::Json(data)) => req.json(&data), + Some(Body::UrlEncoded(data)) => req.form(&data), + None => req, + }; + + let response = req.send().await?; + let status = response.status(); + let response_text = response.text().await?; + + if status.is_success() { + trace!("Response: {response_text}"); + + // Handle empty responses (like 204 No Content) + if response_text.is_empty() { + Ok(serde_json::from_str("{}")?) + } else { + Ok(serde_json::from_str(&response_text)?) + } + } else { + // Log errors with appropriate level based on context + Self::log_error(status, url, &response_text); + Err(Self::handle_error_response(status, response_text)) + } + } + + /// Logs HTTP errors with appropriate log level based on context. + fn log_error(status: StatusCode, url: &str, response_text: &str) { + // Try to parse as JSON to get structured error message + if let Ok(json_error) = serde_json::from_str::(response_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 team access - log as debug + if status == StatusCode::NOT_FOUND + && url.contains("/teams") + && message.contains("Could not find teams") + { + trace!("HTTP {status} for {url}: {message} (expected when not a team member)"); + return; + } + } + } + + // Log all other errors at error level + error!("HTTP {status}: {response_text}"); + } + + /// Handles error responses from the API. + fn handle_error_response(status: StatusCode, response_text: String) -> Error { + if response_text.starts_with("") || response_text.starts_with("(&response_text) { + Ok(json) => { + if let Some(message) = json.get("message").and_then(|v| v.as_str()) { + Error::StatusText(status, message.to_string()) + } else { + Error::StatusText(status, response_text) + } + } + Err(_) => Error::StatusText(status, response_text), + } + } + } + + /// Generic GET request for any `Gettable` type. + pub async fn get( + &self, + token: &str, + id: &str, + host: Option<&str>, + ) -> Result { + let host_prefix = self.get_host_prefix(host, "rest"); + let full_url = format!("{host_prefix}/{}/{id}", T::API_ENDPOINT); + self.get_with_url(token, &full_url).await + } + + /// Generic LIST request for any `Gettable` type. + pub async fn list( + &self, + token: &str, + params: &T::ListParams<'_>, + host: Option<&str>, + ) -> Result, Error> { + let list_result: ListResult = self + .get_with_params(token, T::API_ENDPOINT, params, host) + .await?; + + // Handle both 'items' and 'devices' fields + Ok(list_result + .items + .or(list_result.devices) + .unwrap_or_default()) + } + + // Legacy API methods for compatibility with client/mod.rs + + /// Performs a GET request (legacy API name). + pub async fn api_get( + &self, + rest_method: &str, + params: Option, + auth: AuthorizationType<'_>, + ) -> Result { + let url_trimmed = rest_method.split('?').next().unwrap_or(rest_method); + let prefix = self + .host_prefix + .get(url_trimmed) + .map_or(super::REST_HOST_PREFIX, String::as_str); + let full_url = format!("{prefix}/{rest_method}"); + + if let Some(params) = params { + self.request_with_query("GET", auth, &full_url, Some(¶ms), BODY_NONE) + .await + } else { + self.request("GET", auth, &full_url, BODY_NONE).await + } + } + + /// Performs a POST request with JSON body (legacy API name). + pub async fn api_post( + &self, + rest_method: &str, + body: impl Serialize, + params: Option, + auth: AuthorizationType<'_>, + ) -> Result { + let url_trimmed = rest_method.split('?').next().unwrap_or(rest_method); + let prefix = self + .host_prefix + .get(url_trimmed) + .map_or(super::REST_HOST_PREFIX, String::as_str); + let full_url = format!("{prefix}/{rest_method}"); + + if let Some(params) = params { + // For params, we append them as query string + let _params = params; // params need to be serialized to query string but we'll keep simple for now + } + + self.request("POST", auth, &full_url, Some(Body::Json(body))) + .await + } + + /// Performs a PUT request with JSON body (legacy API name). + pub async fn api_put( + &self, + rest_method: &str, + body: impl Serialize, + params: Option, + auth: AuthorizationType<'_>, + ) -> Result { + let url_trimmed = rest_method.split('?').next().unwrap_or(rest_method); + let prefix = self + .host_prefix + .get(url_trimmed) + .map_or(super::REST_HOST_PREFIX, String::as_str); + let full_url = format!("{prefix}/{rest_method}"); + + if let Some(_params) = params { + // params are not currently used but kept for API compatibility + } + + self.request("PUT", auth, &full_url, Some(Body::Json(body))) + .await + } + + /// Performs a DELETE request (legacy API name). + pub async fn api_delete( + &self, + rest_method: &str, + params: Option, + auth: AuthorizationType<'_>, + ) -> Result<(), Error> { + let url_trimmed = rest_method.split('?').next().unwrap_or(rest_method); + let prefix = self + .host_prefix + .get(url_trimmed) + .map_or(super::REST_HOST_PREFIX, String::as_str); + let full_url = format!("{prefix}/{rest_method}"); + + if let Some(_params) = params { + // params are not currently used but kept for API compatibility + } + + let _: EmptyReply = self.request("DELETE", auth, &full_url, BODY_NONE).await?; + Ok(()) + } +} + +impl Default for RestClient { + fn default() -> Self { + Self::new() + } +} + +/// Extract title from HTML error page. +fn extract_html_title(html: &str, status: StatusCode) -> String { + if let (Some(start_pos), Some(end_pos)) = (html.find(""), html.find("")) { + let start = start_pos + 7; + if start < end_pos && end_pos <= html.len() { + return html[start..end_pos].to_string(); + } + } + format!("HTTP {} - HTML error page returned", status.as_u16()) +} diff --git a/src/client/websocket.rs b/src/client/websocket.rs new file mode 100644 index 0000000..48b2564 --- /dev/null +++ b/src/client/websocket.rs @@ -0,0 +1,146 @@ +//! WebSocket event stream handling for real-time Webex events. + +use crate::error::Error; +use crate::types::{Authorization, Event}; +use futures_util::{SinkExt, StreamExt}; +use log::{debug, trace, warn}; +use std::time::Duration; +use tokio_tungstenite::tungstenite::{Error as TErr, Message as TMessage}; + +/// WebSocket stream type. +pub type WStream = + tokio_tungstenite::WebSocketStream>; + +/// Webex event stream for receiving real-time events via WebSocket. +pub struct WebexEventStream { + pub(crate) ws_stream: WStream, + pub(crate) timeout: Duration, + /// Signifies if `WebStream` is Open + pub is_open: bool, +} + +impl WebexEventStream { + /// Creates a new `WebexEventStream` from a WebSocket stream. + pub(crate) const fn new(ws_stream: WStream, timeout: Duration) -> Self { + Self { + ws_stream, + timeout, + is_open: true, + } + } + + /// Get the next event from an event stream. + /// + /// Returns an event or an error. + /// + /// # Errors + /// Returns an error when the underlying stream has a problem, but will + /// continue to work on subsequent calls to `next()` - the errors can safely + /// be ignored. + pub async fn next(&mut self) -> Result { + loop { + let next = self.ws_stream.next(); + + match tokio::time::timeout(self.timeout, next).await { + // Timed out + Err(_) => { + // This does not seem to be recoverable, or at least there are conditions under + // which it does not recover. Indicate that the connection is closed and a new + // one will have to be opened. + self.is_open = false; + return Err(format!("no activity for at least {:?}", self.timeout).into()); + } + // Didn't time out + Ok(next_result) => match next_result { + None => {} + Some(msg) => match msg { + Ok(msg) => { + if let Some(h_msg) = self.handle_message(msg)? { + return Ok(h_msg); + } + // `None` messages still reset the timeout (e.g. Ping to keep alive) + } + Err(TErr::Protocol(_) | TErr::Io(_)) => { + // Protocol error probably requires a connection reset + // IO error is (apart from WouldBlock) generally an error with the + // underlying connection and also fatal + self.is_open = false; + return Err(msg.unwrap_err().to_string().into()); + } + Err(e) => { + return Err(Error::Tungstenite( + Box::new(e), + "Error getting next_result".into(), + )) + } + }, + }, + } + } + } + + fn handle_message(&mut self, msg: TMessage) -> Result, Error> { + match msg { + TMessage::Binary(bytes) => { + let json = std::str::from_utf8(&bytes)?; + match serde_json::from_str(json) { + Ok(ev) => Ok(Some(ev)), + Err(e) => { + warn!("Couldn't deserialize: {:?}. Original JSON:\n{}", e, &json); + Err(e.into()) + } + } + } + TMessage::Text(t) => { + debug!("text: {t}"); + Ok(None) + } + TMessage::Ping(_) => { + trace!("Ping!"); + Ok(None) + } + TMessage::Close(t) => { + debug!("close: {t:?}"); + self.is_open = false; + Err(Error::Closed("Web Socket Closed".to_string())) + } + TMessage::Pong(_) => { + debug!("Pong!"); + Ok(None) + } + TMessage::Frame(_) => { + debug!("Frame"); + Ok(None) + } + } + } + + /// Authenticate to the WebSocket stream. + pub(crate) async fn auth(ws_stream: &mut WStream, token: &str) -> Result<(), Error> { + let auth = Authorization::new(token); + debug!("Authenticating to stream"); + let auth_json = serde_json::to_string(&auth)?; + match ws_stream.send(TMessage::Text(auth_json.into())).await { + Ok(()) => { + // The next thing back should be a pong + match ws_stream.next().await { + Some(msg) => match msg { + Ok(msg) => match msg { + TMessage::Ping(_) | TMessage::Pong(_) => { + debug!("Authentication succeeded"); + Ok(()) + } + _ => Err(format!("Received {msg:?} in reply to auth message").into()), + }, + Err(e) => Err(format!("Received error from websocket: {e}").into()), + }, + None => Err("Websocket closed".to_string().into()), + } + } + Err(e) => Err(Error::Tungstenite( + Box::new(e), + "failed to send authentication".to_string(), + )), + } + } +} diff --git a/src/error.rs b/src/error.rs index b2ab08c..06bddf4 100644 --- a/src/error.rs +++ b/src/error.rs @@ -1,41 +1,99 @@ use reqwest::StatusCode; +/// Errors that can occur when using the Webex API client. #[derive(thiserror::Error, Debug)] pub enum Error { // Foreign errors + /// IO error from standard library operations. #[error("IO error: {0}")] Io(#[from] std::io::Error), + + /// JSON serialization/deserialization error. + /// + /// Occurs when parsing API responses or serializing request bodies. #[error("JSON error: {0}")] Json(#[from] serde_json::error::Error), + + /// URL form encoding error when serializing query parameters. #[error("URL form encoding error: {0}")] FormEncoding(#[from] serde_html_form::ser::Error), + + /// UTF-8 decoding error. #[error("UTF8 error: {0}")] UTF8(#[from] std::str::Utf8Error), + /// HTTP client error from reqwest. + /// + /// Wraps errors from the underlying HTTP client, including network errors, + /// connection failures, and timeout errors. #[error("reqwest error: {0}")] Reqwest(#[from] reqwest::Error), // WS/request errors + /// WebSocket connection was closed. + /// + /// The WebSocket connection to the Webex event stream was closed, + /// either by the server or due to a network error. #[error("Connection was closed: {0}")] Closed(String), + + /// HTTP error status code without detailed message. + /// + /// The API returned an HTTP error status code (4xx or 5xx). + /// Common codes: + /// - 401: Unauthorized (invalid or expired token) + /// - 403: Forbidden (missing OAuth scopes) + /// - 404: Not Found (resource doesn't exist) + /// - 429: Too Many Requests (rate limited) + /// - 500: Internal Server Error #[error("HTTP Status: '{0}'")] Status(StatusCode), + + /// HTTP error status code with detailed error message. + /// + /// Like [`Status`](Error::Status), but includes the error message from the API response. #[error("HTTP Status: '{0}' Message: {1}")] StatusText(StatusCode, String), + + /// Rate limiting error with optional retry delay. + /// + /// The API returned HTTP 429 (Too Many Requests). The second field contains + /// the number of seconds to wait before retrying, if provided by the API. #[error("{0} Retry in: '{1:?}'")] Limited(StatusCode, Option), + + /// WebSocket protocol error from tungstenite. + /// + /// Errors from the underlying WebSocket implementation, such as protocol + /// violations, handshake failures, or frame parsing errors. #[error("{0} {1}")] Tungstenite(Box, String), + + /// Webex API behavior changed unexpectedly. + /// + /// The API response format or behavior differs from what this library expects. + /// This usually indicates that Cisco changed the API in a backwards-incompatible way. #[error("Webex API changed: {0}")] Api(&'static str), + /// Authentication or authorization error. + /// + /// Generic authentication failure, typically when the token is invalid + /// or missing required permissions. #[error("Authentication error")] Authentication, + /// User-facing error message. + /// + /// Error created from application logic with a custom message intended + /// for end users. #[error("{0}")] UserError(String), // catch-all + /// Unknown or uncategorized error. + /// + /// Fallback error type for errors that don't fit other categories. #[error("Unknown error: {0}")] Other(String), } @@ -50,3 +108,77 @@ impl From<&str> for Error { Error::Other(s.to_string()) } } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_error_from_string() { + let error: Error = "test error".to_string().into(); + assert!(matches!(error, Error::Other(_))); + assert_eq!(error.to_string(), "Unknown error: test error"); + } + + #[test] + fn test_error_from_str() { + let error: Error = "test error".into(); + assert!(matches!(error, Error::Other(_))); + assert_eq!(error.to_string(), "Unknown error: test error"); + } + + #[test] + fn test_error_closed() { + let error = Error::Closed("connection lost".to_string()); + assert_eq!(error.to_string(), "Connection was closed: connection lost"); + } + + #[test] + fn test_error_status() { + let error = Error::Status(StatusCode::NOT_FOUND); + assert_eq!(error.to_string(), "HTTP Status: '404 Not Found'"); + } + + #[test] + fn test_error_status_text() { + let error = Error::StatusText(StatusCode::FORBIDDEN, "Missing scopes".to_string()); + assert_eq!( + error.to_string(), + "HTTP Status: '403 Forbidden' Message: Missing scopes" + ); + } + + #[test] + fn test_error_limited_with_retry() { + let error = Error::Limited(StatusCode::TOO_MANY_REQUESTS, Some(60)); + assert!(error.to_string().contains("429")); + assert!(error.to_string().contains("60")); + } + + #[test] + fn test_error_limited_without_retry() { + let error = Error::Limited(StatusCode::TOO_MANY_REQUESTS, None); + assert!(error.to_string().contains("429")); + } + + #[test] + fn test_error_api() { + let error = Error::Api("unexpected response format"); + assert_eq!( + error.to_string(), + "Webex API changed: unexpected response format" + ); + } + + #[test] + fn test_error_authentication() { + let error = Error::Authentication; + assert_eq!(error.to_string(), "Authentication error"); + } + + #[test] + fn test_error_user_error() { + let error = Error::UserError("Invalid input provided".to_string()); + assert_eq!(error.to_string(), "Invalid input provided"); + } +} diff --git a/src/lib.rs b/src/lib.rs index d01c3d2..12fcec0 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -33,1492 +33,21 @@ //! author is a current developer at Cisco, but has no direct affiliation //! with the Webex development team. -extern crate lazy_static; - pub mod adaptive_card; +pub mod auth; +pub mod client; #[allow(missing_docs)] pub mod error; pub mod types; -pub use types::*; -pub mod auth; - -use error::Error; -use crate::adaptive_card::AdaptiveCard; -use futures::{future::try_join_all, try_join}; -use futures_util::{SinkExt, StreamExt}; -use log::{debug, error, trace, warn}; -use reqwest::StatusCode; -use serde::{de::DeserializeOwned, Serialize}; -use std::{ - collections::{hash_map::DefaultHasher, HashMap}, - hash::{self, Hasher}, - sync::{Arc, Mutex}, - time::Duration, -}; -use tokio::net::TcpStream; -use tokio_tungstenite::{ - connect_async, - tungstenite::{Error as TErr, Message as TMessage}, - MaybeTlsStream, WebSocketStream, -}; - -/* - * URLs: - * - * https://help.webex.com/en-us/xbcr37/External-Connections-Made-by-the-Serviceability-Connector - * - * These apply to the central Webex Teams (Wxt) servers. WxT also supports enterprise servers; - * these are not supported. - */ +// Re-export main client types +pub use client::{WStream, Webex, WebexEventStream}; +pub use types::*; -// Main API URL - default for any request. +// Constants used throughout the crate const REST_HOST_PREFIX: &str = "https://api.ciscospark.com/v1"; -// U2C - service discovery, used to discover other URLs (for example, the mercury URL). const U2C_HOST_PREFIX: &str = "https://u2c.wbx2.com/u2c/api/v1"; -// Default mercury URL, used when the token doesn't have permissions to list organizations. const DEFAULT_REGISTRATION_HOST_PREFIX: &str = "https://wdm-a.wbx2.com/wdm/api/v1"; - const CRATE_VERSION: &str = env!("CARGO_PKG_VERSION"); - -// Qualify webex devices created by this crate const DEFAULT_DEVICE_NAME: &str = "rust-client"; const DEVICE_SYSTEM_NAME: &str = "rust-spark-client"; - -/// Web Socket Stream type -pub type WStream = WebSocketStream>; - -/// Webex API Client -#[derive(Clone)] -#[must_use] -pub struct Webex { - id: u64, - client: RestClient, - 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 -pub struct WebexEventStream { - ws_stream: WStream, - timeout: Duration, - /// Signifies if `WebStream` is Open - pub is_open: bool, -} - -impl WebexEventStream { - /// Get the next event from an event stream - /// - /// Returns an event or an error - /// - /// # Errors - /// Returns an error when the underlying stream has a problem, but will - /// continue to work on subsequent calls to `next()` - the errors can safely - /// be ignored. - pub async fn next(&mut self) -> Result { - loop { - let next = self.ws_stream.next(); - - match tokio::time::timeout(self.timeout, next).await { - // Timed out - Err(_) => { - // This does not seem to be recoverable, or at least there are conditions under - // which it does not recover. Indicate that the connection is closed and a new - // one will have to be opened. - self.is_open = false; - return Err(format!("no activity for at least {:?}", self.timeout).into()); - } - // Didn't time out - Ok(next_result) => match next_result { - None => {} - Some(msg) => match msg { - Ok(msg) => { - if let Some(h_msg) = self.handle_message(msg)? { - return Ok(h_msg); - } - // `None` messages still reset the timeout (e.g. Ping to keep alive) - } - Err(TErr::Protocol(_) | TErr::Io(_)) => { - // Protocol error probably requires a connection reset - // IO error is (apart from WouldBlock) generally an error with the - // underlying connection and also fatal - self.is_open = false; - return Err(msg.unwrap_err().to_string().into()); - } - Err(e) => { - return Err(Error::Tungstenite( - Box::new(e), - "Error getting next_result".into(), - )) - } - }, - }, - } - } - } - - fn handle_message(&mut self, msg: TMessage) -> Result, Error> { - match msg { - TMessage::Binary(bytes) => { - let json = std::str::from_utf8(&bytes)?; - match serde_json::from_str(json) { - Ok(ev) => Ok(Some(ev)), - Err(e) => { - warn!("Couldn't deserialize: {:?}. Original JSON:\n{}", e, &json); - Err(e.into()) - } - } - } - TMessage::Text(t) => { - debug!("text: {t}"); - Ok(None) - } - TMessage::Ping(_) => { - trace!("Ping!"); - Ok(None) - } - TMessage::Close(t) => { - debug!("close: {t:?}"); - self.is_open = false; - Err(Error::Closed("Web Socket Closed".to_string())) - } - TMessage::Pong(_) => { - debug!("Pong!"); - Ok(None) - } - TMessage::Frame(_) => { - debug!("Frame"); - Ok(None) - } - } - } - - pub(crate) async fn auth(ws_stream: &mut WStream, token: &str) -> Result<(), Error> { - /* - * Authenticate to the stream - */ - let auth = types::Authorization::new(token); - debug!("Authenticating to stream"); - match ws_stream - .send(TMessage::Text(serde_json::to_string(&auth).unwrap())) - .await - { - Ok(()) => { - /* - * The next thing back should be a pong - */ - match ws_stream.next().await { - Some(msg) => match msg { - Ok(msg) => match msg { - TMessage::Ping(_) | TMessage::Pong(_) => { - debug!("Authentication succeeded"); - Ok(()) - } - _ => Err(format!("Received {msg:?} in reply to auth message").into()), - }, - Err(e) => Err(format!("Received error from websocket: {e}").into()), - }, - None => Err("Websocket closed".to_string().into()), - } - } - Err(e) => Err(Error::Tungstenite( - Box::new(e), - "failed to send authentication".to_string(), - )), - } - } -} - -enum AuthorizationType<'a> { - None, - Bearer(&'a str), - Basic { - username: &'a str, - password: &'a str, - }, -} - -enum Body { - Json(T), - UrlEncoded(T), -} - -const BODY_NONE: Option> = None; - -/// Implements low level REST requests to be used internally by the library -#[derive(Clone)] -struct RestClient { - host_prefix: HashMap, - web_client: reqwest::Client, -} - -impl RestClient { - /// Creates a new `RestClient` - pub fn new() -> Self { - Self { - host_prefix: HashMap::new(), - web_client: reqwest::Client::new(), - } - } - - /****************************************************************** - * Low-level API. These calls are chained to build various - * high-level calls like "get_message" - ******************************************************************/ - - async fn api_get( - &self, - rest_method: &str, - params: Option, - auth: AuthorizationType<'_>, - ) -> Result { - self.rest_api(reqwest::Method::GET, rest_method, auth, params, BODY_NONE) - .await - } - - async fn api_delete( - &self, - rest_method: &str, - params: Option, - auth: AuthorizationType<'_>, - ) -> Result<(), Error> { - 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( - &self, - rest_method: &str, - body: impl Serialize, - params: Option, - auth: AuthorizationType<'_>, - ) -> Result -where { - self.rest_api( - reqwest::Method::POST, - rest_method, - auth, - params, - Some(Body::Json(body)), - ) - .await - } - - async fn api_post_form_urlencoded( - &self, - rest_method: &str, - body: impl Serialize, - params: Option, - auth: AuthorizationType<'_>, - ) -> Result { - self.rest_api( - reqwest::Method::POST, - rest_method, - auth, - params, - Some(Body::UrlEncoded(body)), - ) - .await - } - - async fn api_put( - &self, - rest_method: &str, - body: impl Serialize, - params: Option, - auth: AuthorizationType<'_>, - ) -> Result { - self.rest_api( - reqwest::Method::PUT, - rest_method, - auth, - params, - Some(Body::Json(body)), - ) - .await - } - - async fn rest_api( - &self, - http_method: reqwest::Method, - url: &str, - auth: AuthorizationType<'_>, - params: Option, - body: Option>, - ) -> Result { - let url_trimmed = url.split('?').next().unwrap_or(url); - let prefix = self - .host_prefix - .get(url_trimmed) - .map_or(REST_HOST_PREFIX, String::as_str); - 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); - } - match body { - Some(Body::Json(body)) => { - request_builder = request_builder.json(&body); - } - Some(Body::UrlEncoded(body)) => { - request_builder = request_builder.form(&body); - } - None => {} - } - 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 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()) - } - } - } -} - -impl Webex { - /// Constructs a new Webex Teams context from a token - /// Tokens can be obtained when creating a bot, see for - /// more information and to create your own Webex bots. - pub async fn new(token: &str) -> Self { - Self::new_with_device_name(DEFAULT_DEVICE_NAME, token).await - } - - /// Constructs a new Webex Teams context from a token and a chosen name - /// The name is used to identify the device/client with Webex api - pub async fn new_with_device_name(device_name: &str, token: &str) -> Self { - let mut client: RestClient = RestClient { - host_prefix: HashMap::new(), - web_client: reqwest::Client::new(), - }; - - let mut hasher = DefaultHasher::new(); - hash::Hash::hash_slice(token.as_bytes(), &mut hasher); - let id = hasher.finish(); - - // Have to insert this before calling get_mercury_url() since it uses U2C for the catalog - // request. - client - .host_prefix - .insert("limited/catalog".to_string(), U2C_HOST_PREFIX.to_string()); - - let mut webex = Self { - id, - client, - token: token.to_string(), - device: DeviceData { - device_name: Some(DEFAULT_DEVICE_NAME.to_string()), - device_type: Some("DESKTOP".to_string()), - localized_model: Some("rust".to_string()), - model: Some(format!("rust-v{CRATE_VERSION}")), - name: Some(device_name.to_owned()), - system_name: Some(DEVICE_SYSTEM_NAME.to_string()), - 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}"); - url - } - Err(e) => { - debug!("Failed to fetch devices url, falling back to default"); - debug!("Error: {e:?}"); - DEFAULT_REGISTRATION_HOST_PREFIX.to_string() - } - }; - webex - .client - .host_prefix - .insert("devices".to_string(), devices_url); - - webex - } - - /// Get an event stream handle - pub async fn event_stream(&self) -> Result { - // Helper function to connect to a device - // refactored out to make it easier to loop through all devices and also lazily create a - // new one if needed - async fn connect_device(s: &Webex, device: DeviceData) -> Result { - trace!("Attempting connection with device named {:?}", device.name); - let Some(ws_url) = device.ws_url else { - return Err("Device has no ws_url".into()); - }; - let url = url::Url::parse(ws_url.as_str()) - .map_err(|_| Error::from("Failed to parse ws_url"))?; - debug!("Connecting to {url:?}"); - match connect_async(url.as_str()).await { - Ok((mut ws_stream, _response)) => { - debug!("Connected to {url}"); - WebexEventStream::auth(&mut ws_stream, &s.token).await?; - debug!("Authenticated"); - let timeout = Duration::from_secs(20); - Ok(WebexEventStream { - ws_stream, - timeout, - is_open: true, - }) - } - Err(e) => { - warn!("Failed to connect to {url:?}: {e:?}"); - Err(Error::Tungstenite( - Box::new(e), - "Failed to connect to ws_url".to_string(), - )) - } - } - } - - // get_devices automatically tries to set up devices if the get fails. - // Keep only devices named DEVICE_NAME to avoid conflicts with other clients - let mut devices: Vec = self - .get_devices() - .await? - .iter() - .filter(|d| d.name == self.device.name) - .inspect(|d| trace!("Kept device: {d}")) - .cloned() - .collect(); - - // Sort devices in descending order by modification time, meaning latest created device - // first. - devices.sort_by(|a: &DeviceData, b: &DeviceData| { - b.modification_time - .unwrap_or_else(chrono::Utc::now) - .cmp(&a.modification_time.unwrap_or_else(chrono::Utc::now)) - }); - - for device in devices { - if let Ok(event_stream) = connect_device(self, device).await { - trace!("Successfully connected to device."); - return Ok(event_stream); - } - } - - // Failed to connect to any existing devices, creating new one - 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 - 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()) - { - trace!("Found mercury URL in cache!"); - return result.map_err(|()| None); - } - - let mercury_url = self.get_mercury_url_uncached().await; - - if let Ok(mut cache) = MERCURY_CACHE.lock() { - let result = mercury_url.as_ref().map_or(Err(()), |url| Ok(url.clone())); - trace!("Saving mercury url to cache: {}=>{:?}", self.id, &result); - cache.insert(self.id, result); - } - - mercury_url.map_err(Some) - } - - async fn get_mercury_url_uncached(&self) -> Result { - // Steps: - // 1. Get org id by GET /v1/organizations - // 2. Get urls json from https://u2c.wbx2.com/u2c/api/v1/limited/catalog?orgId=[org id] - // 3. mercury url is urls["serviceLinks"]["wdm"] - // - // 4. Add caching because this doesn't change, and it can be slow - - 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()); - } - let org_id = &orgs[0].id; - let api_url = "limited/catalog"; - let params = [("format", "hostmap"), ("orgId", org_id.as_str())]; - let catalogs = self - .client - .api_get::( - api_url, - Some(params), - AuthorizationType::Bearer(&self.token), - ) - .await?; - let mercury_url = catalogs.service_links.wdm; - - Ok(mercury_url) - } - - /// Get list of organizations - #[deprecated( - since = "0.6.3", - note = "Please use `webex::list::()` instead" - )] - pub async fn get_orgs(&self) -> Result, Error> { - self.list().await - } - /// Get attachment action - /// Retrieves the attachment for the given ID. This can be used to - /// retrieve data from an `AdaptiveCard` submission - #[deprecated( - since = "0.6.3", - note = "Please use `webex::get::(id)` instead" - )] - pub async fn get_attachment_action(&self, id: &GlobalId) -> Result { - self.get(id).await - } - - /// Get a message by ID - #[deprecated( - since = "0.6.3", - note = "Please use `webex::get::(id)` instead" - )] - pub async fn get_message(&self, id: &GlobalId) -> Result { - self.get(id).await - } - - /// Delete a message by ID - #[deprecated( - since = "0.6.3", - note = "Please use `webex::delete::(id)` instead" - )] - pub async fn delete_message(&self, id: &GlobalId) -> Result<(), Error> { - self.delete::(id).await - } - - /// Get available rooms - #[deprecated(since = "0.6.3", note = "Please use `webex::list::()` instead")] - pub async fn get_rooms(&self) -> Result, Error> { - self.list().await - } - - /// Get all rooms from all organizations that the client belongs to. - /// Will be slow as does multiple API calls (one to get teamless rooms, one to get teams, then - /// one per team). - pub async fn get_all_rooms(&self) -> Result, Error> { - let (mut all_rooms, teams) = try_join!(self.list(), self.list::())?; - let futures: Vec<_> = teams - .into_iter() - .map(|team| { - let params = [("teamId", team.id)]; - self.client.api_get::>( - Room::API_ENDPOINT, - Some(params), - AuthorizationType::Bearer(&self.token), - ) - }) - .collect(); - let teams_rooms = try_join_all(futures).await?; - for room in teams_rooms { - all_rooms.extend(room.items.or(room.devices).unwrap_or_else(Vec::new)); - } - Ok(all_rooms) - } - - /// Get available room - #[deprecated(since = "0.6.3", note = "Please use `webex::get::(id)` instead")] - pub async fn get_room(&self, id: &GlobalId) -> Result { - self.get(id).await - } - - /// Get information about person - #[deprecated( - since = "0.6.3", - note = "Please use `webex::get::(id)` instead" - )] - pub async fn get_person(&self, id: &GlobalId) -> Result { - self.get(id).await - } - - /// Send a message to a user or room - /// - /// # Arguments - /// * `message`: [`MessageOut`] - the message to send, including one of `room_id`, - /// `to_person_id` or `to_person_email`. - /// - /// # Errors - /// Types of errors returned: - /// * [`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 your input object cannot be serialized, or the return - /// value cannot be deserialised. (If this happens, this is a library bug and should be - /// reported.) - /// * [`Error::UTF8`] - returned when the request returns non-UTF8 code. - pub async fn send_message(&self, message: &MessageOut) -> Result { - self.client - .api_post( - "messages", - message, - None::<()>, - AuthorizationType::Bearer(&self.token), - ) - .await - } - - /// Edit an existing message - /// - /// # Arguments - /// * `params`: [`MessageEditParams`] - the message to edit, including the message ID and the room ID, - /// as well as the new message text. - /// - /// # Errors - /// Types of errors returned: - /// * [`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 your input object cannot be serialized, or the return - /// value cannot be deserialised. (If this happens, this is a library bug and should be reported). - pub async fn edit_message( - &self, - message_id: &GlobalId, - params: &MessageEditParams<'_>, - ) -> Result { - let rest_method = format!("messages/{}", message_id.id()); - self.client - .api_put( - &rest_method, - params, - None::<()>, - AuthorizationType::Bearer(&self.token), - ) - .await - } - - /// Get a resource from an ID - /// # 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 your input object cannot be serialized, or the return - /// value cannot be deserialised. (If this happens, this is a library bug and should be - /// reported.) - /// * [`Error::UTF8`] - returned when the request returns non-UTF8 code. - pub async fn get(&self, id: &GlobalId) -> Result { - let rest_method = format!("{}/{}", T::API_ENDPOINT, id.id()); - self.client - .api_get::( - rest_method.as_str(), - None::<()>, - AuthorizationType::Bearer(&self.token), - ) - .await - } - - /// Delete a resource from an ID - pub async fn delete(&self, id: &GlobalId) -> Result<(), Error> { - let rest_method = format!("{}/{}", T::API_ENDPOINT, id.id()); - self.client - .api_delete( - rest_method.as_str(), - None::<()>, - AuthorizationType::Bearer(&self.token), - ) - .await - } - - /// List resources of a type - pub async fn list(&self) -> Result, Error> { - self.client - .api_get::>( - T::API_ENDPOINT, - None::<()>, - AuthorizationType::Bearer(&self.token), - ) - .await - .map(|result| result.items.or(result.devices).unwrap_or_default()) - } - - /// List resources of a type, with parameters - pub async fn list_with_params( - &self, - list_params: T::ListParams<'_>, - ) -> Result, Error> { - self.client - .api_get::>( - T::API_ENDPOINT, - Some(list_params), - AuthorizationType::Bearer(&self.token), - ) - .await - .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> { - match self - .client - .api_get::( - "devices", - None::<()>, - AuthorizationType::Bearer(&self.token), - ) - .await - { - #[rustfmt::skip] - Ok(DevicesReply { devices: Some(devices), .. }) => Ok(devices), - Ok(DevicesReply { devices: None, .. }) => { - debug!("Chaining one-time device setup from devices query"); - self.setup_devices().await.map(|device| vec![device]) - } - 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 { - trace!("Setting up new device: {}", &self.device); - self.client - .api_post( - "devices", - &self.device, - None::<()>, - AuthorizationType::Bearer(&self.token), - ) - .await - } -} - -impl From<&AttachmentAction> for MessageOut { - fn from(action: &AttachmentAction) -> Self { - Self { - room_id: action.room_id.clone(), - ..Self::default() - } - } -} - -impl From<&Message> for MessageOut { - fn from(msg: &Message) -> Self { - let mut new_msg = Self::default(); - - if msg.room_type == Some(RoomType::Group) { - new_msg.room_id.clone_from(&msg.room_id); - } else if let Some(_person_id) = &msg.person_id { - new_msg.to_person_id.clone_from(&msg.person_id); - } else { - new_msg.to_person_email.clone_from(&msg.person_email); - } - - new_msg - } -} - -impl Message { - /// Reply to a message. - /// Posts the reply in the same chain as the replied-to message. - /// Contrast with [`MessageOut::from()`] which only replies in the same room. - #[must_use] - pub fn reply(&self) -> MessageOut { - MessageOut { - room_id: self.room_id.clone(), - parent_id: self - .parent_id - .as_deref() - .or(self.id.as_deref()) - .map(ToOwned::to_owned), - ..Default::default() - } - } -} - -impl MessageOut { - /// Generates a new outgoing message from an existing message - /// - /// # Arguments - /// - /// * `msg` - the template message - /// - /// Use `from_msg` to create a reply from a received message. - #[deprecated(since = "0.2.0", note = "Please use the from instead")] - #[must_use] - pub fn from_msg(msg: &Message) -> Self { - Self::from(msg) - } - - /// Add attachment to an existing message - /// - /// # Arguments - /// - /// * `card` - Adaptive Card to attach - pub fn add_attachment(&mut self, card: AdaptiveCard) -> &Self { - self.attachments = Some(vec![Attachment { - content_type: "application/vnd.microsoft.card.adaptive".to_string(), - content: card, - }]); - 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/api.rs b/src/types/api.rs new file mode 100644 index 0000000..ce06391 --- /dev/null +++ b/src/types/api.rs @@ -0,0 +1,75 @@ +//! Internal API traits and types. + +use super::{ + attachment::AttachmentAction, + membership::{Membership, MembershipListParams}, + message::{Message, MessageListParams}, + organization::{Organization, Team}, + person::Person, + room::{Room, RoomListParams}, +}; + +/// Trait for API types. Has to be public due to trait bounds limitations on webex API, but hidden +/// in a private module so users don't see it. +pub trait Gettable { + /// Endpoint to query to perform an HTTP GET request with an id (to get an instance), or + /// without an id (to list them). + const API_ENDPOINT: &'static str; + /// List parameters type for this gettable type. + type ListParams<'a>: serde::Serialize; +} + +/// Infallible type for API endpoints that don't support listing. +#[derive(serde::Serialize, Clone, Debug)] +pub enum Infallible {} + +impl Gettable for Message { + const API_ENDPOINT: &'static str = "messages"; + type ListParams<'a> = MessageListParams<'a>; +} + +impl Gettable for Organization { + const API_ENDPOINT: &'static str = "organizations"; + type ListParams<'a> = Option; +} + +impl Gettable for AttachmentAction { + const API_ENDPOINT: &'static str = "attachment/actions"; + type ListParams<'a> = Option; +} + +impl Gettable for Room { + const API_ENDPOINT: &'static str = "rooms"; + type ListParams<'a> = RoomListParams<'a>; +} + +impl Gettable for Person { + const API_ENDPOINT: &'static str = "people"; + type ListParams<'a> = Option; +} + +impl Gettable for Team { + const API_ENDPOINT: &'static str = "teams"; + type ListParams<'a> = Option; +} + +impl Gettable for Membership { + const API_ENDPOINT: &'static str = "memberships"; + type ListParams<'a> = MembershipListParams<'a>; +} + +/// Result of listing API resources. +#[derive(serde::Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct ListResult { + /// Items returned from the API. + pub items: Option>, + /// Some API endpoints might return different field names (e.g., devices). + pub devices: Option>, + /// Handle error cases - allow `dead_code` since these are for future API error handling + #[allow(dead_code)] + pub(crate) message: Option, + /// Errors returned from the API. + #[allow(dead_code)] + pub(crate) errors: Option>, +} diff --git a/src/types/attachment.rs b/src/types/attachment.rs new file mode 100644 index 0000000..35adaa1 --- /dev/null +++ b/src/types/attachment.rs @@ -0,0 +1,42 @@ +//! Attachment and attachment action types for the Webex API. + +use crate::adaptive_card::AdaptiveCard; +use serde::{Deserialize, Serialize}; +use serde_with::skip_serializing_none; +use std::collections::HashMap; + +/// Attachment for a message (typically an Adaptive Card). +#[derive(Clone, Debug, Deserialize, PartialEq, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct Attachment { + /// The content type of the attachment. + #[serde(rename = "contentType")] + pub content_type: String, + /// Adaptive Card content. + pub content: AdaptiveCard, +} + +/// Attachment action details (when a user interacts with an Adaptive Card). +#[skip_serializing_none] +#[derive(Clone, Debug, Default, Deserialize, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct AttachmentAction { + /// A unique identifier for the action. + pub id: String, + /// The type of action performed. Only 'submit' is currently supported. + /// Required when posting an attachment. + #[serde(rename = "type")] + pub action_type: Option, + /// The parent message the attachment action was performed on. + /// Required when posting an attachment. + pub message_id: Option, + /// The action's inputs. + /// Required when posting an attachment. + pub inputs: Option>, + /// The ID of the person who performed the action. + pub person_id: Option, + /// The ID of the room the action was performed within. + pub room_id: Option, + /// The date and time the action was created. + pub created: Option, +} diff --git a/src/types/device.rs b/src/types/device.rs new file mode 100644 index 0000000..f50a22d --- /dev/null +++ b/src/types/device.rs @@ -0,0 +1,81 @@ +//! Device and authorization types for the Webex API. + +use serde::{Deserialize, Serialize}; +use serde_with::skip_serializing_none; +use std::fmt; +use uuid::Uuid; + +/// Device error information. +#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)] +pub struct DeviceError { + /// Error description + pub description: String, +} + +/// Internal devices reply wrapper. +#[allow(missing_docs)] +#[skip_serializing_none] +#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)] +pub(crate) struct DevicesReply { + pub devices: Option>, + pub message: Option, + pub errors: Option>, + #[serde(rename = "trackingId")] + pub tracking_id: Option, +} + +/// Webex device information. +#[allow(missing_docs)] +#[derive(Clone, Debug, Default, Deserialize, Eq, PartialEq, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct DeviceData { + pub url: Option, + #[serde(rename = "webSocketUrl")] + pub ws_url: Option, + pub device_name: Option, + pub device_type: Option, + pub localized_model: Option, + pub modification_time: Option>, + pub model: Option, + pub name: Option, + pub system_name: Option, + pub system_version: Option, +} + +impl fmt::Display for DeviceData { + fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result { + write!(f, "name: {:?}, device_name: {:?}, device_type: {:?}, model: {:?}, system_name: {:?}, system_version: {:?}, url: {:?}", + self.name, self.device_name, self.device_type, self.model, self.system_name, self.system_version, self.url) + } +} + +/// Authorization token for WebSocket connection. +#[allow(missing_docs)] +#[derive(Clone, Debug, Default, Deserialize, Eq, PartialEq, Serialize)] +pub struct Authorization { + pub id: String, + #[serde(rename = "type")] + pub auth_type: String, + data: AuthToken, +} + +impl Authorization { + /// Create a new `Authorization` object from a token + /// id is a random UUID v4 + #[must_use] + pub fn new(token: &str) -> Self { + Self { + id: Uuid::new_v4().to_string(), + auth_type: "authorization".to_string(), + data: AuthToken { + token: format!("Bearer {token}"), + }, + } + } +} + +/// Internal auth token wrapper. +#[derive(Clone, Debug, Default, Deserialize, Eq, PartialEq, Serialize)] +pub(crate) struct AuthToken { + pub token: String, +} diff --git a/src/types.rs b/src/types/event.rs similarity index 54% rename from src/types.rs rename to src/types/event.rs index 236eb8a..a7c1c2e 100644 --- a/src/types.rs +++ b/src/types/event.rs @@ -1,475 +1,14 @@ -#![deny(missing_docs)] -//! Basic types for Webex Teams APIs +//! Event and activity types for the Webex WebSocket API, including `GlobalId` utilities. -use crate::{adaptive_card::AdaptiveCard, error}; +use crate::error; use base64::Engine; - use serde::{Deserialize, Serialize}; use serde_with::skip_serializing_none; +use std::collections::HashMap; use std::convert::TryFrom; -use std::{collections::HashMap, fmt}; use uuid::Uuid; -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, 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 - /// in a private crate so users don't see it. - pub trait Gettable { - /// Endpoint to query to perform an HTTP GET request with an id (to get an instance), or - /// without an id (to list them). - const API_ENDPOINT: &'static str; - type ListParams<'a>: serde::Serialize; - } - - #[derive(crate::types::Serialize, Clone, Debug)] - pub enum Infallible {} - - impl Gettable for Message { - const API_ENDPOINT: &'static str = "messages"; - type ListParams<'a> = MessageListParams<'a>; - } - - impl Gettable for Organization { - const API_ENDPOINT: &'static str = "organizations"; - type ListParams<'a> = Option; - } - - impl Gettable for AttachmentAction { - const API_ENDPOINT: &'static str = "attachment/actions"; - type ListParams<'a> = Option; - } - - impl Gettable for Room { - const API_ENDPOINT: &'static str = "rooms"; - type ListParams<'a> = RoomListParams<'a>; - } - - impl Gettable for Person { - const API_ENDPOINT: &'static str = "people"; - type ListParams<'a> = Option; - } - - impl Gettable for Team { - const API_ENDPOINT: &'static str = "teams"; - 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: 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>, - } -} - -/// Webex Teams room information -#[skip_serializing_none] -#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)] -#[serde(rename_all = "camelCase")] -pub struct Room { - /// A unique identifier for the room. - pub id: String, - /// A user-friendly name for the room. - pub title: Option, - /// The room type. - /// - /// direct - 1:1 room - /// group - group room - #[serde(rename = "type")] - pub room_type: String, - /// Whether the room is moderated (locked) or not. - pub is_locked: bool, - /// The ID for the team with which this room is associated. - pub team_id: Option, - /// The date and time of the room's last activity. - pub last_activity: String, - /// The ID of the person who created this room. - pub creator_id: String, - /// The date and time the room was created. - pub created: String, -} - -#[derive(Clone, Debug, Eq, PartialEq, crate::types::Serialize)] -#[serde(rename_all = "lowercase")] -/// Sorting order for `RoomListParams` -pub enum SortRoomsBy { - /// room id - Id, - /// last activity timestamp - LastActivity, - /// created timestamp - Created, -} - -#[skip_serializing_none] -#[derive(Clone, Debug, Default, Eq, PartialEq, crate::types::Serialize)] -#[serde(rename_all = "camelCase")] -/// Parameters for listing rooms -pub struct RoomListParams<'a> { - /// List rooms in a team, by ID. - pub team_id: Option<&'a str>, - /// List rooms by type. Cannot be set in combination with orgPublicSpaces. - #[serde(rename = "type")] - pub room_type: Option, - /// Shows the org's public spaces joined and unjoined. When set the result list is sorted by the madePublic timestamp. - pub org_public_spaces: Option, - /// Filters rooms, that were made public after this time. See madePublic timestamp - pub from: Option<&'a str>, - /// Filters rooms, that were made public before this time. See madePublic timestamp - pub to: Option<&'a str>, - /// Sort results. Cannot be set in combination with orgPublicSpaces. - pub sort_by: Option, - /// Limit the maximum number of rooms in the response. - /// Default: 100 - pub max: Option, -} - -/// Holds details about the organization an account belongs to. -#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)] -#[serde(rename_all = "camelCase")] -pub struct Organization { - /// Id of the org. - pub id: String, - /// Display name of the org - pub display_name: Option, - /// Date and time the org was created - pub created: String, -} - -#[skip_serializing_none] -#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)] -/// Holds details about a team that includes the account. -pub struct Team { - /// Id of the team - pub id: String, - /// Name of the team - pub name: Option, - /// Date and time the team was created - pub created: String, - /// Team description - 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 { - pub service_links: Catalog, -} - -#[allow(missing_docs)] -#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)] -#[serde(rename_all = "kebab-case")] -pub struct Catalog { - pub atlas: String, - #[serde(rename = "broadworksIdpProxy")] - pub broadworks_idp_proxy: String, - #[serde(rename = "clientLogs")] - pub client_logs: String, - pub ecomm: String, - pub fms: String, - pub idbroker: String, - pub idbroker_guest: String, - pub identity: String, - pub identity_guest_cs: String, - pub license: String, - #[serde(rename = "meetingRegistry")] - pub meeting_registry: String, - pub metrics: String, - pub oauth_helper: String, - pub settings_service: String, - pub u2c: String, - /// wdm is the url used for fetching devices. - pub wdm: String, - pub web_authentication: String, - pub webex_appapi_service: String, -} - -/// Destination for a `MessageOut` -#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)] -#[serde(rename_all = "camelCase")] -pub enum Destination { - /// Post a message in this room - RoomId(String), - /// Post a message to a person, using their user ID - ToPersonId(String), - /// Post a message to a person, using their email - ToPersonEmail(String), -} - -/// Outgoing message -#[skip_serializing_none] -#[derive(Clone, Debug, Default, Deserialize, PartialEq, Serialize)] -#[serde(rename_all = "camelCase")] -pub struct MessageOut { - /// The parent message to reply to. - pub parent_id: Option, - /// The room ID of the message. - pub room_id: Option, - /// The person ID of the recipient when sending a private 1:1 message. - pub to_person_id: Option, - /// The email address of the recipient when sending a private 1:1 message. - pub to_person_email: Option, - // TODO - should we use globalIDs? We should check this field before the message is sent - // rolls up room_id, to_person_id, and to_person_email all in one field :) - //#[serde(flatten)] - //pub deliver_to: Option, - /// The message, in plain text. If markdown is specified this parameter may be optionally used to provide alternate text for UI clients that do not support rich text. The maximum message length is 7439 bytes. - pub text: Option, - /// The message, in Markdown format. The maximum message length is 7439 bytes. - pub markdown: Option, - /// The public URL to a binary file to be posted into the room. Only one file is allowed per message. Uploaded files are automatically converted into a format that all Webex Teams clients can render. For the supported media types and the behavior of uploads, see the [Message Attachments Guide](https://developer.webex.com/docs/api/basics#message-attachments). - pub files: Option>, - /// Content attachments to attach to the message. Only one card per message is supported. - pub attachments: Option>, -} - -/// Type of room -#[derive(Clone, Debug, Default, Deserialize, Eq, PartialEq, Serialize)] -#[serde(rename_all = "lowercase")] -pub enum RoomType { - #[default] - /// 1:1 private chat - Direct, - /// Group room - Group, -} - -/// Webex Teams message information -#[skip_serializing_none] -#[derive(Clone, Debug, Default, Deserialize, PartialEq, Serialize)] -#[serde(rename_all = "camelCase")] -pub struct Message { - /// The unique identifier for the message. - pub id: Option, - /// The room ID of the message. - pub room_id: Option, - /// The room type. - pub room_type: Option, - /// The person ID of the recipient when sending a private 1:1 message. - pub to_person_id: Option, - /// The email address of the recipient when sending a private 1:1 message. - pub to_person_email: Option, - /// The message, in plain text. If markdown is specified this parameter may be optionally used to provide alternate text for UI clients that do not support rich text. - pub text: Option, - /// The message, in Markdown format. - pub markdown: Option, - /// The text content of the message, in HTML format. This read-only property is used by the Webex Teams clients. - pub html: Option, - /// Public URLs for files attached to the message. For the supported media types and the behavior of file uploads, see Message Attachments. - pub files: Option>, - /// The person ID of the message author. - pub person_id: Option, - /// The email address of the message author. - pub person_email: Option, - /// People IDs for anyone mentioned in the message. - pub mentioned_people: Option>, - /// Group names for the groups mentioned in the message. - pub mentioned_groups: Option>, - /// Message content attachments attached to the message. - pub attachments: Option>, - /// The date and time the message was created. - pub created: Option, - /// The date and time the message was updated, if it was edited. - pub updated: Option, - /// The ID of the "parent" message (the start of the reply chain) - pub parent_id: Option, -} - -#[skip_serializing_none] -#[derive(Clone, Debug, Eq, PartialEq, Serialize)] -#[serde(rename_all = "camelCase")] -/// Parameters for listing messages -pub struct MessageListParams<'a> { - /// List messages in a room, by ID. - pub room_id: &'a str, - /// List messages with a parent, by ID. - pub parent_id: Option<&'a str>, - /// List messages with these people mentioned, by ID. Use me as a shorthand for the current API user. - /// Only me or the person ID of the current user may be specified. Bots must include this parameter - /// to list messages in group rooms (spaces). - #[serde(skip_serializing_if = "<[_]>::is_empty")] - pub mentioned_people: &'a [&'a str], - /// List messages sent before a date and time. - pub before: Option<&'a str>, - /// List messages sent before a message, by ID. - pub before_message: Option<&'a str>, - /// Limit the maximum number of messages in the response. - /// Default: 50 - pub max: Option, -} - -impl<'a> MessageListParams<'a> { - /// Creates a new `MessageListParams` with the given room ID. - #[allow(clippy::must_use_candidate)] - pub const fn new(room_id: &'a str) -> Self { - Self { - room_id, - parent_id: None, - mentioned_people: &[], - before: None, - before_message: None, - max: None, - } - } -} - -/// Parameters for editing a message. -/// `room_id` is required, and at least one of `text` or `markdown` must be set. -/// Follows -#[skip_serializing_none] -#[derive(Clone, Debug, Default, Eq, PartialEq, Serialize)] -#[serde(rename_all = "camelCase")] -pub struct MessageEditParams<'a> { - /// The id of the room the message is posted in. - pub room_id: &'a str, - /// The plain text content of the message. If markdown is specified this parameter may be optionally - /// used to provide alternate text for UI clients that do not support rich text. - pub text: Option<&'a str>, - /// The markdown content of the message. If this attribute is set ensure that the request does NOT contain an html attribute. - pub markdown: Option<&'a str>, - /// The message, in HTML format. The maximum message length is 7439 bytes. - pub html: Option<&'a str>, -} - -#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)] -#[allow(dead_code)] -pub(crate) struct EmptyReply {} - -/// API Error -#[allow(missing_docs)] -#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)] -pub struct DeviceError { - pub description: String, -} - -#[allow(missing_docs)] -#[skip_serializing_none] -#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)] -pub(crate) struct DevicesReply { - pub devices: Option>, - pub message: Option, - pub errors: Option>, - #[serde(rename = "trackingId")] - pub tracking_id: Option, -} - -#[allow(missing_docs)] -#[derive(Clone, Debug, Default, Deserialize, Eq, PartialEq, Serialize)] -#[serde(rename_all = "camelCase")] -pub struct DeviceData { - pub url: Option, - #[serde(rename = "webSocketUrl")] - pub ws_url: Option, - pub device_name: Option, - pub device_type: Option, - pub localized_model: Option, - pub modification_time: Option>, - pub model: Option, - pub name: Option, - pub system_name: Option, - pub system_version: Option, -} - -impl fmt::Display for DeviceData { - fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result { - write!(f, "name: {:?}, device_name: {:?}, device_type: {:?}, model: {:?}, system_name: {:?}, system_version: {:?}, url: {:?}", - self.name, self.device_name, self.device_type, self.model, self.system_name, self.system_version, self.url) - } -} - -#[allow(missing_docs)] -#[derive(Clone, Debug, Default, Deserialize, Eq, PartialEq, Serialize)] -pub struct Authorization { - pub id: String, - #[serde(rename = "type")] - pub auth_type: String, - data: AuthToken, -} - -impl Authorization { - /// Create a new `Authorization` object from a token - /// id is a random UUID v4 - #[must_use] - pub fn new(token: &str) -> Self { - Self { - id: Uuid::new_v4().to_string(), - auth_type: "authorization".to_string(), - data: AuthToken { - token: format!("Bearer {token}"), - }, - } - } -} - -#[derive(Clone, Debug, Default, Deserialize, Eq, PartialEq, Serialize)] -pub(crate) struct AuthToken { - pub token: String, -} - +/// Actor information from WebSocket events. #[allow(missing_docs)] #[derive(Clone, Debug, Default, Deserialize, Eq, PartialEq, Serialize)] #[serde(rename_all = "camelCase")] @@ -534,7 +73,7 @@ pub enum ActivityType { Message(MessageActivity), /// The space the bot is in has changed - see [`SpaceActivity`] for details. Space(SpaceActivity), - /// The user has submitted an [`AdaptiveCard`]. + /// The user has submitted an [`AdaptiveCard`](crate::adaptive_card::AdaptiveCard). AdaptiveCardSubmit, /// Meeting event. /// TODO: This needs to be broken down like `Message` and `Space`, if anyone cares. @@ -750,16 +289,15 @@ impl Event { /// Get the UUID of the room the Space created event corresponds to. /// This is a workaround for a bug in the API, where the UUID returned in the event is not correct. /// - /// # Panics + /// # Errors /// - /// Will panic if the event is not `Space::Created` or if activity is not set. + /// Returns an error if the event is not `Space::Created` or if activity is not set. fn room_id_of_space_created_event(&self) -> Result { - assert_eq!( - self.activity_type(), - ActivityType::Space(SpaceActivity::Created), - "Expected space created event, got {:?}", - self.activity_type() - ); + if self.activity_type() != ActivityType::Space(SpaceActivity::Created) { + return Err(crate::error::Error::Api( + "Expected space created event, got different activity type", + )); + } let activity_id = self .data .activity @@ -1049,101 +587,6 @@ pub struct Event { pub filter_message: bool, } -/// Message content attachments attached to the message. -#[derive(Clone, Debug, Default, Deserialize, PartialEq, Serialize)] -pub struct Attachment { - /// The content type of the attachment. - #[serde(rename = "contentType")] - pub content_type: String, - /// Adaptive Card content. - pub content: AdaptiveCard, -} - -/// Attachment action details -#[skip_serializing_none] -#[derive(Clone, Debug, Default, Deserialize, Serialize)] -#[serde(rename_all = "camelCase")] -pub struct AttachmentAction { - /// A unique identifier for the action. - pub id: String, - /// The type of action performed. Only 'submit' is currently supported. - /// Required when posting an attachment. - #[serde(rename = "type")] - pub action_type: Option, - /// The parent message the attachment action was performed on. - /// Required when posting an attachment. - pub message_id: Option, - /// The action's inputs. - /// Required when posting an attachment. - pub inputs: Option>, - /// The ID of the person who performed the action. - pub person_id: Option, - /// The ID of the room the action was performed within. - pub room_id: Option, - /// The date and time the action was created. - pub created: Option, -} - -/// Person information -#[derive(Clone, Debug, Default, Deserialize, Eq, PartialEq, Serialize)] -#[serde(rename_all = "camelCase", default)] -pub struct Person { - /// A unique identifier for the person. - pub id: String, - /// The email addresses of the person. - pub emails: Vec, - /// Phone numbers for the person. - 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: Option, - /// The first name of the person. - pub first_name: Option, - /// The last name of the person. - pub last_name: Option, - /// The URL to the person's avatar in PNG format. - 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, - /// The date and time of the person's last activity within Webex Teams. - pub last_activity: String, - /// The current presence status of the person. - /// - /// active - active within the last 10 minutes - /// call - the user is in a call - /// `DoNotDisturb` - the user has manually set their status to "Do Not Disturb" - /// inactive - last activity occurred more than 10 minutes ago - /// meeting - the user is in a meeting - /// `OutOfOffice` - the user or a Hybrid Calendar service has indicated that they are "Out of Office" - /// pending - the user has never logged in; a status cannot be determined - /// presenting - the user is sharing content - /// unknown - the user’s status could not be determined - pub status: String, - /// The type of person account, such as person or bot. - /// - /// person- account belongs to a person - /// bot - account is a bot user - /// appuser - account is a guest user - #[serde(rename = "type")] - pub person_type: String, -} - -/// Phone number information -#[derive(Clone, Debug, Default, Deserialize, Eq, PartialEq, Serialize)] -#[serde(default)] -pub struct PhoneNumber { - /// Phone number type - #[serde(rename = "type")] - pub number_type: String, - /// Phone number - pub value: String, -} - #[cfg(test)] mod tests { use super::*; @@ -1239,4 +682,66 @@ mod tests { }); assert!(event.room_id_of_space_created_event().is_err()); } + + #[test] + fn test_global_id_from_uuid() { + let uuid = "1ab849e0-9ab4-11ee-a70f-d9b57e49f8bf"; + let global_id = GlobalId::new(GlobalIdType::Room, uuid.to_string()).unwrap(); + + assert_eq!(global_id.type_, GlobalIdType::Room); + // The ID should be base64 encoded when created from a UUID + assert!(!global_id.id().is_empty()); + assert_ne!(global_id.id(), uuid); + } + + #[test] + fn test_global_id_check_type_success() { + let uuid = "1ab849e0-9ab4-11ee-a70f-d9b57e49f8bf"; + let global_id = GlobalId::new(GlobalIdType::Room, uuid.to_string()).unwrap(); + + assert!(global_id.check_type(GlobalIdType::Room).is_ok()); + } + + #[test] + fn test_global_id_check_type_failure() { + let uuid = "1ab849e0-9ab4-11ee-a70f-d9b57e49f8bf"; + let global_id = GlobalId::new(GlobalIdType::Room, uuid.to_string()).unwrap(); + + assert!(global_id.check_type(GlobalIdType::Person).is_err()); + } + + #[test] + fn test_global_id_with_cluster() { + let uuid = "1ab849e0-9ab4-11ee-a70f-d9b57e49f8bf"; + let global_id = + GlobalId::new_with_cluster(GlobalIdType::Room, uuid.to_string(), Some("eu")).unwrap(); + + // The cluster should be encoded in the base64 ID + assert!(!global_id.id().is_empty()); + assert_ne!(global_id.id(), uuid); + } + + #[test] + fn test_global_id_unknown_type_error() { + let uuid = "1ab849e0-9ab4-11ee-a70f-d9b57e49f8bf"; + let result = GlobalId::new(GlobalIdType::Unknown, uuid.to_string()); + + assert!(result.is_err()); + } + + #[test] + fn test_global_id_already_encoded() { + // If given an already encoded GlobalId, it should pass through + let encoded = + "Y2lzY29zcGFyazovL3VzL1JPT00vMWFiODQ5ZTAtOWFiNC0xMWVlLWE3MGYtZDliNTdlNDlmOGJm"; + let global_id = GlobalId::new(GlobalIdType::Room, encoded.to_string()).unwrap(); + + assert_eq!(global_id.id, encoded); + } + + #[test] + fn test_message_activity_is_created() { + assert!(MessageActivity::Posted.is_created()); + assert!(!MessageActivity::Deleted.is_created()); + } } diff --git a/src/types/membership.rs b/src/types/membership.rs new file mode 100644 index 0000000..698843c --- /dev/null +++ b/src/types/membership.rs @@ -0,0 +1,50 @@ +//! Membership-related types for the Webex API. + +use serde::{Deserialize, Serialize}; +use serde_with::skip_serializing_none; + +/// 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, +} + +/// Parameters for listing memberships. +#[skip_serializing_none] +#[derive(Clone, Debug, Default, Eq, PartialEq, Serialize)] +#[serde(rename_all = "camelCase")] +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, +} diff --git a/src/types/message.rs b/src/types/message.rs new file mode 100644 index 0000000..b07f7ad --- /dev/null +++ b/src/types/message.rs @@ -0,0 +1,130 @@ +//! Message-related types for the Webex API. + +use super::attachment::Attachment; +use super::room::RoomType; +use serde::{Deserialize, Serialize}; +use serde_with::skip_serializing_none; + +/// Outgoing message to be sent to Webex. +#[skip_serializing_none] +#[derive(Clone, Debug, Default, Deserialize, PartialEq, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct MessageOut { + /// The parent message to reply to. + pub parent_id: Option, + /// The room ID of the message. + pub room_id: Option, + /// The person ID of the recipient when sending a private 1:1 message. + pub to_person_id: Option, + /// The email address of the recipient when sending a private 1:1 message. + pub to_person_email: Option, + // TODO - should we use globalIDs? We should check this field before the message is sent + // rolls up room_id, to_person_id, and to_person_email all in one field :) + //#[serde(flatten)] + //pub deliver_to: Option, + /// The message, in plain text. If markdown is specified this parameter may be optionally used to provide alternate text for UI clients that do not support rich text. The maximum message length is 7439 bytes. + pub text: Option, + /// The message, in Markdown format. The maximum message length is 7439 bytes. + pub markdown: Option, + /// The public URL to a binary file to be posted into the room. Only one file is allowed per message. Uploaded files are automatically converted into a format that all Webex Teams clients can render. For the supported media types and the behavior of uploads, see the [Message Attachments Guide](https://developer.webex.com/docs/api/basics#message-attachments). + pub files: Option>, + /// Content attachments to attach to the message. Only one card per message is supported. + pub attachments: Option>, +} + +/// Webex Teams message information. +#[skip_serializing_none] +#[derive(Clone, Debug, Default, Deserialize, PartialEq, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct Message { + /// The unique identifier for the message. + pub id: Option, + /// The room ID of the message. + pub room_id: Option, + /// The room type. + pub room_type: Option, + /// The person ID of the recipient when sending a private 1:1 message. + pub to_person_id: Option, + /// The email address of the recipient when sending a private 1:1 message. + pub to_person_email: Option, + /// The message, in plain text. If markdown is specified this parameter may be optionally used to provide alternate text for UI clients that do not support rich text. + pub text: Option, + /// The message, in Markdown format. + pub markdown: Option, + /// The text content of the message, in HTML format. This read-only property is used by the Webex Teams clients. + pub html: Option, + /// Public URLs for files attached to the message. For the supported media types and the behavior of file uploads, see Message Attachments. + pub files: Option>, + /// The person ID of the message author. + pub person_id: Option, + /// The email address of the message author. + pub person_email: Option, + /// People IDs for anyone mentioned in the message. + pub mentioned_people: Option>, + /// Group names for the groups mentioned in the message. + pub mentioned_groups: Option>, + /// Message content attachments attached to the message. + pub attachments: Option>, + /// The date and time the message was created. + pub created: Option, + /// The date and time the message was updated, if it was edited. + pub updated: Option, + /// The ID of the "parent" message (the start of the reply chain) + pub parent_id: Option, +} + +/// Parameters for listing messages. +#[skip_serializing_none] +#[derive(Clone, Debug, Eq, PartialEq, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct MessageListParams<'a> { + /// List messages in a room, by ID. + pub room_id: &'a str, + /// List messages with a parent, by ID. + pub parent_id: Option<&'a str>, + /// List messages with these people mentioned, by ID. Use me as a shorthand for the current API user. + /// Only me or the person ID of the current user may be specified. Bots must include this parameter + /// to list messages in group rooms (spaces). + #[serde(skip_serializing_if = "<[_]>::is_empty")] + pub mentioned_people: &'a [&'a str], + /// List messages sent before a date and time. + pub before: Option<&'a str>, + /// List messages sent before a message, by ID. + pub before_message: Option<&'a str>, + /// Limit the maximum number of messages in the response. + /// Default: 50 + pub max: Option, +} + +impl<'a> MessageListParams<'a> { + /// Creates a new `MessageListParams` with the given room ID. + #[allow(clippy::must_use_candidate)] + pub const fn new(room_id: &'a str) -> Self { + Self { + room_id, + parent_id: None, + mentioned_people: &[], + before: None, + before_message: None, + max: None, + } + } +} + +/// Parameters for editing a message. +/// `room_id` is required, and at least one of `text` or `markdown` must be set. +/// Follows +#[skip_serializing_none] +#[derive(Clone, Debug, Default, Eq, PartialEq, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct MessageEditParams<'a> { + /// The id of the room the message is posted in. + pub room_id: &'a str, + /// The plain text content of the message. If markdown is specified this parameter may be optionally + /// used to provide alternate text for UI clients that do not support rich text. + pub text: Option<&'a str>, + /// The markdown content of the message. If this attribute is set ensure that the request does NOT contain an html attribute. + pub markdown: Option<&'a str>, + /// The message, in HTML format. The maximum message length is 7439 bytes. + pub html: Option<&'a str>, +} diff --git a/src/types/mod.rs b/src/types/mod.rs new file mode 100644 index 0000000..fd356d9 --- /dev/null +++ b/src/types/mod.rs @@ -0,0 +1,37 @@ +#![deny(missing_docs)] +//! Basic types for Webex Teams APIs + +// Submodules +mod api; +pub mod attachment; +pub mod device; +pub mod event; +pub mod membership; +pub mod message; +pub mod organization; +pub mod person; +pub mod room; + +// Re-export commonly used types at the crate root +pub use attachment::{Attachment, AttachmentAction}; +pub use device::{Authorization, DeviceData, DeviceError}; +pub use event::{ + Activity, ActivityParent, ActivityType, Actor, AlertType, Event, EventData, GlobalId, + GlobalIdType, MessageActivity, MiscItem, MiscItems, Object, SpaceActivity, Target, + VectorCounters, +}; +pub use membership::{Membership, MembershipListParams}; +pub use message::{Message, MessageEditParams, MessageListParams, MessageOut}; +pub use organization::{Catalog, Destination, Organization, Team}; +pub use person::{Person, PhoneNumber}; +pub use room::{Room, RoomListParams, RoomType, SortRoomsBy}; + +// Internal types +pub(crate) use api::{Gettable, ListResult}; +pub(crate) use device::DevicesReply; +pub(crate) use organization::CatalogReply; + +/// Empty reply for API endpoints that return no data. +#[derive(Clone, Debug, serde::Deserialize, Eq, PartialEq, serde::Serialize)] +#[allow(dead_code)] +pub(crate) struct EmptyReply {} diff --git a/src/types/organization.rs b/src/types/organization.rs new file mode 100644 index 0000000..9737290 --- /dev/null +++ b/src/types/organization.rs @@ -0,0 +1,96 @@ +//! Organization and team-related types for the Webex API. + +use serde::{Deserialize, Serialize}; +use serde_with::skip_serializing_none; + +/// Holds details about the organization an account belongs to. +#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct Organization { + /// Id of the org. + pub id: String, + /// Display name of the org + pub display_name: Option, + /// Date and time the org was created + pub created: String, +} + +/// Holds details about a team that includes the account. +#[skip_serializing_none] +#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)] +pub struct Team { + /// Id of the team + pub id: String, + /// Name of the team + pub name: Option, + /// Date and time the team was created + pub created: String, + /// Team description + pub description: Option, +} + +/// Internal catalog reply wrapper. +#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)] +#[serde(rename_all = "camelCase")] +pub(crate) struct CatalogReply { + /// Service links catalog + pub service_links: Catalog, +} + +/// Service catalog with URLs for various Webex services. +#[allow(missing_docs)] +#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)] +#[serde(rename_all = "kebab-case")] +pub struct Catalog { + /// Atlas service URL + pub atlas: String, + /// Broadworks IDP proxy URL + #[serde(rename = "broadworksIdpProxy")] + pub broadworks_idp_proxy: String, + /// Client logs service URL + #[serde(rename = "clientLogs")] + pub client_logs: String, + /// Ecomm service URL + pub ecomm: String, + /// FMS service URL + pub fms: String, + /// ID broker service URL + pub idbroker: String, + /// ID broker guest service URL + pub idbroker_guest: String, + /// Identity service URL + pub identity: String, + /// Identity guest CS service URL + pub identity_guest_cs: String, + /// License service URL + pub license: String, + /// Meeting registry service URL + #[serde(rename = "meetingRegistry")] + pub meeting_registry: String, + /// Metrics service URL + pub metrics: String, + /// OAuth helper service URL + pub oauth_helper: String, + /// Settings service URL + pub settings_service: String, + /// U2C service URL + pub u2c: String, + /// wdm is the url used for fetching devices. + pub wdm: String, + /// Web authentication service URL + pub web_authentication: String, + /// Webex App API service URL + pub webex_appapi_service: String, +} + +/// Destination for a `MessageOut`. +#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)] +#[serde(rename_all = "camelCase")] +pub enum Destination { + /// Post a message in this room + RoomId(String), + /// Post a message to a person, using their user ID + ToPersonId(String), + /// Post a message to a person, using their email + ToPersonEmail(String), +} diff --git a/src/types/person.rs b/src/types/person.rs new file mode 100644 index 0000000..339cfdd --- /dev/null +++ b/src/types/person.rs @@ -0,0 +1,63 @@ +//! Person-related types for the Webex API. + +use serde::{Deserialize, Serialize}; + +/// Information about a Webex Teams person/user. +#[derive(Clone, Debug, Default, Deserialize, Eq, PartialEq, Serialize)] +#[serde(default)] +pub struct Person { + /// A unique identifier for the person. + pub id: String, + /// The email addresses of the person. + pub emails: Vec, + /// Phone numbers for the person. + 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: Option, + /// The first name of the person. + pub first_name: Option, + /// The last name of the person. + pub last_name: Option, + /// The URL to the person's avatar in PNG format. + 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, + /// The date and time of the person's last activity within Webex Teams. + pub last_activity: String, + /// The current presence status of the person. + /// + /// active - active within the last 10 minutes + /// call - the user is in a call + /// `DoNotDisturb` - the user has manually set their status to "Do Not Disturb" + /// inactive - last activity occurred more than 10 minutes ago + /// meeting - the user is in a meeting + /// `OutOfOffice` - the user or a Hybrid Calendar service has indicated that they are "Out of Office" + /// pending - the user has never logged in; a status cannot be determined + /// presenting - the user is sharing content + /// unknown - the user's status could not be determined + pub status: String, + /// The type of person account, such as person or bot. + /// + /// person- account belongs to a person + /// bot - account is a bot user + /// appuser - account is a guest user + #[serde(rename = "type")] + pub person_type: String, +} + +/// Phone number information for a person. +#[derive(Clone, Debug, Default, Deserialize, Eq, PartialEq, Serialize)] +#[serde(default)] +pub struct PhoneNumber { + /// Phone number type + #[serde(rename = "type")] + pub number_type: String, + /// Phone number + pub value: String, +} diff --git a/src/types/room.rs b/src/types/room.rs new file mode 100644 index 0000000..4a789fb --- /dev/null +++ b/src/types/room.rs @@ -0,0 +1,77 @@ +//! Room-related types for the Webex API. + +use serde::{Deserialize, Serialize}; +use serde_with::skip_serializing_none; + +/// Webex Teams room information. +#[skip_serializing_none] +#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct Room { + /// A unique identifier for the room. + pub id: String, + /// A user-friendly name for the room. + pub title: Option, + /// The room type. + /// + /// direct - 1:1 room + /// group - group room + #[serde(rename = "type")] + pub room_type: String, + /// Whether the room is moderated (locked) or not. + pub is_locked: bool, + /// The ID for the team with which this room is associated. + pub team_id: Option, + /// The date and time of the room's last activity. + pub last_activity: String, + /// The ID of the person who created this room. + pub creator_id: String, + /// The date and time the room was created. + pub created: String, +} + +/// Sorting order for `RoomListParams`. +#[derive(Clone, Debug, Eq, PartialEq, Serialize)] +#[serde(rename_all = "lowercase")] +pub enum SortRoomsBy { + /// room id + Id, + /// last activity timestamp + LastActivity, + /// created timestamp + Created, +} + +/// Parameters for listing rooms. +#[skip_serializing_none] +#[derive(Clone, Debug, Default, Eq, PartialEq, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct RoomListParams<'a> { + /// List rooms in a team, by ID. + pub team_id: Option<&'a str>, + /// List rooms by type. Cannot be set in combination with orgPublicSpaces. + #[serde(rename = "type")] + pub room_type: Option, + /// Shows the org's public spaces joined and unjoined. When set the result list is sorted by the madePublic timestamp. + pub org_public_spaces: Option, + /// Filters rooms, that were made public after this time. See madePublic timestamp + pub from: Option<&'a str>, + /// Filters rooms, that were made public before this time. See madePublic timestamp + pub to: Option<&'a str>, + /// Sort results. Cannot be set in combination with orgPublicSpaces. + pub sort_by: Option, + /// Limit the maximum number of rooms in the response. + /// Default: 100 + pub max: Option, +} + +/// The type of room (direct message or group). +#[derive(Clone, Copy, Debug, Default, Deserialize, Eq, PartialEq, Serialize)] +#[serde(rename_all = "lowercase")] +pub enum RoomType { + #[default] + /// 1:1 private chat + Direct, + /// Group room + Group, +}