diff --git a/CHANGELOG.md b/CHANGELOG.md index 2dd35de..682571d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,8 @@ ### Added - `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. ### Fixed diff --git a/README.md b/README.md index ffc3510..8292bc3 100644 --- a/README.md +++ b/README.md @@ -381,9 +381,10 @@ teams message send --team --channel --mention - teams message send --team --channel --subject "Release plan" --body "Details inside." echo "Build passed" | teams message send --team --channel --stdin teams message list --team --channel +teams message list --team --channel --message-id --all-pages teams message list --chat teams message get --team --channel --message -teams message reply --team --channel --message --body "Thanks!" +teams message reply --team --channel --message --mention --body "Thanks!" teams message update --team --channel --message --body "Corrected" teams message update --chat --message --body "Corrected" teams message delete --team --channel --message @@ -395,11 +396,13 @@ teams message pin --team --channel --message teams message unpin --team --channel --pinned-message-id ``` -`message send --mention USER` tags a person as a real Teams @mention — the kind that pings +`message list --message-id ROOT_MESSAGE_ID` lists the replies under that channel thread root. The global `--page-size` and `--all-pages` options apply to the replies collection. + +The `--mention USER` flag on `message send` and `message reply` tags a person as a real Teams @mention — the kind that pings them, not literal `@Name` text. The flag is repeatable and `USER` may be an Entra object ID -or UPN; the display name is resolved through Microsoft Graph. Works for chat sends and -channel sends alike. Graph requires an HTML body plus a synchronized `mentions` array, so -the CLI builds both: a plain-text body is safely converted to HTML (escaped, line breaks +or UPN; the display name is resolved through Microsoft Graph. It works for chat sends, +channel sends, and channel replies. Graph requires an HTML body plus a synchronized +`mentions` array, so the CLI builds both: a plain-text body is safely converted to HTML (escaped, line breaks preserved), the `` elements are prepended in flag order, and a mention by itself counts as a body (`--body` optional). Raw `` markup typed into an HTML body is rejected with exit code 2 before anything is sent. @@ -420,6 +423,10 @@ teams message send --chat \ teams message send --team --channel \ --mention --mention \ --body "Deploy is going out now." + +teams message reply --team --channel \ + --message-id --mention sophie@example.com \ + --body "I have picked this up." ``` Reactions accept either a channel (`--team` with `--channel`) or a chat (`--chat`), never both. diff --git a/docs/command-reference.md b/docs/command-reference.md index cb3afbd..64992ef 100644 --- a/docs/command-reference.md +++ b/docs/command-reference.md @@ -107,11 +107,11 @@ teams channel members remove TEAM_ID CHANNEL_ID MEMBER_ID ```bash teams message send (--team TEAM_ID --channel CHANNEL_ID | --chat CHAT_ID) [--body TEXT | --stdin] [--content-type text|html] [--adaptive-card PATH] [--image PATH]... [--attach PATH]... [--mention USER]... [--subject TEXT] -teams message list (--team TEAM_ID --channel CHANNEL_ID | --chat CHAT_ID) +teams message list (--team TEAM_ID --channel CHANNEL_ID [--message-id ROOT_MESSAGE_ID] | --chat CHAT_ID) teams message get --team TEAM_ID --channel CHANNEL_ID (MESSAGE_ID | --message MESSAGE_ID) [--with-attachments] teams message attachments list (--team TEAM_ID --channel CHANNEL_ID [--reply REPLY_ID] | --chat CHAT_ID) (MESSAGE_ID | --message MESSAGE_ID) teams message attachments download (--team TEAM_ID --channel CHANNEL_ID [--reply REPLY_ID] | --chat CHAT_ID) (MESSAGE_ID | --message MESSAGE_ID) [--index N] [--dir DIR | --path FILE] -teams message reply --team TEAM_ID --channel CHANNEL_ID --message-id MESSAGE_ID [--body TEXT | --stdin] [--content-type text|html] [--image PATH]... [--attach PATH]... +teams message reply --team TEAM_ID --channel CHANNEL_ID --message-id MESSAGE_ID [--body TEXT | --stdin] [--content-type text|html] [--image PATH]... [--attach PATH]... [--mention USER]... teams message update (--team TEAM_ID --channel CHANNEL_ID | --chat CHAT_ID) (MESSAGE_ID | --message MESSAGE_ID) --body TEXT [--content-type text|html] teams message delete --team TEAM_ID --channel CHANNEL_ID (MESSAGE_ID | --message MESSAGE_ID) teams message react (--team TEAM_ID --channel CHANNEL_ID | --chat CHAT_ID) --message-id MESSAGE_ID (REACTION | --reaction REACTION) @@ -128,13 +128,15 @@ promoted. Normal message mutation requires delegated auth. App-only/client-credentials tokens are rejected for these commands. +`message list --team TEAM_ID --channel CHANNEL_ID --message-id ROOT_MESSAGE_ID` lists the replies under that channel thread root. `--page-size` controls the first page and `--all-pages` follows the complete replies collection. + `REACTION` is an emoji character or one of the names the CLI translates for you (`like`, `heart`, `laugh`, `surprised`, `sad`, `angry`, `thumbsup`, `thumbsdown`, `eyes`, `tada`, `rocket`, `fire`). Graph only accepts the emoji character on writes. `message update` edits your own message in place (Graph lets a delegated caller change any property except `policyViolation`); channel edits need the `ChannelMessage.ReadWrite` delegated scope, chat edits need `Chat.ReadWrite`. Graph returns no content on success, so the command reads the message back and prints it; if that read fails the edit has still been applied and the output is `{"id": ..., "updated": true, "readBackError": ...}`. `--image` sends a picture the way pasting a screenshot does — the bytes travel inside the message itself (a Graph "hosted content"), so it needs no scopes beyond sending messages. `--attach` uploads the file to real storage first (your OneDrive's `Microsoft Teams Chat Files` for chats, the team's SharePoint library for channels) and links it from the message; that upload needs `Files.ReadWrite` (chats) or `Files.ReadWrite.All` (channels). Both flags repeat for multiple files, and `--body` becomes optional when either is present. Inline images are capped at 3MB each; attachments use Graph's 250MB simple-upload limit. -`--mention USER` tags a person as a real Teams @mention (the kind that pings them), in chat sends and channel sends alike. It is repeatable, and `USER` may be an Entra object ID or a UPN — the CLI resolves the display name through Microsoft Graph. A mention needs an HTML body plus a synchronized `mentions` array; the CLI builds both for you: a plain-text body is safely converted to HTML (escaped, line breaks preserved as `
`) and the `` elements are prepended to your body in flag order. A mention by itself counts as a body, so `--mention USER` without `--body` works. Raw `` markup typed directly into an HTML body is rejected with exit code 2 before anything is sent, because Graph does not turn it into a real mention. +`--mention USER` tags a person as a real Teams @mention (the kind that pings them) in chat sends, channel sends, and channel replies. It is repeatable, and `USER` may be an Entra object ID or a UPN — the CLI resolves the display name through Microsoft Graph. A mention needs an HTML body plus a synchronized `mentions` array; the CLI builds both for you: a plain-text body is safely converted to HTML (escaped, line breaks preserved as `
`) and the `` elements are prepended to your body in flag order. A mention by itself counts as a body, so `--mention USER` without `--body` works. Raw `` markup typed directly into an HTML body is rejected with exit code 2 before anything is sent, because Graph does not turn it into a real mention. ```bash # Tag someone in a chat @@ -144,6 +146,10 @@ teams message send --chat 19:abc@thread.v2 \ # Tag two people in a channel post (repeat --mention) teams message send --team TEAM_ID --channel CHANNEL_ID \ --mention --mention --body "Deploy is going out now." + +# Tag someone in a channel reply +teams message reply --team TEAM_ID --channel CHANNEL_ID --message-id ROOT_MESSAGE_ID \ + --mention sophie@example.com --body "I have picked this up." ``` `--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 with exit code 2 before anything is sent. The stored subject comes back on `message list` and `message get`. Human lists include a Subject column. Plain lists collect columns across all messages, so a titled message keeps its subject even when the first message is untitled; missing values are blank. JSON omits absent subjects. diff --git a/docs/examples.md b/docs/examples.md index e681880..efc2d7e 100644 --- a/docs/examples.md +++ b/docs/examples.md @@ -89,6 +89,13 @@ teams message reply \ --channel "$CHANNEL_ID" \ --message-id "$MESSAGE_ID" \ --body "Acknowledged." + +teams message list \ + --team "$TEAM_ID" \ + --channel "$CHANNEL_ID" \ + --message-id "$MESSAGE_ID" \ + --all-pages \ + --output json ``` ## Controlled smoke test diff --git a/docs/man/teams-examples.7 b/docs/man/teams-examples.7 index 3827586..c6bf377 100644 --- a/docs/man/teams-examples.7 +++ b/docs/man/teams-examples.7 @@ -56,6 +56,13 @@ teams message reply \\ --message-id "$MESSAGE_ID" \\ --body "Acknowledged." \\ --output json + +teams message list \\ + --team "$TEAM_ID" \\ + --channel "$CHANNEL_ID" \\ + --message-id "$MESSAGE_ID" \\ + --all-pages \\ + --output json .fi .SH FILE SMOKE TEST .nf diff --git a/docs/man/teams.1 b/docs/man/teams.1 index cf08430..2f1d3f6 100644 --- a/docs/man/teams.1 +++ b/docs/man/teams.1 @@ -150,11 +150,11 @@ teams channel members remove TEAM_ID CHANNEL_ID MEMBER_ID .SH MESSAGE COMMANDS .nf teams message send (--team TEAM_ID --channel CHANNEL_ID | --chat CHAT_ID) [--body TEXT | --stdin] [--content-type text|html] [--adaptive-card PATH] [--image PATH]... [--attach PATH]... [--mention USER]... [--subject TEXT] -teams message list (--team TEAM_ID --channel CHANNEL_ID | --chat CHAT_ID) +teams message list (--team TEAM_ID --channel CHANNEL_ID [--message-id ROOT_MESSAGE_ID] | --chat CHAT_ID) teams message get --team TEAM_ID --channel CHANNEL_ID (MESSAGE_ID | --message MESSAGE_ID) [--with-attachments] teams message attachments list (--team TEAM_ID --channel CHANNEL_ID [--reply REPLY_ID] | --chat CHAT_ID) (MESSAGE_ID | --message MESSAGE_ID) teams message attachments download (--team TEAM_ID --channel CHANNEL_ID [--reply REPLY_ID] | --chat CHAT_ID) (MESSAGE_ID | --message MESSAGE_ID) [--index N] [--dir DIR | --path FILE] -teams message reply --team TEAM_ID --channel CHANNEL_ID --message-id MESSAGE_ID [--body TEXT | --stdin] [--content-type text|html] [--image PATH]... [--attach PATH]... +teams message reply --team TEAM_ID --channel CHANNEL_ID --message-id MESSAGE_ID [--body TEXT | --stdin] [--content-type text|html] [--image PATH]... [--attach PATH]... [--mention USER]... teams message reply --team TEAM_ID --channel CHANNEL_ID --message MESSAGE_ID --body TEXT teams message update (--team TEAM_ID --channel CHANNEL_ID | --chat CHAT_ID) (MESSAGE_ID | --message MESSAGE_ID) --body TEXT [--content-type text|html] teams message delete --team TEAM_ID --channel CHANNEL_ID (MESSAGE_ID | --message MESSAGE_ID) @@ -191,8 +191,16 @@ and A user has one reaction per message, so reacting again replaces the previous one; unreacting a reaction that is not set succeeds without effect. .PP +.B message list --message-id ROOT_MESSAGE_ID +lists the replies under that channel thread root. The global +.B --page-size +option controls the first page and +.B --all-pages +follows the complete replies collection. +.PP .B --mention USER -(repeatable) tags a person as a real Teams @mention in a chat or channel send; +(repeatable) tags a person as a real Teams @mention in a chat send, channel +send, or channel reply; USER may be an Entra object ID or UPN, and the display name is resolved through Microsoft Graph. Graph requires an HTML body plus a synchronized .B mentions diff --git a/src/api/messages.rs b/src/api/messages.rs index fcd6c84..90332af 100644 --- a/src/api/messages.rs +++ b/src/api/messages.rs @@ -226,6 +226,33 @@ pub async fn delete_message( // --- Chat Messages --- +/// List the replies in one channel thread in the order Graph returns them. +/// +/// `list_channel_messages` returns thread roots only, so without this a +/// caller cannot see whether a question has already been answered. +pub async fn list_channel_message_replies( + client: &GraphClient, + team_id: &str, + channel_id: &str, + message_id: &str, + pagination: &PaginationOpts, +) -> Result> { + list_channel_message_replies_at( + client, + &endpoints::channel_message_replies(team_id, channel_id, message_id), + pagination, + ) + .await +} + +async fn list_channel_message_replies_at( + client: &GraphClient, + url: &str, + pagination: &PaginationOpts, +) -> Result> { + client.get_paged(url, &[], pagination).await +} + pub async fn list_chat_messages( client: &GraphClient, chat_id: &str, @@ -370,7 +397,7 @@ mod tests { use crate::error::TeamsError; use crate::models::message::ItemBody; use reqwest::Client; - use wiremock::matchers::{body_json, header, method, path}; + use wiremock::matchers::{body_json, header, method, path, query_param}; use wiremock::{Mock, MockServer, ResponseTemplate}; fn test_client() -> GraphClient { @@ -737,4 +764,67 @@ mod tests { assert!(matches!(err, TeamsError::NotFound(_)), "{err:?}"); } + + #[test] + fn channel_message_replies_endpoint_targets_the_replies_collection() { + assert_eq!( + endpoints::channel_message_replies("team-id", "channel-id", "1700000000000"), + "https://graph.microsoft.com/v1.0/teams/team-id/channels/channel-id/messages/1700000000000/replies" + ); + } + + /// The replies collection pages like any other listing: `$top` carries + /// the page size and the rows come back as full messages in Graph's order. + #[tokio::test] + async fn list_channel_message_replies_reads_the_replies_collection() { + let server = MockServer::start().await; + Mock::given(method("GET")) + .and(path( + "/teams/team-id/channels/channel-id/messages/1700000000000/replies", + )) + .and(query_param("$top", "2")) + .and(header("authorization", "Bearer test-token")) + .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({ + "value": [ + { + "id": "1700000000002", + "createdDateTime": "2026-09-03T08:00:02Z", + "body": { "contentType": "text", "content": "second" } + }, + { + "id": "1700000000001", + "createdDateTime": "2026-09-03T08:00:01Z", + "body": { "contentType": "text", "content": "first" } + } + ] + }))) + .expect(1) + .mount(&server) + .await; + + let replies = list_channel_message_replies_at( + &test_client(), + &format!( + "{}/teams/team-id/channels/channel-id/messages/1700000000000/replies", + server.uri() + ), + &PaginationOpts { + page_size: 2, + all_pages: false, + }, + ) + .await + .unwrap(); + + assert_eq!(replies.len(), 2); + assert_eq!(replies[0].id.as_deref(), Some("1700000000002")); + assert_eq!(replies[1].id.as_deref(), Some("1700000000001")); + assert_eq!( + replies[1] + .body + .as_ref() + .and_then(|body| body.content.as_deref()), + Some("first") + ); + } } diff --git a/src/cli/message.rs b/src/cli/message.rs index ba019ad..7f4a182 100644 --- a/src/cli/message.rs +++ b/src/cli/message.rs @@ -62,6 +62,14 @@ pub enum MessageCommand { /// Chat ID (for chat messages) #[arg(long)] chat: Option, + /// List the replies in this channel thread instead of the thread roots + #[arg( + long = "message-id", + visible_alias = "message", + requires = "channel", + conflicts_with = "chat" + )] + message_id: Option, }, /// Get a specific message Get { @@ -117,6 +125,9 @@ pub enum MessageCommand { /// File to upload and attach (repeatable; needs a Files.ReadWrite scope) #[arg(long)] attach: Vec, + /// User to @mention (repeatable): an Entra object ID or UPN + #[arg(long, value_name = "USER")] + mention: Vec, }, /// Add a reaction to a channel or chat message #[command( @@ -310,10 +321,13 @@ pub async fn run( // media-only exception the same way --image/--attach do. let content = resolve_body_or_media(body, stdin, has_media || !mention.is_empty())?; let identities = resolve_mentions(&client, &mention).await?; - ensure_no_raw_at_markup(&content_type, &content)?; - let mut req = build_send_request(content, &content_type, adaptive_card.as_deref())?; + let mut req = build_send_request_with_mentions( + content, + &content_type, + adaptive_card.as_deref(), + &identities, + )?; req.subject = subject; - apply_mentions(&mut req, &identities)?; let msg = if let Some(chat_id) = chat { super::message_media::apply_media( @@ -356,6 +370,7 @@ pub async fn run( team, channel, chat, + message_id, } => { let start = Instant::now(); @@ -367,8 +382,27 @@ pub async fn run( })?; let channel_id = channel .ok_or_else(|| TeamsError::InvalidInput("--channel is required".into()))?; - api::messages::list_channel_messages(&client, &team_id, &channel_id, pagination) - .await? + match message_id { + Some(root_id) => { + api::messages::list_channel_message_replies( + &client, + &team_id, + &channel_id, + &root_id, + pagination, + ) + .await? + } + None => { + api::messages::list_channel_messages( + &client, + &team_id, + &channel_id, + pagination, + ) + .await? + } + } }; if format == OutputFormat::Human { @@ -425,12 +459,17 @@ pub async fn run( content_type, image, attach, + mention, } => { let start = Instant::now(); auth::require_delegated_token(&client.token, "Replying to Teams messages")?; let has_media = !image.is_empty() || !attach.is_empty(); - let content = resolve_body_or_media(body, stdin, has_media)?; - let mut req = build_send_request(content, &content_type, None)?; + // A mention alone is a valid non-empty body, so it lifts the + // requirement for --body or --stdin, exactly as it does on send. + let content = resolve_body_or_media(body, stdin, has_media || !mention.is_empty())?; + let identities = resolve_mentions(&client, &mention).await?; + let mut req = + build_send_request_with_mentions(content, &content_type, None, &identities)?; super::message_media::apply_media( &client, &mut req, @@ -887,6 +926,21 @@ fn apply_mentions(req: &mut SendMessageRequest, identities: &[MentionIdentity]) Ok(()) } +/// Build a message body and synchronize any resolved mentions after rejecting +/// raw `` markup. Both new messages and replies use this path so neither can +/// post ambiguous mention IDs or markup that only looks like a notification. +fn build_send_request_with_mentions( + content: String, + content_type: &str, + adaptive_card_path: Option<&str>, + identities: &[MentionIdentity], +) -> Result { + ensure_no_raw_at_markup(content_type, &content)?; + let mut req = build_send_request(content, content_type, adaptive_card_path)?; + apply_mentions(&mut req, identities)?; + Ok(req) +} + /// Raw `` markup in an explicit HTML body is not a real mention — Graph /// renders or strips it as ordinary text. Fail before posting anything rather /// than send a message that only looks like it tagged someone. @@ -1231,8 +1285,13 @@ mod tests { std::fs::create_dir_all(&dir).unwrap(); let card = write_card(&dir); - let mut req = build_send_request("a < b".to_string(), "text", Some(&card)).unwrap(); - apply_mentions(&mut req, &[identity("oid-1", "Sophie")]).unwrap(); + let req = build_send_request_with_mentions( + "a < b".to_string(), + "text", + Some(&card), + &[identity("oid-1", "Sophie")], + ) + .unwrap(); let body = req.body.content.clone().unwrap(); let attachment_id = req.attachments.as_ref().unwrap()[0].id.clone().unwrap(); @@ -1281,14 +1340,21 @@ mod tests { } #[test] - fn raw_at_markup_without_mention_flag_is_rejected() { - let err = ensure_no_raw_at_markup("html", r#"

Sophie please review

"#) - .unwrap_err(); + fn message_build_rejects_raw_at_markup_before_applying_mentions() { + let err = build_send_request_with_mentions( + r#"

Sophie please review

"#.into(), + "html", + None, + &[identity("oid-1", "Alex")], + ) + .unwrap_err(); assert!(matches!(err, TeamsError::InvalidInput(_)), "{err:?}"); assert!(err.to_string().contains("--mention"), "{err}"); // Uppercase markup is caught too. - assert!(ensure_no_raw_at_markup("HTML", "Sophie").is_err()); + assert!( + build_send_request_with_mentions("Sophie".into(), "HTML", None, &[]).is_err() + ); } /// `` shares the `")); +} + +#[test] +fn message_list_help_advertises_thread_replies_flag() { + teams() + .args(["message", "list", "--help"]) + .assert() + .success() + .stdout(predicate::str::contains("--message-id ")); +} + +#[test] +fn message_list_message_id_requires_channel() { + teams() + .args([ + "message", + "list", + "--team", + "team-id", + "--message-id", + "1234", + ]) + .assert() + .failure() + .stderr(predicate::str::contains("--channel")); +} + +#[test] +fn message_list_rejects_message_id_with_chat() { + teams() + .args([ + "message", + "list", + "--chat", + "19:abc@thread.v2", + "--channel", + "channel-id", + "--message-id", + "1234", + ]) + .assert() + .failure() + .stderr(predicate::str::contains("cannot be used with")); +} + #[test] fn message_documented_flags_are_available() { teams()