Skip to content
Merged
Show file tree
Hide file tree
Changes from 9 commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/issue-461-calendar-meet.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@googleworkspace/cli": minor
---

feat: support google meet video conferencing in calendar +insert
8 changes: 8 additions & 0 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -63,6 +63,7 @@ zeroize = { version = "1.8.2", features = ["derive"] }
tracing = "0.1"
tracing-subscriber = { version = "0.3", features = ["env-filter", "json"] }
tracing-appender = "0.2"
uuid = { version = "1.22.0", features = ["v4", "v5"] }

[target.'cfg(target_os = "macos")'.dependencies]
keyring = { version = "3.6.3", features = ["apple-native"] }
Expand Down
138 changes: 133 additions & 5 deletions src/helpers/calendar.rs
Original file line number Diff line number Diff line change
Expand Up @@ -80,14 +80,21 @@ impl Helper for CalendarHelper {
.value_name("EMAIL")
.action(ArgAction::Append),
)
.arg(
Arg::new("meet")
.long("meet")
.help("Add a Google Meet video conference link")
.action(ArgAction::SetTrue),
)
.after_help("\
EXAMPLES:
gws calendar +insert --summary 'Standup' --start '2026-06-17T09:00:00-07:00' --end '2026-06-17T09:30:00-07:00'
gws calendar +insert --summary 'Review' --start ... --end ... --attendee alice@example.com
gws calendar +insert --summary 'Meet' --start ... --end ... --meet

TIPS:
Use RFC3339 format for times (e.g. 2026-06-17T09:00:00-07:00).
For recurring events or conference links, use the raw API instead."),
The --meet flag automatically adds a Google Meet link to the event."),
);
cmd = cmd.subcommand(
Command::new("+agenda")
Expand Down Expand Up @@ -453,13 +460,44 @@ fn build_insert_request(
body["attendees"] = json!(attendees_list);
}

let mut params = json!({
"calendarId": calendar_id
});

if matches.get_flag("meet") {
let namespace = uuid::Uuid::NAMESPACE_DNS;

let mut attendees: Vec<_> = matches
.get_many::<String>("attendee")
.map(|vals| vals.cloned().collect())
.unwrap_or_default();
attendees.sort();

let seed_payload = json!({
"v": 1,
"summary": summary,
"start": start,
"end": end,
"location": location,
"description": description,
"attendees": attendees,
});

let seed_data = serde_json::to_vec(&seed_payload).unwrap_or_default();
let request_id = uuid::Uuid::new_v5(&namespace, &seed_data).to_string();

body["conferenceData"] = json!({
"createRequest": {
"requestId": request_id,
"conferenceSolutionKey": { "type": "hangoutsMeet" }
}
});
params["conferenceDataVersion"] = json!(1);
}
let body_str = body.to_string();
let scopes: Vec<String> = insert_method.scopes.iter().map(|s| s.to_string()).collect();

// events.insert requires 'calendarId' path parameter
let params = json!({
"calendarId": calendar_id
});
let params_str = params.to_string();

Ok((params_str, body_str, scopes))
Expand Down Expand Up @@ -497,7 +535,8 @@ mod tests {
Arg::new("attendee")
.long("attendee")
.action(ArgAction::Append),
);
)
.arg(Arg::new("meet").long("meet").action(ArgAction::SetTrue));
cmd.try_get_matches_from(args).unwrap()
}

Expand All @@ -521,6 +560,95 @@ mod tests {
assert_eq!(scopes[0], "https://scope");
}

#[test]
fn test_build_insert_request_with_meet() {
let doc = make_mock_doc();
let matches = make_matches_insert(&[
"test",
"--summary",
"Meeting",
"--start",
"2024-01-01T10:00:00Z",
"--end",
"2024-01-01T11:00:00Z",
"--meet",
]);
let (params, body, _) = build_insert_request(&matches, &doc).unwrap();

let params_json: serde_json::Value = serde_json::from_str(&params).unwrap();
assert_eq!(params_json["conferenceDataVersion"], 1);

let body_json: serde_json::Value = serde_json::from_str(&body).unwrap();
let create_req = &body_json["conferenceData"]["createRequest"];
assert_eq!(create_req["conferenceSolutionKey"]["type"], "hangoutsMeet");
assert!(uuid::Uuid::parse_str(create_req["requestId"].as_str().unwrap()).is_ok());
}

#[test]
fn test_build_insert_request_with_meet_is_idempotent() {
let doc = make_mock_doc();
let args = &[
"test",
"--summary",
"Idempotent Meeting",
"--start",
"2024-01-01T10:00:00Z",
"--end",
"2024-01-01T11:00:00Z",
"--meet",
];
let matches1 = make_matches_insert(args);
let (_, body1, _) = build_insert_request(&matches1, &doc).unwrap();

let matches2 = make_matches_insert(args);
let (_, body2, _) = build_insert_request(&matches2, &doc).unwrap();

let b1: serde_json::Value = serde_json::from_str(&body1).unwrap();
let b2: serde_json::Value = serde_json::from_str(&body2).unwrap();

assert_eq!(
b1["conferenceData"]["createRequest"]["requestId"],
b2["conferenceData"]["createRequest"]["requestId"],
"requestId should be deterministic for the same event details"
);
}

#[test]
fn test_build_insert_request_with_meet_idempotency_robust() {
let doc = make_mock_doc();

// Base case
let args_base = &[
"test", "--summary", "S", "--start", "2024-01-01T10:00:00Z", "--end", "2024-01-01T11:00:00Z",
"--meet", "--attendee", "a@b.com", "--attendee", "c@d.com"
];
let (_, body_base, _) = build_insert_request(&make_matches_insert(args_base), &doc).unwrap();
let b_base: serde_json::Value = serde_json::from_str(&body_base).unwrap();
let id_base = b_base["conferenceData"]["createRequest"]["requestId"].as_str().unwrap();

// Same but different attendee order
let args_reordered = &[
"test", "--summary", "S", "--start", "2024-01-01T10:00:00Z", "--end", "2024-01-01T11:00:00Z",
"--meet", "--attendee", "c@d.com", "--attendee", "a@b.com"
];
let (_, body_reordered, _) = build_insert_request(&make_matches_insert(args_reordered), &doc).unwrap();
let b_reordered: serde_json::Value = serde_json::from_str(&body_reordered).unwrap();
let id_reordered = b_reordered["conferenceData"]["createRequest"]["requestId"].as_str().unwrap();

assert_eq!(id_base, id_reordered, "Attendee order should not change requestId");

// Different summary -> different ID
let args_diff = &[
"test", "--summary", "Diff", "--start", "2024-01-01T10:00:00Z", "--end", "2024-01-01T11:00:00Z",
"--meet", "--attendee", "a@b.com", "--attendee", "c@d.com"
];
let (_, body_diff, _) = build_insert_request(&make_matches_insert(args_diff), &doc).unwrap();
let b_diff: serde_json::Value = serde_json::from_str(&body_diff).unwrap();
let id_diff = b_diff["conferenceData"]["createRequest"]["requestId"].as_str().unwrap();

assert_ne!(id_base, id_diff, "Different summary should produce different requestId");
}

#[test]
fn test_build_insert_request_with_optional_fields() {
let doc = make_mock_doc();
Expand Down
Loading