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: 2 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
17 changes: 12 additions & 5 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -381,9 +381,10 @@ teams message send --team <team-id> --channel <channel-id> --mention <user-id> -
teams message send --team <team-id> --channel <channel-id> --subject "Release plan" --body "Details inside."
echo "Build passed" | teams message send --team <team-id> --channel <channel-id> --stdin
teams message list --team <team-id> --channel <channel-id>
teams message list --team <team-id> --channel <channel-id> --message-id <root-message-id> --all-pages
teams message list --chat <chat-id>
teams message get --team <team-id> --channel <channel-id> --message <msg-id>
teams message reply --team <team-id> --channel <channel-id> --message <msg-id> --body "Thanks!"
teams message reply --team <team-id> --channel <channel-id> --message <msg-id> --mention <user-id-or-upn> --body "Thanks!"
teams message update --team <team-id> --channel <channel-id> --message <msg-id> --body "Corrected"
teams message update --chat <chat-id> --message <msg-id> --body "Corrected"
teams message delete --team <team-id> --channel <channel-id> --message <msg-id>
Expand All @@ -395,11 +396,13 @@ teams message pin --team <team-id> --channel <channel-id> --message <msg-id>
teams message unpin --team <team-id> --channel <channel-id> --pinned-message-id <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 `<at>` elements are prepended in flag order, and a mention by itself counts
as a body (`--body` optional). Raw `<at>` markup typed into an HTML body is rejected with
exit code 2 before anything is sent.
Expand All @@ -420,6 +423,10 @@ teams message send --chat <chat-id> \
teams message send --team <team-id> --channel <channel-id> \
--mention <user-id-1> --mention <user-id-2> \
--body "Deploy is going out now."

teams message reply --team <team-id> --channel <channel-id> \
--message-id <root-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.
Expand Down
12 changes: 9 additions & 3 deletions docs/command-reference.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand All @@ -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 `<br>`) and the `<at>` elements are prepended to your body in flag order. A mention by itself counts as a body, so `--mention USER` without `--body` works. Raw `<at>` 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 `<br>`) and the `<at>` elements are prepended to your body in flag order. A mention by itself counts as a body, so `--mention USER` without `--body` works. Raw `<at>` 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
Expand All @@ -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 <object-id-1> --mention <object-id-2> --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.
Expand Down
7 changes: 7 additions & 0 deletions docs/examples.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
7 changes: 7 additions & 0 deletions docs/man/teams-examples.7
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
14 changes: 11 additions & 3 deletions docs/man/teams.1
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -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
Expand Down
92 changes: 91 additions & 1 deletion src/api/messages.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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<Vec<ChatMessage>> {
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<Vec<ChatMessage>> {
client.get_paged(url, &[], pagination).await
}

pub async fn list_chat_messages(
client: &GraphClient,
chat_id: &str,
Expand Down Expand Up @@ -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 {
Expand Down Expand Up @@ -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")
);
}
}
Loading