diff --git a/hive-c0re/src/matrix.rs b/hive-c0re/src/matrix.rs index 0205c1e4..a931e2c3 100644 --- a/hive-c0re/src/matrix.rs +++ b/hive-c0re/src/matrix.rs @@ -1353,9 +1353,41 @@ async fn invite_to_room( invite_user_id(client, admin_token, room_id, &user_id).await } +/// Fetch a user's current membership in a room via the admin token, or +/// `None` if there is no membership event (never invited) or the lookup +/// fails. Returns the raw membership string (`invite`, `join`, `leave`, …). +async fn room_membership( + client: &reqwest::Client, + admin_token: &str, + encoded_room_id: &str, + user_id: &str, +) -> Option { + // `:` must be percent-encoded in both the room-id and user-id path + // segments; `@` and `!` are permitted path characters per RFC 3986. + let encoded_user = user_id.replace(':', "%3A"); + let url = format!( + "{MATRIX_HTTP}/_matrix/client/v3/rooms/{encoded_room_id}/state/m.room.member/{encoded_user}" + ); + let resp = client + .get(&url) + .bearer_auth(admin_token) + .send() + .await + .ok()?; + if !resp.status().is_success() { + // 404 = no membership event yet; anything else we treat as "unknown" + // and let the caller fall through to the invite attempt. + return None; + } + let body = resp.json::().await.ok()?; + body["membership"].as_str().map(ToOwned::to_owned) +} + /// Invite a fully-qualified Matrix user id (`@user:server`) to `room_id` -/// using the admin token. Idempotent: a 403 `M_FORBIDDEN` / `M_BAD_STATE` -/// (already a member or pending invite) is treated as success. +/// using the admin token. Idempotent: a user who is already a member or +/// already has a pending invite is left untouched (no fresh invite is sent, +/// so they are not re-notified), and a 403 `M_FORBIDDEN` / `M_BAD_STATE` +/// from a racing invite is still treated as success. async fn invite_user_id( client: &reqwest::Client, admin_token: &str, @@ -1365,6 +1397,17 @@ async fn invite_user_id( // `:` must be percent-encoded in the room-id path segment; `!` is // permitted in URL path characters per RFC 3986. let encoded_room_id = room_id.replace(':', "%3A"); + + // Skip the invite entirely when the user is already invited or joined. + // Re-POSTing an invite to a pending member re-sends the invite event, + // which re-notifies the agent on every provisioning sweep. + if let Some(membership) = room_membership(client, admin_token, &encoded_room_id, user_id).await + && matches!(membership.as_str(), "invite" | "join") + { + tracing::debug!(%user_id, %room_id, %membership, "matrix: invite skipped (already a member/invited)"); + return Ok(()); + } + let url = format!("{MATRIX_HTTP}/_matrix/client/v3/rooms/{encoded_room_id}/invite"); let resp = client .post(&url)