zoomcli 0.2.0__tar.gz → 0.2.1__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- zoomcli-0.2.1/.gitignore +2 -0
- zoomcli-0.2.1/CHANGELOG.md +37 -0
- {zoomcli-0.2.0 → zoomcli-0.2.1}/Cargo.lock +2 -1
- {zoomcli-0.2.0 → zoomcli-0.2.1}/Cargo.toml +2 -1
- zoomcli-0.2.1/Formula/zoom-cli.rb +32 -0
- {zoomcli-0.2.0 → zoomcli-0.2.1}/PKG-INFO +1 -1
- {zoomcli-0.2.0 → zoomcli-0.2.1}/src/api/client.rs +116 -20
- {zoomcli-0.2.0 → zoomcli-0.2.1}/src/api/mod.rs +12 -0
- {zoomcli-0.2.0 → zoomcli-0.2.1}/src/api/types.rs +75 -0
- {zoomcli-0.2.0 → zoomcli-0.2.1}/src/commands/config.rs +75 -0
- {zoomcli-0.2.0 → zoomcli-0.2.1}/src/commands/meetings.rs +31 -4
- {zoomcli-0.2.0 → zoomcli-0.2.1}/src/commands/recordings.rs +140 -32
- {zoomcli-0.2.0 → zoomcli-0.2.1}/src/commands/reports.rs +91 -6
- {zoomcli-0.2.0 → zoomcli-0.2.1}/src/commands/users.rs +122 -4
- {zoomcli-0.2.0 → zoomcli-0.2.1}/src/commands/webinars.rs +1 -4
- {zoomcli-0.2.0 → zoomcli-0.2.1}/src/config.rs +107 -33
- {zoomcli-0.2.0 → zoomcli-0.2.1}/src/main.rs +71 -1
- {zoomcli-0.2.0 → zoomcli-0.2.1}/src/output.rs +40 -2
- zoomcli-0.2.0/.gitignore +0 -1
- zoomcli-0.2.0/CHANGELOG.md +0 -18
- {zoomcli-0.2.0 → zoomcli-0.2.1}/.github/workflows/ci.yml +0 -0
- {zoomcli-0.2.0 → zoomcli-0.2.1}/.github/workflows/release.yml +0 -0
- {zoomcli-0.2.0 → zoomcli-0.2.1}/Makefile +0 -0
- {zoomcli-0.2.0 → zoomcli-0.2.1}/README.md +0 -0
- {zoomcli-0.2.0 → zoomcli-0.2.1}/pyproject.toml +0 -0
- {zoomcli-0.2.0 → zoomcli-0.2.1}/src/commands/init.rs +0 -0
- {zoomcli-0.2.0 → zoomcli-0.2.1}/src/commands/mod.rs +0 -0
- {zoomcli-0.2.0 → zoomcli-0.2.1}/src/lib.rs +0 -0
- {zoomcli-0.2.0 → zoomcli-0.2.1}/src/test_support.rs +0 -0
- {zoomcli-0.2.0 → zoomcli-0.2.1}/zoom_cli/__init__.py +0 -0
- {zoomcli-0.2.0 → zoomcli-0.2.1}/zoom_cli/__main__.py +0 -0
zoomcli-0.2.1/.gitignore
ADDED
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
# Changelog
|
|
2
|
+
|
|
3
|
+
All notable changes to this project will be documented in this file.
|
|
4
|
+
|
|
5
|
+
The format is based on [Keep a Changelog](https://keepachangelog.com/).
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
## [0.2.1](https://github.com/rvben/zoom-cli/compare/v0.2.0...v0.2.1) - 2026-04-02
|
|
9
|
+
|
|
10
|
+
### Added
|
|
11
|
+
|
|
12
|
+
- show pagination progress when fetching multiple pages ([e5e3364](https://github.com/rvben/zoom-cli/commit/e5e336472b9cb153afed1d5020d0a758b2342359))
|
|
13
|
+
- **config**: add zoom config delete command ([5841781](https://github.com/rvben/zoom-cli/commit/5841781a42e68c0c3568e9f16b06030fa19ec9ed))
|
|
14
|
+
- **reports**: add zoom reports participants command ([fcf08dc](https://github.com/rvben/zoom-cli/commit/fcf08dcf19186a3613f84af701d67114abd27ce6))
|
|
15
|
+
- **recordings**: add transcript download command ([c7b8fff](https://github.com/rvben/zoom-cli/commit/c7b8fff658e57b296d27e2632cf98e9187925ef8))
|
|
16
|
+
- **users**: add create, deactivate, and activate commands ([eb4fc29](https://github.com/rvben/zoom-cli/commit/eb4fc2900837add72d7ecc75227c6e8ae68a5517))
|
|
17
|
+
- **meetings**: add zoom meetings invite command ([051e7e6](https://github.com/rvben/zoom-cli/commit/051e7e6e4471ae01d30e3102c78ca18fe602fe17))
|
|
18
|
+
|
|
19
|
+
### Fixed
|
|
20
|
+
|
|
21
|
+
- add clickable link to missing-scope error message ([c71f3ba](https://github.com/rvben/zoom-cli/commit/c71f3bae31479e7fd3103dff05800a6b2afd1e4f))
|
|
22
|
+
- parse Zoom error JSON and give actionable guidance for scope errors ([9ffac38](https://github.com/rvben/zoom-cli/commit/9ffac3889c124684a354be1f05b3538a1837b7fe))
|
|
23
|
+
- hoist safe_topic out of per-file loop in recordings::download ([fa409c8](https://github.com/rvben/zoom-cli/commit/fa409c84f5363c1261a7c2f97b60ccb0873d3861))
|
|
24
|
+
- **config,recordings**: exit non-zero when config delete aborts non-interactively, output structured JSON from transcript ([ed6f9f7](https://github.com/rvben/zoom-cli/commit/ed6f9f73d1379d64394e1747d2c77fb04c3d6c1c))
|
|
25
|
+
|
|
26
|
+
## [0.2.0](https://github.com/rvben/zoom-cli/compare/v0.1.0...v0.2.0) - 2026-04-02
|
|
27
|
+
|
|
28
|
+
### Added
|
|
29
|
+
|
|
30
|
+
- **init**: redesign interactive setup with excellent UX ([2bc79df](https://github.com/rvben/zoom-cli/commit/2bc79dfc82e18f476e69ea7aaaa4416d3fce2c23))
|
|
31
|
+
- **config**: add zoom config show command ([dbe8490](https://github.com/rvben/zoom-cli/commit/dbe84901f3618e6c17542ae6fd8e5c52a741eb29))
|
|
32
|
+
|
|
33
|
+
### Fixed
|
|
34
|
+
|
|
35
|
+
- double-encode recording UUIDs and write downloads atomically ([f346939](https://github.com/rvben/zoom-cli/commit/f346939569336d808e5c02f7c2029c55e7273169))
|
|
36
|
+
- unreachable!() in send_with_retry was reachable, causing panics ([ceb905d](https://github.com/rvben/zoom-cli/commit/ceb905d7b12dfbc25fa8a7150daf2a5f09158375))
|
|
37
|
+
- transparent token refresh on 401, --permanent flag for recordings delete ([fdae155](https://github.com/rvben/zoom-cli/commit/fdae155e096e953c4a6634a177abb9c7ce2ab6cf))
|
|
@@ -2219,7 +2219,7 @@ checksum = "b8848ee67ecc8aedbaf3e4122217aff892639231befc6a1b58d29fff4c2cabaa"
|
|
|
2219
2219
|
|
|
2220
2220
|
[[package]]
|
|
2221
2221
|
name = "zoom-cli"
|
|
2222
|
-
version = "0.2.
|
|
2222
|
+
version = "0.2.1"
|
|
2223
2223
|
dependencies = [
|
|
2224
2224
|
"assert_cmd",
|
|
2225
2225
|
"base64",
|
|
@@ -2235,5 +2235,6 @@ dependencies = [
|
|
|
2235
2235
|
"tempfile",
|
|
2236
2236
|
"tokio",
|
|
2237
2237
|
"toml",
|
|
2238
|
+
"toml_edit",
|
|
2238
2239
|
"wiremock",
|
|
2239
2240
|
]
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
[package]
|
|
2
2
|
name = "zoom-cli"
|
|
3
|
-
version = "0.2.
|
|
3
|
+
version = "0.2.1"
|
|
4
4
|
edition = "2024"
|
|
5
5
|
rust-version = "1.90"
|
|
6
6
|
description = "Agent-friendly Zoom CLI with JSON output, structured exit codes, and schema introspection"
|
|
@@ -23,6 +23,7 @@ serde = { version = "1", features = ["derive"] }
|
|
|
23
23
|
serde_json = "1"
|
|
24
24
|
tokio = { version = "1", features = ["macros", "rt-multi-thread", "fs", "io-util", "time"] }
|
|
25
25
|
toml = "0.8"
|
|
26
|
+
toml_edit = "0.22"
|
|
26
27
|
owo-colors = "4"
|
|
27
28
|
dirs = "6"
|
|
28
29
|
base64 = "0.22"
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
class ZoomCli < Formula
|
|
2
|
+
desc "Agent-friendly Zoom CLI with JSON output, structured exit codes, and schema introspection"
|
|
3
|
+
homepage "https://github.com/rvben/zoom-cli"
|
|
4
|
+
version "0.2.0"
|
|
5
|
+
license "MIT"
|
|
6
|
+
|
|
7
|
+
on_macos do
|
|
8
|
+
on_arm do
|
|
9
|
+
url "https://github.com/rvben/zoom-cli/releases/download/v0.2.0/zoom-cli-v0.2.0-aarch64-apple-darwin.tar.gz"
|
|
10
|
+
sha256 "1646d509545baaf0dad29c136f6d63a02d8537c0b2cf42b29c64b2777cca267b"
|
|
11
|
+
end
|
|
12
|
+
on_intel do
|
|
13
|
+
url "https://github.com/rvben/zoom-cli/releases/download/v0.2.0/zoom-cli-v0.2.0-x86_64-apple-darwin.tar.gz"
|
|
14
|
+
sha256 "94f11f7a6cbb59e1fe5e1b55e8518c52ed2774a1fbbfd7ede824f84258786c18"
|
|
15
|
+
end
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
def install
|
|
19
|
+
bin.install "zoom"
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
def caveats
|
|
23
|
+
<<~EOS
|
|
24
|
+
Run `zoom init` to set up your Zoom Server-to-Server OAuth credentials.
|
|
25
|
+
Run `zoom config show` to verify your configuration.
|
|
26
|
+
EOS
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
test do
|
|
30
|
+
assert_match "zoom #{version}", shell_output("#{bin}/zoom --version")
|
|
31
|
+
end
|
|
32
|
+
end
|
|
@@ -2,6 +2,7 @@ use base64::Engine;
|
|
|
2
2
|
use base64::engine::general_purpose::STANDARD as BASE64;
|
|
3
3
|
use serde::Serialize;
|
|
4
4
|
use serde::de::DeserializeOwned;
|
|
5
|
+
use std::io::IsTerminal;
|
|
5
6
|
|
|
6
7
|
use super::ApiError;
|
|
7
8
|
use super::types::{self, *};
|
|
@@ -13,6 +14,41 @@ const ZOOM_OAUTH_BASE: &str = "https://zoom.us";
|
|
|
13
14
|
/// 4 attempts = initial + 3 retries.
|
|
14
15
|
const MAX_RETRY_ATTEMPTS: u32 = 4;
|
|
15
16
|
|
|
17
|
+
/// Extract a human-readable message from a Zoom API error body.
|
|
18
|
+
///
|
|
19
|
+
/// Zoom wraps errors as `{"code": N, "message": "..."}`. We unwrap that and,
|
|
20
|
+
/// for known patterns, add actionable guidance beyond the raw API message.
|
|
21
|
+
fn parse_zoom_error(body: &str) -> String {
|
|
22
|
+
let Ok(val) = serde_json::from_str::<serde_json::Value>(body) else {
|
|
23
|
+
return body.trim().to_owned();
|
|
24
|
+
};
|
|
25
|
+
|
|
26
|
+
let message = match val["message"].as_str() {
|
|
27
|
+
Some(m) => m.to_owned(),
|
|
28
|
+
None => return body.to_owned(),
|
|
29
|
+
};
|
|
30
|
+
|
|
31
|
+
// Code 4711: token is valid but missing a required OAuth scope.
|
|
32
|
+
// Extract the scope name and give actionable instructions.
|
|
33
|
+
if val["code"].as_u64() == Some(4711) || message.contains("does not contain scopes") {
|
|
34
|
+
let scope = message
|
|
35
|
+
.find('[')
|
|
36
|
+
.and_then(|s| message.find(']').map(|e| &message[s + 1..e]))
|
|
37
|
+
.unwrap_or("");
|
|
38
|
+
let what = if scope.is_empty() {
|
|
39
|
+
message.clone()
|
|
40
|
+
} else {
|
|
41
|
+
format!("Missing required OAuth scope: {scope}")
|
|
42
|
+
};
|
|
43
|
+
let link = crate::output::hyperlink("https://marketplace.zoom.us/user/build");
|
|
44
|
+
return format!(
|
|
45
|
+
"{what}\nAdd this scope to your app at {link}, then run `zoom init` to update credentials."
|
|
46
|
+
);
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
message
|
|
50
|
+
}
|
|
51
|
+
|
|
16
52
|
pub struct ZoomClient {
|
|
17
53
|
http: reqwest::Client,
|
|
18
54
|
base_url: String,
|
|
@@ -90,7 +126,7 @@ impl ZoomClient {
|
|
|
90
126
|
let body = resp.text().await.unwrap_or_default();
|
|
91
127
|
return Err(ApiError::Api {
|
|
92
128
|
status: status.as_u16(),
|
|
93
|
-
message: body,
|
|
129
|
+
message: parse_zoom_error(&body),
|
|
94
130
|
});
|
|
95
131
|
}
|
|
96
132
|
|
|
@@ -175,16 +211,25 @@ impl ZoomClient {
|
|
|
175
211
|
T: DeserializeOwned + types::Paginated,
|
|
176
212
|
{
|
|
177
213
|
let mut result: T = self.get_with_query(path, base_params).await?;
|
|
214
|
+
let mut page_num: u32 = 1;
|
|
178
215
|
loop {
|
|
179
216
|
let token = match result.next_page_token() {
|
|
180
217
|
Some(t) if !t.is_empty() => t.to_owned(),
|
|
181
218
|
_ => break,
|
|
182
219
|
};
|
|
220
|
+
page_num += 1;
|
|
221
|
+
if std::io::stderr().is_terminal() {
|
|
222
|
+
eprint!("\r Fetching page {page_num}...");
|
|
223
|
+
let _ = std::io::Write::flush(&mut std::io::stderr());
|
|
224
|
+
}
|
|
183
225
|
let mut params = base_params.to_vec();
|
|
184
226
|
params.push(("next_page_token", token.as_str()));
|
|
185
227
|
let next: T = self.get_with_query(path, ¶ms).await?;
|
|
186
228
|
result.append_page(next);
|
|
187
229
|
}
|
|
230
|
+
if page_num > 1 && std::io::stderr().is_terminal() {
|
|
231
|
+
eprint!("\r \r");
|
|
232
|
+
}
|
|
188
233
|
Ok(result)
|
|
189
234
|
}
|
|
190
235
|
|
|
@@ -245,18 +290,18 @@ impl ZoomClient {
|
|
|
245
290
|
200..=299 => Ok(resp.json::<T>().await?),
|
|
246
291
|
401 | 403 => {
|
|
247
292
|
let body = resp.text().await.unwrap_or_default();
|
|
248
|
-
Err(ApiError::Auth(body))
|
|
293
|
+
Err(ApiError::Auth(parse_zoom_error(&body)))
|
|
249
294
|
}
|
|
250
295
|
404 => {
|
|
251
296
|
let body = resp.text().await.unwrap_or_default();
|
|
252
|
-
Err(ApiError::NotFound(body))
|
|
297
|
+
Err(ApiError::NotFound(parse_zoom_error(&body)))
|
|
253
298
|
}
|
|
254
299
|
429 => Err(ApiError::RateLimit),
|
|
255
300
|
_ => {
|
|
256
301
|
let body = resp.text().await.unwrap_or_default();
|
|
257
302
|
Err(ApiError::Api {
|
|
258
303
|
status: status.as_u16(),
|
|
259
|
-
message: body,
|
|
304
|
+
message: parse_zoom_error(&body),
|
|
260
305
|
})
|
|
261
306
|
}
|
|
262
307
|
}
|
|
@@ -268,18 +313,18 @@ impl ZoomClient {
|
|
|
268
313
|
200..=299 => Ok(()),
|
|
269
314
|
401 | 403 => {
|
|
270
315
|
let body = resp.text().await.unwrap_or_default();
|
|
271
|
-
Err(ApiError::Auth(body))
|
|
316
|
+
Err(ApiError::Auth(parse_zoom_error(&body)))
|
|
272
317
|
}
|
|
273
318
|
404 => {
|
|
274
319
|
let body = resp.text().await.unwrap_or_default();
|
|
275
|
-
Err(ApiError::NotFound(body))
|
|
320
|
+
Err(ApiError::NotFound(parse_zoom_error(&body)))
|
|
276
321
|
}
|
|
277
322
|
429 => Err(ApiError::RateLimit),
|
|
278
323
|
_ => {
|
|
279
324
|
let body = resp.text().await.unwrap_or_default();
|
|
280
325
|
Err(ApiError::Api {
|
|
281
326
|
status: status.as_u16(),
|
|
282
|
-
message: body,
|
|
327
|
+
message: parse_zoom_error(&body),
|
|
283
328
|
})
|
|
284
329
|
}
|
|
285
330
|
}
|
|
@@ -294,10 +339,8 @@ impl ZoomClient {
|
|
|
294
339
|
) -> Result<MeetingList, ApiError> {
|
|
295
340
|
let path = format!("/users/{user_id}/meetings");
|
|
296
341
|
let mut params: Vec<(&str, &str)> = vec![("page_size", "300")];
|
|
297
|
-
let mt_owned;
|
|
298
342
|
if let Some(mt) = meeting_type {
|
|
299
|
-
|
|
300
|
-
params.push(("type", mt_owned.as_str()));
|
|
343
|
+
params.push(("type", mt));
|
|
301
344
|
}
|
|
302
345
|
self.get_all_pages(&path, ¶ms).await
|
|
303
346
|
}
|
|
@@ -306,6 +349,14 @@ impl ZoomClient {
|
|
|
306
349
|
self.get(&format!("/meetings/{meeting_id}")).await
|
|
307
350
|
}
|
|
308
351
|
|
|
352
|
+
pub async fn get_meeting_invitation(
|
|
353
|
+
&mut self,
|
|
354
|
+
meeting_id: u64,
|
|
355
|
+
) -> Result<MeetingInvitation, ApiError> {
|
|
356
|
+
self.get(&format!("/meetings/{meeting_id}/invitation"))
|
|
357
|
+
.await
|
|
358
|
+
}
|
|
359
|
+
|
|
309
360
|
pub async fn create_meeting(
|
|
310
361
|
&mut self,
|
|
311
362
|
user_id: &str,
|
|
@@ -340,10 +391,8 @@ impl ZoomClient {
|
|
|
340
391
|
|
|
341
392
|
pub async fn list_users(&mut self, status: Option<&str>) -> Result<UserList, ApiError> {
|
|
342
393
|
let mut params: Vec<(&str, &str)> = vec![("page_size", "300")];
|
|
343
|
-
let st_owned;
|
|
344
394
|
if let Some(st) = status {
|
|
345
|
-
|
|
346
|
-
params.push(("status", st_owned.as_str()));
|
|
395
|
+
params.push(("status", st));
|
|
347
396
|
}
|
|
348
397
|
self.get_all_pages("/users", ¶ms).await
|
|
349
398
|
}
|
|
@@ -352,6 +401,17 @@ impl ZoomClient {
|
|
|
352
401
|
self.get(&format!("/users/{user_id}")).await
|
|
353
402
|
}
|
|
354
403
|
|
|
404
|
+
pub async fn create_user(&mut self, req: CreateUserRequest) -> Result<User, ApiError> {
|
|
405
|
+
self.post("/users", &req).await
|
|
406
|
+
}
|
|
407
|
+
|
|
408
|
+
pub async fn set_user_status(&mut self, user_id: &str, action: &str) -> Result<(), ApiError> {
|
|
409
|
+
let req = UserStatusRequest {
|
|
410
|
+
action: action.into(),
|
|
411
|
+
};
|
|
412
|
+
self.put(&format!("/users/{user_id}/status"), &req).await
|
|
413
|
+
}
|
|
414
|
+
|
|
355
415
|
// ── Participants ─────────────────────────────────────────────────────────
|
|
356
416
|
|
|
357
417
|
pub async fn list_past_meeting_participants(
|
|
@@ -376,15 +436,11 @@ impl ZoomClient {
|
|
|
376
436
|
) -> Result<RecordingList, ApiError> {
|
|
377
437
|
let path = format!("/users/{user_id}/recordings");
|
|
378
438
|
let mut params: Vec<(&str, &str)> = vec![("page_size", "300")];
|
|
379
|
-
let from_owned;
|
|
380
|
-
let to_owned;
|
|
381
439
|
if let Some(f) = from {
|
|
382
|
-
|
|
383
|
-
params.push(("from", from_owned.as_str()));
|
|
440
|
+
params.push(("from", f));
|
|
384
441
|
}
|
|
385
442
|
if let Some(t) = to {
|
|
386
|
-
|
|
387
|
-
params.push(("to", to_owned.as_str()));
|
|
443
|
+
params.push(("to", t));
|
|
388
444
|
}
|
|
389
445
|
self.get_all_pages(&path, ¶ms).await
|
|
390
446
|
}
|
|
@@ -455,7 +511,7 @@ impl ZoomClient {
|
|
|
455
511
|
let body = resp.text().await.unwrap_or_default();
|
|
456
512
|
return Err(ApiError::Api {
|
|
457
513
|
status: status.as_u16(),
|
|
458
|
-
message: body,
|
|
514
|
+
message: parse_zoom_error(&body),
|
|
459
515
|
});
|
|
460
516
|
}
|
|
461
517
|
|
|
@@ -513,6 +569,18 @@ impl ZoomClient {
|
|
|
513
569
|
.await
|
|
514
570
|
}
|
|
515
571
|
|
|
572
|
+
pub async fn list_meeting_participant_reports(
|
|
573
|
+
&mut self,
|
|
574
|
+
meeting_id: &str,
|
|
575
|
+
) -> Result<MeetingParticipantReportList, ApiError> {
|
|
576
|
+
let encoded_id = encode_meeting_id(meeting_id);
|
|
577
|
+
self.get_all_pages(
|
|
578
|
+
&format!("/report/meetings/{encoded_id}/participants"),
|
|
579
|
+
&[("page_size", "300")],
|
|
580
|
+
)
|
|
581
|
+
.await
|
|
582
|
+
}
|
|
583
|
+
|
|
516
584
|
// ── Webinars ──────────────────────────────────────────────────────────────
|
|
517
585
|
|
|
518
586
|
pub async fn list_webinars(&mut self, user_id: &str) -> Result<WebinarList, ApiError> {
|
|
@@ -561,6 +629,34 @@ mod tests {
|
|
|
561
629
|
use wiremock::matchers::{header, method, path, query_param};
|
|
562
630
|
use wiremock::{Mock, MockServer, ResponseTemplate};
|
|
563
631
|
|
|
632
|
+
#[test]
|
|
633
|
+
fn parse_zoom_error_extracts_message_from_json() {
|
|
634
|
+
assert_eq!(
|
|
635
|
+
parse_zoom_error(r#"{"code":3001,"message":"Meeting does not exist"}"#),
|
|
636
|
+
"Meeting does not exist"
|
|
637
|
+
);
|
|
638
|
+
}
|
|
639
|
+
|
|
640
|
+
#[test]
|
|
641
|
+
fn parse_zoom_error_scope_4711_gives_actionable_message() {
|
|
642
|
+
let body = r#"{"code":4711,"message":"Invalid access token, does not contain scopes:[report:read:user:admin]."}"#;
|
|
643
|
+
let msg = parse_zoom_error(body);
|
|
644
|
+
assert!(
|
|
645
|
+
msg.contains("report:read:user:admin"),
|
|
646
|
+
"must name the missing scope"
|
|
647
|
+
);
|
|
648
|
+
assert!(msg.contains("zoom init"), "must tell user how to fix it");
|
|
649
|
+
assert!(
|
|
650
|
+
!msg.contains("Invalid access token"),
|
|
651
|
+
"must not echo the raw API message"
|
|
652
|
+
);
|
|
653
|
+
}
|
|
654
|
+
|
|
655
|
+
#[test]
|
|
656
|
+
fn parse_zoom_error_falls_back_to_raw_body_for_non_json() {
|
|
657
|
+
assert_eq!(parse_zoom_error("plain text error"), "plain text error");
|
|
658
|
+
}
|
|
659
|
+
|
|
564
660
|
async fn mock_client(server: &MockServer) -> ZoomClient {
|
|
565
661
|
ZoomClient::new_for_test(
|
|
566
662
|
format!("{}/v2", server.uri()),
|
|
@@ -115,6 +115,18 @@ mod tests {
|
|
|
115
115
|
assert!(msg.contains("Invalid parameter: duration"));
|
|
116
116
|
}
|
|
117
117
|
|
|
118
|
+
#[test]
|
|
119
|
+
fn api_error_scope_message_is_actionable() {
|
|
120
|
+
// Simulates what parse_zoom_error produces for a code-4711 response.
|
|
121
|
+
let err = ApiError::Api {
|
|
122
|
+
status: 400,
|
|
123
|
+
message: "Missing required OAuth scope: report:read:user:admin\nAdd this scope to your Zoom Server-to-Server OAuth app, then run `zoom init` to update credentials.".into(),
|
|
124
|
+
};
|
|
125
|
+
let msg = err.to_string();
|
|
126
|
+
assert!(msg.contains("report:read:user:admin"));
|
|
127
|
+
assert!(msg.contains("zoom init"), "must tell user how to fix it");
|
|
128
|
+
}
|
|
129
|
+
|
|
118
130
|
#[test]
|
|
119
131
|
fn other_error_display_is_verbatim() {
|
|
120
132
|
let err = ApiError::Other("unexpected failure".into());
|
|
@@ -112,6 +112,30 @@ pub struct UserList {
|
|
|
112
112
|
pub page_size: Option<u32>,
|
|
113
113
|
}
|
|
114
114
|
|
|
115
|
+
// ── User management ──────────────────────────────────────────────────────────
|
|
116
|
+
|
|
117
|
+
#[derive(Debug, Serialize)]
|
|
118
|
+
pub struct CreateUserInfo {
|
|
119
|
+
pub email: String,
|
|
120
|
+
#[serde(rename = "type")]
|
|
121
|
+
pub user_type: u8,
|
|
122
|
+
#[serde(skip_serializing_if = "Option::is_none")]
|
|
123
|
+
pub first_name: Option<String>,
|
|
124
|
+
#[serde(skip_serializing_if = "Option::is_none")]
|
|
125
|
+
pub last_name: Option<String>,
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
#[derive(Debug, Serialize)]
|
|
129
|
+
pub struct CreateUserRequest {
|
|
130
|
+
pub action: String,
|
|
131
|
+
pub user_info: CreateUserInfo,
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
#[derive(Debug, Serialize)]
|
|
135
|
+
pub struct UserStatusRequest {
|
|
136
|
+
pub action: String,
|
|
137
|
+
}
|
|
138
|
+
|
|
115
139
|
// ── Recording ─────────────────────────────────────────────────────────────────
|
|
116
140
|
|
|
117
141
|
#[derive(Debug, Clone, Deserialize, Serialize)]
|
|
@@ -136,6 +160,8 @@ pub struct RecordingFile {
|
|
|
136
160
|
#[serde(skip_serializing_if = "Option::is_none")]
|
|
137
161
|
pub file_type: Option<String>,
|
|
138
162
|
#[serde(skip_serializing_if = "Option::is_none")]
|
|
163
|
+
pub file_extension: Option<String>,
|
|
164
|
+
#[serde(skip_serializing_if = "Option::is_none")]
|
|
139
165
|
pub file_size: Option<u64>,
|
|
140
166
|
#[serde(skip_serializing_if = "Option::is_none")]
|
|
141
167
|
pub play_url: Option<String>,
|
|
@@ -180,6 +206,13 @@ pub struct MeetingStatusRequest {
|
|
|
180
206
|
pub action: String,
|
|
181
207
|
}
|
|
182
208
|
|
|
209
|
+
// ── Meeting invitation ────────────────────────────────────────────────────────
|
|
210
|
+
|
|
211
|
+
#[derive(Debug, Clone, Deserialize, Serialize)]
|
|
212
|
+
pub struct MeetingInvitation {
|
|
213
|
+
pub invitation: String,
|
|
214
|
+
}
|
|
215
|
+
|
|
183
216
|
// ── Participants ──────────────────────────────────────────────────────────────
|
|
184
217
|
|
|
185
218
|
#[derive(Debug, Clone, Deserialize, Serialize)]
|
|
@@ -251,6 +284,38 @@ pub struct UserMeetingReportList {
|
|
|
251
284
|
pub page_size: Option<u32>,
|
|
252
285
|
}
|
|
253
286
|
|
|
287
|
+
// ── Meeting participant report ────────────────────────────────────────────────
|
|
288
|
+
|
|
289
|
+
#[derive(Debug, Clone, Deserialize, Serialize)]
|
|
290
|
+
pub struct MeetingParticipantReport {
|
|
291
|
+
#[serde(skip_serializing_if = "Option::is_none")]
|
|
292
|
+
pub id: Option<String>,
|
|
293
|
+
#[serde(skip_serializing_if = "Option::is_none")]
|
|
294
|
+
pub user_id: Option<String>,
|
|
295
|
+
#[serde(skip_serializing_if = "Option::is_none")]
|
|
296
|
+
pub name: Option<String>,
|
|
297
|
+
#[serde(skip_serializing_if = "Option::is_none")]
|
|
298
|
+
pub user_email: Option<String>,
|
|
299
|
+
#[serde(skip_serializing_if = "Option::is_none")]
|
|
300
|
+
pub join_time: Option<String>,
|
|
301
|
+
#[serde(skip_serializing_if = "Option::is_none")]
|
|
302
|
+
pub leave_time: Option<String>,
|
|
303
|
+
/// Participation duration in seconds.
|
|
304
|
+
#[serde(skip_serializing_if = "Option::is_none")]
|
|
305
|
+
pub duration: Option<u64>,
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
#[derive(Debug, Deserialize, Serialize)]
|
|
309
|
+
pub struct MeetingParticipantReportList {
|
|
310
|
+
pub participants: Vec<MeetingParticipantReport>,
|
|
311
|
+
#[serde(skip_serializing_if = "is_none_or_empty")]
|
|
312
|
+
pub next_page_token: Option<String>,
|
|
313
|
+
#[serde(skip_serializing_if = "Option::is_none")]
|
|
314
|
+
pub total_records: Option<u64>,
|
|
315
|
+
#[serde(skip_serializing)]
|
|
316
|
+
pub page_size: Option<u32>,
|
|
317
|
+
}
|
|
318
|
+
|
|
254
319
|
// ── Webinar ───────────────────────────────────────────────────────────────────
|
|
255
320
|
|
|
256
321
|
#[derive(Debug, Clone, Deserialize, Serialize)]
|
|
@@ -344,6 +409,16 @@ impl Paginated for UserMeetingReportList {
|
|
|
344
409
|
}
|
|
345
410
|
}
|
|
346
411
|
|
|
412
|
+
impl Paginated for MeetingParticipantReportList {
|
|
413
|
+
fn next_page_token(&self) -> Option<&str> {
|
|
414
|
+
self.next_page_token.as_deref().filter(|s| !s.is_empty())
|
|
415
|
+
}
|
|
416
|
+
fn append_page(&mut self, next: Self) {
|
|
417
|
+
self.participants.extend(next.participants);
|
|
418
|
+
self.next_page_token = next.next_page_token.filter(|t| !t.is_empty());
|
|
419
|
+
}
|
|
420
|
+
}
|
|
421
|
+
|
|
347
422
|
impl Paginated for WebinarList {
|
|
348
423
|
fn next_page_token(&self) -> Option<&str> {
|
|
349
424
|
self.next_page_token.as_deref().filter(|s| !s.is_empty())
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
use crate::api::ApiError;
|
|
1
2
|
use crate::config;
|
|
2
3
|
use crate::output::{self, OutputConfig};
|
|
3
4
|
|
|
@@ -87,6 +88,32 @@ pub fn show(profile_arg: Option<&str>, out: &OutputConfig) {
|
|
|
87
88
|
}
|
|
88
89
|
}
|
|
89
90
|
|
|
91
|
+
pub fn delete(profile_name: &str, force: bool, out: &OutputConfig) -> Result<(), ApiError> {
|
|
92
|
+
let config_path = config::config_path();
|
|
93
|
+
|
|
94
|
+
if !force {
|
|
95
|
+
eprint!("Delete profile '{profile_name}'? [y/N] ");
|
|
96
|
+
use std::io::{BufRead, IsTerminal};
|
|
97
|
+
if !std::io::stdin().is_terminal() {
|
|
98
|
+
eprintln!();
|
|
99
|
+
eprintln!("Use --force to delete non-interactively.");
|
|
100
|
+
return Err(ApiError::Other(
|
|
101
|
+
"stdin is not a terminal; use --force to delete non-interactively".into(),
|
|
102
|
+
));
|
|
103
|
+
}
|
|
104
|
+
let mut input = String::new();
|
|
105
|
+
std::io::stdin().lock().read_line(&mut input).unwrap_or(0);
|
|
106
|
+
if input.trim().to_lowercase() != "y" {
|
|
107
|
+
out.print_message("Aborted.");
|
|
108
|
+
return Ok(());
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
config::delete_profile(&config_path, profile_name)?;
|
|
113
|
+
out.print_message(&format!("Profile '{profile_name}' deleted."));
|
|
114
|
+
Ok(())
|
|
115
|
+
}
|
|
116
|
+
|
|
90
117
|
fn masked_or_unset(value: &Option<String>) -> String {
|
|
91
118
|
match value {
|
|
92
119
|
Some(v) => output::mask_credential(v),
|
|
@@ -236,4 +263,52 @@ client_secret = "work-csec"
|
|
|
236
263
|
);
|
|
237
264
|
assert_eq!(masked_or_unset(&None), "(not set)");
|
|
238
265
|
}
|
|
266
|
+
|
|
267
|
+
#[test]
|
|
268
|
+
fn delete_removes_profile_from_config() {
|
|
269
|
+
let _lock = ProcessEnvLock::acquire().unwrap();
|
|
270
|
+
let dir = TempDir::new().unwrap();
|
|
271
|
+
write_config(
|
|
272
|
+
dir.path(),
|
|
273
|
+
r#"
|
|
274
|
+
[default]
|
|
275
|
+
account_id = "def-acct"
|
|
276
|
+
client_id = "def-cid"
|
|
277
|
+
client_secret = "def-csec"
|
|
278
|
+
|
|
279
|
+
[work]
|
|
280
|
+
account_id = "work-acct"
|
|
281
|
+
client_id = "work-cid"
|
|
282
|
+
client_secret = "work-csec"
|
|
283
|
+
"#,
|
|
284
|
+
)
|
|
285
|
+
.unwrap();
|
|
286
|
+
|
|
287
|
+
let _cfg_dir = set_config_dir_env(dir.path());
|
|
288
|
+
let _env_acct = EnvVarGuard::unset("ZOOM_ACCOUNT_ID");
|
|
289
|
+
let _env_cid = EnvVarGuard::unset("ZOOM_CLIENT_ID");
|
|
290
|
+
let _env_csec = EnvVarGuard::unset("ZOOM_CLIENT_SECRET");
|
|
291
|
+
let _env_prof = EnvVarGuard::unset("ZOOM_PROFILE");
|
|
292
|
+
let path = dir.path().join("zoom-cli").join("config.toml");
|
|
293
|
+
config::delete_profile(&path, "work").unwrap();
|
|
294
|
+
|
|
295
|
+
let summary = config::load_for_show(None);
|
|
296
|
+
assert_eq!(summary.profiles.len(), 1);
|
|
297
|
+
assert_eq!(summary.profiles[0].name, "default");
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
#[test]
|
|
301
|
+
fn delete_returns_not_found_for_missing_profile() {
|
|
302
|
+
let _lock = ProcessEnvLock::acquire().unwrap();
|
|
303
|
+
let dir = TempDir::new().unwrap();
|
|
304
|
+
write_config(
|
|
305
|
+
dir.path(),
|
|
306
|
+
"[default]\naccount_id=\"x\"\nclient_id=\"y\"\nclient_secret=\"z\"\n",
|
|
307
|
+
)
|
|
308
|
+
.unwrap();
|
|
309
|
+
|
|
310
|
+
let path = dir.path().join("zoom-cli").join("config.toml");
|
|
311
|
+
let err = config::delete_profile(&path, "nonexistent").unwrap_err();
|
|
312
|
+
assert!(matches!(err, crate::api::ApiError::NotFound(_)));
|
|
313
|
+
}
|
|
239
314
|
}
|
|
@@ -243,6 +243,20 @@ pub async fn participants(
|
|
|
243
243
|
Ok(())
|
|
244
244
|
}
|
|
245
245
|
|
|
246
|
+
pub async fn invite(
|
|
247
|
+
client: &mut ZoomClient,
|
|
248
|
+
out: &OutputConfig,
|
|
249
|
+
meeting_id: u64,
|
|
250
|
+
) -> Result<(), ApiError> {
|
|
251
|
+
let inv = client.get_meeting_invitation(meeting_id).await?;
|
|
252
|
+
if out.json {
|
|
253
|
+
out.print_data(&serde_json::to_string_pretty(&inv).expect("serialize"));
|
|
254
|
+
} else {
|
|
255
|
+
out.print_data(&inv.invitation);
|
|
256
|
+
}
|
|
257
|
+
Ok(())
|
|
258
|
+
}
|
|
259
|
+
|
|
246
260
|
#[cfg(test)]
|
|
247
261
|
mod tests {
|
|
248
262
|
use super::*;
|
|
@@ -251,10 +265,7 @@ mod tests {
|
|
|
251
265
|
use wiremock::{Mock, MockServer, ResponseTemplate};
|
|
252
266
|
|
|
253
267
|
fn test_out() -> OutputConfig {
|
|
254
|
-
OutputConfig
|
|
255
|
-
json: true,
|
|
256
|
-
quiet: true,
|
|
257
|
-
}
|
|
268
|
+
OutputConfig::for_test()
|
|
258
269
|
}
|
|
259
270
|
|
|
260
271
|
#[test]
|
|
@@ -403,6 +414,22 @@ mod tests {
|
|
|
403
414
|
end(&mut client, &test_out(), 555666777).await.unwrap();
|
|
404
415
|
}
|
|
405
416
|
|
|
417
|
+
#[tokio::test]
|
|
418
|
+
async fn meetings_invite_returns_invitation_text() {
|
|
419
|
+
let server = MockServer::start().await;
|
|
420
|
+
Mock::given(method("GET"))
|
|
421
|
+
.and(path("/v2/meetings/123456789/invitation"))
|
|
422
|
+
.respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
|
|
423
|
+
"invitation": "Join Zoom Meeting\nhttps://zoom.us/j/123456789"
|
|
424
|
+
})))
|
|
425
|
+
.mount(&server)
|
|
426
|
+
.await;
|
|
427
|
+
|
|
428
|
+
let mut client =
|
|
429
|
+
ZoomClient::new_for_test(format!("{}/v2", server.uri()), server.uri(), "tok".into());
|
|
430
|
+
invite(&mut client, &test_out(), 123456789).await.unwrap();
|
|
431
|
+
}
|
|
432
|
+
|
|
406
433
|
#[tokio::test]
|
|
407
434
|
async fn meetings_participants_returns_table_data() {
|
|
408
435
|
let server = MockServer::start().await;
|