Skip to content
Merged
Show file tree
Hide file tree
Changes from all 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
2 changes: 1 addition & 1 deletion AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -124,7 +124,7 @@ The CLI currently covers:
- `channel`: list/get/create/update/delete/member operations.
- `message`: send/list/get/reply/update/delete/react/unreact/pin/unpin.
- `chat`: list/get/create/update/hide/unhide/member operations.
- `presence`: get, batch get, set, clear, status message.
- `presence`: get, batch get, set, clear, preferred set/clear, status message.
- `search`: messages, users, teams.
- `tag`: list/get/create/update/delete/member operations.
- `meeting`: list/get/create/update/delete/join URL/attendance.
Expand Down
5 changes: 5 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,11 @@
- `teams message send --subject TEXT` sets the subject line on a channel root message — the bold title Teams renders above the body, the same field the client offers behind "Add a subject". Channel sends only: chat messages have no subject, so `--subject` with `--chat` (or without `--channel`) is rejected as invalid input before anything is sent.
- `teams message list --team T --channel C --message-id ROOT` lists the replies under one channel thread root, paged like any other listing. Without it a caller could not tell from the existing channel-list output whether a question had already been answered, because that listing returns thread roots only.
- `teams message reply --mention USER` (repeatable) tags a person in a threaded reply the same way `message send --mention` does, so a name in a reply notifies rather than merely appears.
- `teams presence set-preferred --availability A [--expiration D]` and `teams presence clear-preferred` manage the user-preferred presence, the layer Microsoft Graph ranks above every presence session while one exists. The Teams client's "Appear offline" lives there as `Offline`/`OffWork`, and until now nothing in the CLI could reach it: `presence set` returned success and the account stayed offline. Each of the six availabilities Graph accepts here has exactly one activity, so the command derives it and reports both back. The expiration is checked as a positive ISO 8601 duration in whole day, hour, minute and second units but not bounded, because Graph documents defaults of one day for `Busy` and `DoNotDisturb` and seven days otherwise, rather than a range.

### Changed

- Refreshed two Rust dependencies: `uuid` 1.24.1 → 1.26.0 (the weekly rust-minor group) and `rand` 0.9.2 → 0.9.3, which clears RUSTSEC-2026-0097 (`rand::rng()` unsound with a custom logger; reached only through `reqwest`'s QUIC dependency) from `cargo audit`.

### Fixed

Expand Down
2 changes: 1 addition & 1 deletion CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,7 @@ Single-crate Rust binary. Key modules:
- `channel.rs` — list, get, create, update, delete, members
- `message.rs` — send, list, get, reply, delete, react, unreact, pin, unpin
- `chat.rs` — list, get, create, hide, unhide, members
- `presence.rs` — get, set, clear, status, get-batch
- `presence.rs` — get, set, set-preferred, clear, clear-preferred, status, get-batch
- `search.rs` — messages, users, teams
- `tag.rs` — list, get, create, delete, add-member, remove-member
- `meeting.rs` — list, get, create, delete, join-url, attendance
Expand Down
4 changes: 2 additions & 2 deletions Cargo.lock

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

18 changes: 18 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -460,6 +460,9 @@ teams presence get-batch --user-ids <id1>,<id2>
teams presence set --availability Available --activity Available
teams presence set --availability Busy --activity InACall --expiration PT1H
teams presence clear
teams presence set-preferred --availability Offline # "Appear offline"
teams presence set-preferred --availability Busy --expiration PT8H
teams presence clear-preferred
teams presence status --message "In deep focus" [--expiry <datetime>]
```

Expand All @@ -479,6 +482,21 @@ Graph accepts five `--availability`/`--activity` pairs: `Available`/`Available`,
applies Graph's own five-minute default, so a presence set this way lapses on
its own either way.

A preferred presence is a second layer above the sessions. Graph ranks it over
every session's state for as long as at least one session exists, which is how
the Teams client's "Appear offline" works: it is the preferred pair
`Offline`/`OffWork`, and it hides whatever `set` or a signed-in client reports
underneath. `set-preferred` takes one of the six availabilities Graph accepts,
`Available`, `Busy`, `DoNotDisturb`, `BeRightBack`, `Away` or `Offline`, and
sends the one activity Graph pairs with it, reporting both back. Its
`--expiration` is a positive ISO 8601 duration written in whole day, hour,
minute and second units, `P1D` included; left out,
Graph applies one day for `Busy` and `DoNotDisturb` and seven days for the
rest. `clear-preferred` removes the override so the sessions show through
again. With no session at all the user reads `Offline` whatever the preferred
presence says, so an account that is never signed into a Teams client needs
`set` as well as `set-preferred` to appear available.

### Search

```bash
Expand Down
2 changes: 1 addition & 1 deletion docs/auth.md
Original file line number Diff line number Diff line change
Expand Up @@ -88,7 +88,7 @@ Presence.Read.All
Presence.ReadWrite
```

These permissions cover the current chat read/write, channel-send, team/channel discovery, user lookup, and presence reads and writes. `Presence.ReadWrite` is what `presence set`, `presence status` and `presence clear` require; Microsoft does not mark it admin-consent required. The default does not include `ChannelMessage.Read.All` because Microsoft marks that delegated Graph scope as admin-consent required. Add it explicitly when a workflow needs channel message reads:
These permissions cover the current chat read/write, channel-send, team/channel discovery, user lookup, and presence reads and writes. `Presence.ReadWrite` is what `presence set`, `presence set-preferred`, `presence status`, `presence clear` and `presence clear-preferred` require; Microsoft does not mark it admin-consent required. The default does not include `ChannelMessage.Read.All` because Microsoft marks that delegated Graph scope as admin-consent required. Add it explicitly when a workflow needs channel message reads:

```bash
teams auth login --device-code --scopes "User.Read ChannelMessage.Read.All offline_access"
Expand Down
2 changes: 2 additions & 0 deletions docs/command-reference.md
Original file line number Diff line number Diff line change
Expand Up @@ -188,8 +188,10 @@ teams presence get --user USER_ID
teams presence get --users USER_ID,USER_ID
teams presence get-batch --user-ids USER_ID,USER_ID
teams presence set --availability AVAILABILITY --activity ACTIVITY [--expiration PT5M..PT4H]
teams presence set-preferred --availability Available|Busy|DoNotDisturb|BeRightBack|Away|Offline [--expiration WHOLE_UNIT_ISO8601_DURATION]
teams presence status --message TEXT [--expiry ISO8601_DATETIME]
teams presence clear
teams presence clear-preferred
```

## Search
Expand Down
2 changes: 2 additions & 0 deletions docs/man/teams.1
Original file line number Diff line number Diff line change
Expand Up @@ -243,8 +243,10 @@ teams presence get --users USER_ID,USER_ID
teams presence get --user-ids USER_ID,USER_ID
teams presence get-batch --user-ids USER_ID,USER_ID
teams presence set --availability AVAILABILITY --activity ACTIVITY [--expiration PT5M..PT4H]
teams presence set-preferred --availability Available|Busy|DoNotDisturb|BeRightBack|Away|Offline [--expiration WHOLE_UNIT_ISO8601_DURATION]
teams presence status --message TEXT [--expiry ISO8601_DATETIME]
teams presence clear
teams presence clear-preferred
.fi
.SH SEARCH COMMANDS
.nf
Expand Down
6 changes: 6 additions & 0 deletions docs/teams-cli-prd.md
Original file line number Diff line number Diff line change
Expand Up @@ -440,6 +440,12 @@ teams presence set --availability <Available|Busy|DoNotDisturb|Away|BeRightBack|
--activity <Available|InACall|InAMeeting|Presenting|...> \
[--expiration <duration, e.g. 1h, 30m>]

# Set or clear the preferred presence that overrides presence sessions
teams presence set-preferred \
--availability <Available|Busy|DoNotDisturb|BeRightBack|Away|Offline> \
[--expiration <positive whole-unit ISO 8601 duration>]
teams presence clear-preferred

# Set status message
teams presence status --message "In deep focus until 3pm" \
[--expiry <datetime>]
Expand Down
20 changes: 20 additions & 0 deletions src/api/endpoints.rs
Original file line number Diff line number Diff line change
Expand Up @@ -188,6 +188,14 @@ pub fn set_status_message() -> String {
format!("{GRAPH_V1}/me/presence/setStatusMessage")
}

pub fn set_user_preferred_presence() -> String {
format!("{GRAPH_V1}/me/presence/setUserPreferredPresence")
}

pub fn clear_user_preferred_presence() -> String {
format!("{GRAPH_V1}/me/presence/clearUserPreferredPresence")
}

// --- Search ---
pub fn search_query() -> String {
format!("{GRAPH_V1}/search/query")
Expand Down Expand Up @@ -411,6 +419,18 @@ pub fn shares_drive_item_content(sharing_token: &str) -> String {
mod tests {
use super::*;

#[test]
fn preferred_presence_write_urls_target_the_signed_in_user() {
assert_eq!(
set_user_preferred_presence(),
"https://graph.microsoft.com/v1.0/me/presence/setUserPreferredPresence"
);
assert_eq!(
clear_user_preferred_presence(),
"https://graph.microsoft.com/v1.0/me/presence/clearUserPreferredPresence"
);
}

#[test]
fn sharing_url_token_matches_graph_convention() {
// Expected value computed with: printf '%s' "$URL" | base64 | tr '+/' '-_' | tr -d '='
Expand Down
78 changes: 76 additions & 2 deletions src/api/presence.rs
Original file line number Diff line number Diff line change
@@ -1,8 +1,8 @@
use crate::error::{Result, TeamsError};
use crate::models::common::PageResponse;
use crate::models::presence::{
ClearPresenceRequest, GetPresenceBatchRequest, Presence, SetPresenceRequest,
SetStatusMessageRequest,
ClearPresenceRequest, ClearUserPreferredPresenceRequest, GetPresenceBatchRequest, Presence,
SetPresenceRequest, SetStatusMessageRequest, SetUserPreferredPresenceRequest,
};

use super::client::GraphClient;
Expand Down Expand Up @@ -63,6 +63,31 @@ pub async fn set_status_message(client: &GraphClient, req: &SetStatusMessageRequ
.await
}

pub async fn set_user_preferred_presence(
client: &GraphClient,
req: &SetUserPreferredPresenceRequest,
) -> Result<()> {
set_user_preferred_presence_at(client, &endpoints::set_user_preferred_presence(), req).await
}

async fn set_user_preferred_presence_at(
client: &GraphClient,
url: &str,
req: &SetUserPreferredPresenceRequest,
) -> Result<()> {
client.post_no_content(url, req).await
}

pub async fn clear_user_preferred_presence(client: &GraphClient) -> Result<()> {
clear_user_preferred_presence_at(client, &endpoints::clear_user_preferred_presence()).await
}

async fn clear_user_preferred_presence_at(client: &GraphClient, url: &str) -> Result<()> {
client
.post_no_content(url, &ClearUserPreferredPresenceRequest {})
.await
}

#[cfg(test)]
mod tests {
use super::*;
Expand Down Expand Up @@ -248,4 +273,53 @@ mod tests {
.await
.unwrap();
}

#[tokio::test]
async fn set_user_preferred_presence_sends_the_pair_and_expiration() {
let server = MockServer::start().await;
Mock::given(method("POST"))
.and(path("/me/presence/setUserPreferredPresence"))
.and(body_json(serde_json::json!({
"availability": "Offline",
"activity": "OffWork",
"expirationDuration": "P1D"
})))
.respond_with(ResponseTemplate::new(200))
.expect(1)
.mount(&server)
.await;

set_user_preferred_presence_at(
&test_client(),
&format!("{}/me/presence/setUserPreferredPresence", server.uri()),
&SetUserPreferredPresenceRequest {
availability: "Offline".to_string(),
activity: "OffWork".to_string(),
expiration_duration: Some("P1D".to_string()),
},
)
.await
.unwrap();
}

/// Graph documents the body as `{}`. `body_json` compares parsed values, so a request that
/// went out as `null` or with no body would not match.
#[tokio::test]
async fn clear_user_preferred_presence_sends_an_empty_object() {
let server = MockServer::start().await;
Mock::given(method("POST"))
.and(path("/me/presence/clearUserPreferredPresence"))
.and(body_json(serde_json::json!({})))
.respond_with(ResponseTemplate::new(200))
.expect(1)
.mount(&server)
.await;

clear_user_preferred_presence_at(
&test_client(),
&format!("{}/me/presence/clearUserPreferredPresence", server.uri()),
)
.await
.unwrap();
}
}
Loading