From e18eb93e46eef81a151e415ed371e9abc254457f Mon Sep 17 00:00:00 2001 From: Sekers <46898253+Sekers@users.noreply.github.com> Date: Wed, 30 Sep 2026 11:42:59 -0500 Subject: [PATCH 1/3] Post into the most recent of several matching group chats When more than one group chat has exactly the sender and the recipients as members, Send-ScriptMessage_MicrosoftGraph posts into the least recent one. The chats are sorted most recent first, but the match loop never stops, so the last match wins. Stop at the first match. With Chat.Read the most recent chat is the one with the latest message; with only Chat.ReadBasic it is the one most recently created, renamed, or given different members, because Graph does not change LastUpdatedDateTime when a message is posted. Correct the comment that said otherwise. Measured against a tenant on 2026-09-30: Graph creates a second group chat with the same members rather than returning the first, and posting leaves LastUpdatedDateTime alone. Chat.ReadBasic cannot expand lastMessagePreview, so that sort is the best it allows, and a delegated sign-in carries every permission already granted to the app, so the Chat.ReadBasic sort applies only when the app has no Chat.Read grant. See Research_Notes/Microsoft-Graph-Behavior.md section 9. --- CHANGELOG.md | 3 + Research_Notes/Microsoft-Graph-Behavior.md | 92 ++++++++++++++++++++-- ScriptMessage/Services/MicrosoftGraph.ps1 | 5 +- Tests/Send-ScriptMessage.Tests.ps1 | 57 ++++++++++++++ 4 files changed, 148 insertions(+), 9 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 82e931f..0815fe1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -29,6 +29,9 @@ - `Send-ScriptMessage` now honors an explicit `-IncludeBCCInGroupChat $false`. The configuration file's `IncludeBCCInGroupChat` setting was used in its place, so a call asking to keep BCC recipients out of a group chat added them anyway when that setting was `true`, making those addresses visible to everyone in the chat. An explicit `-IncludeBCCInGroupChat $true` was unaffected. - `Send-ScriptMessage` now sends mail without a `ChatType` setting in the configuration file. A Mail-only send failed without it, reporting "Cannot convert null to type ChatType", even though `ChatType` only affects chat; a configuration file listing only `Mail` in `AllowableMessageTypes`, or one written before the setting was added in version 1.0.8, was affected. A `Chat` send with no chat type, because the setting is missing or blank and `-ChatType` was not passed, now reports that in the returned result's `Error` property and names both places it can be set. - `Get-ScriptMessageContext -ReturnCachedContext` no longer reports a connection that `Disconnect-ScriptMessage` has already ended. Disconnecting left the cached context in place, so a cached read kept returning the account and scopes of the finished session, and only a read without `-ReturnCachedContext` showed the truth. Disconnecting now clears that service's cached context, so the next cached read asks the service again. +- `Send-ScriptMessage` with `ChatType` `Group` now posts into the most recent of several group chats that have exactly the same members, instead of the least recent, so a script's messages no longer land in a chat nobody uses while people talk in a newer one. Which chat counts as the most recent depends on whether the sign-in can read chat messages: + - With `Chat.Read`, which `MgDelegatedPermission_RequestChatReadPermission` set to `true` asks for: the chat with the latest message, whoever sent it. + - With only `Chat.ReadBasic`: the chat most recently created, renamed, or given different members. Messages don't count, so the same chat keeps being chosen until one of those changes. A sign-in gets every permission already granted to the app registration, so this applies only when the app registration hasn't been granted `Chat.Read`, whatever `MgDelegatedPermission_RequestChatReadPermission` says. - Minor: `Send-ScriptMessage` now reads every true/false setting in the configuration file the same way, so a value other than `true` no longer turns a setting on. - Minor: The help for `Get-ScriptMessageConfig` and `Set-ScriptMessageConfigFilePath` now describes the `-Path` parameter, which it left blank. - Minor: The fourth `Send-ScriptMessage` help example, which sends attachments held in variables, now runs as written. It failed with "Missing closing ')' in subexpression" when copied, and it ended without calling `Send-ScriptMessage`. diff --git a/Research_Notes/Microsoft-Graph-Behavior.md b/Research_Notes/Microsoft-Graph-Behavior.md index 7529b70..539d506 100644 --- a/Research_Notes/Microsoft-Graph-Behavior.md +++ b/Research_Notes/Microsoft-Graph-Behavior.md @@ -1,13 +1,14 @@ # Microsoft Graph behavior What Microsoft Graph and the Microsoft Graph PowerShell SDK do in the areas this module relies on: -permissions, what a successful send returns, chat creation, attachment and upload limits, how SDK sign-in +permissions, what a successful send returns, chat creation and reuse, attachment and upload limits, how SDK sign-in persists, and what disconnecting after a send changes. Each claim is tied to the part of the module it affects. -**Only section 7 was measured against a tenant**, on **2026-09-16**; that section gives the environment. -Documentation was read on **2026-09-15** from the pages listed under Sources, and the SDK source on -**2026-09-16**, except for section 8, whose pages and SDK source were read on **2026-09-21**. Microsoft revises -these pages, so re-read a page before relying on a limit or a permission. +**Only sections 7 and 9 were measured against a tenant**, on **2026-09-16** and **2026-09-30**; each section +gives its environment. Documentation was read on **2026-09-15** from the pages listed under Sources, and the SDK +source on **2026-09-16**, except for section 8, whose pages and SDK source were read on **2026-09-21**, and +section 9, whose pages were read on **2026-09-30**. Microsoft revises these pages, so re-read a page before +relying on a limit or a permission. **Claims are labelled with their evidence.** @@ -50,6 +51,8 @@ these pages, so re-read a page before relying on a limit or a permission. | [ConvertFrom-SecureString](https://learn.microsoft.com/en-us/powershell/module/microsoft.powershell.security/convertfrom-securestring) | 2026-08-10 | | [about_Signing](https://learn.microsoft.com/en-us/powershell/module/microsoft.powershell.core/about/about_signing) | 2026-09-21 | | [Microsoft Graph PowerShell SDK authentication source, branch `main`](https://github.com/microsoftgraph/msgraph-sdk-powershell/tree/main/src/Authentication) | none (branch, read 2026-09-21) | +| [List chats](https://learn.microsoft.com/en-us/graph/api/chat-list?view=graph-rest-1.0) | 2026-04-13 | +| [chat resource type](https://learn.microsoft.com/en-us/graph/api/resources/chat?view=graph-rest-1.0) | 2026-08-05 | ## 1. From documentation: permissions for each operation the module performs @@ -124,8 +127,8 @@ relies on this to land in the existing chat. For `Group`, it first lists all of (`Get-MgChat -All`, expanding members) and posts into one whose members exactly match the sender plus the recipients, creating a new group chat only when none matches. -**Unverified:** whether `New-MgChat` for a group chat always creates a new chat even when one with the same -members already exists. The page does not say, and the module's lookup exists on the assumption that it does. +**Measured** (section 9): creating a group chat with the same members as an existing one created a second chat +rather than returning the first. The page does not say, and the module's lookup exists because of it. ## 5. From documentation: attachment and upload size limits @@ -316,3 +319,78 @@ that runs the script, on the computer that runs it. - **Unverified:** the message `ConvertTo-SecureString` gives for a string encrypted by another account or on another computer. The wiki's troubleshooting entry uses "Key not valid for use in specified state.", the Windows message usually reported for it; it was not reproduced here. + +## 9. Measured: finding a group chat to reuse + +**Environment:** measured on 2026-09-30 in PowerShell 7.6.6 on Windows 11, with `Microsoft.Graph.Authentication` +and `Microsoft.Graph.Teams` 2.25.0. The sign-in was delegated, through an app registration with `Chat.Read` +consented, and `Send-ScriptMessage` came from `develop` at `bcc28ea`. Every chat and message involved only three +accounts the tester controls: the signed-in sender and two recipients. + +**Measured:** + +- `Get-MgChat -All -Filter "ChatType eq 'group'"` with `-ExpandProperty 'Members', 'LastMessagePreview'`, the + call the module makes, succeeded: Graph accepts both expansions in one request. Each of the signed-in account's + 6 group chats came back with its members' `email` values and a `LastMessagePreview`, and no two of them had the + same members. +- **A group chat with the same members as an existing one is a new chat.** `New-MgChat -BodyParameter`, with + `chatType` `group` and the same three members as an existing group chat, returned a chat with a new ID and a + `CreatedDateTime` of that moment. +- **Posting a message does not change `LastUpdatedDateTime`.** After three messages were posted into the existing + chat, its `LastUpdatedDateTime` still equalled its `CreatedDateTime`, more than a year earlier, while + `LastMessagePreview.CreatedDateTime` moved to each new message. +- With one matching chat, `Send-ScriptMessage -ChatType Group` posted into it. With two, where the newer chat held + the latest message, it posted into the older one, whose last message was older too. +- **A delegated sign-in gets every delegated permission already granted to the app, not only the ones asked + for.** With `MgDelegatedPermission_RequestChatReadPermission` false, `AllowableMessageTypes` only `Chat`, and + `MgDelegatedPermission_RequestFilesReadWritePermission` false, `Connect-ScriptMessage` asked for + `Chat.ReadBasic` but no `Chat.Read`, `Mail.Send`, or `Files.ReadWrite`. The connection's scopes, as + `Get-ScriptMessageContext` reported them, included all four, because the app registration had all of them + granted. The group chat lookup therefore took the `Chat.Read` sort. +- **`Chat.ReadBasic` cannot read `lastMessagePreview`.** With `Chat.Read`'s grant revoked, so the connection's + chat scopes were only `Chat.Create`, `Chat.ReadBasic`, and `ChatMessage.Send`, the same `Get-MgChat` call with + `-ExpandProperty 'Members', 'LastMessagePreview'` failed with 403 "Expansion on lastMessagePreview requires one + of the 'Chat.Read,Chat.ReadWrite' additional permissions". Expanding only `Members` succeeded. +- On that connection, with the same two matching chats, `Send-ScriptMessage -ChatType Group` from this change's + code posted into the newer chat, by `LastUpdatedDateTime`, although the older one held the latest message. +- Before `Chat.ReadBasic` was granted to the app, that sign-in stopped at an "Approval required" prompt listing it, + in a tenant whose user consent settings require an administrator. The earlier sign-ins, which asked for + `Chat.Read`, already granted, had no prompt. +- `New-MgChat` failed with 400 `'user@odata.bind' field is missing in the request.` when a member's + `user@odata.bind` value was present but named a user that does not exist (a quoted list of addresses passed as + one address). The message says the field is missing when it is there but wrong. Both the `-Members` form and + the `-BodyParameter` form gave it. + +**From documentation:** the chat resource type page defines `lastUpdatedDateTime` as "Date and time at which the +chat was renamed or the list of members was last changed", and says `lastMessagePreview` is "Null if no messages +were sent in the chat" and that only the list chats operation supports it. The List chats page lists +`Chat.ReadBasic` as the least privileged delegated permission and supports `$orderby` only on +`lastMessagePreview/createdDateTime`, descending. It also says `$expand=members` "returns a maximum of 25 member +items". Neither page says which permission is needed to expand `lastMessagePreview`. + +**From source:** the `Group` branch of `Send-ScriptMessage_MicrosoftGraph` lists every group chat and sorts it +most recent first: by `LastMessagePreview.CreatedDateTime` when the connection's scopes include `Chat.Read` or +`Chat.ReadWrite`, and otherwise by `LastUpdatedDateTime`. It posts into the first chat whose member `email` +values are exactly the sender plus the recipients, and creates a new group chat only when none matches. + +**Inference:** + +- Without `Chat.Read`, "most recent" means the chat most recently created, renamed, or given different members; + messages never change which chat is chosen. That sort is used only when the app registration has not been + granted `Chat.Read` (or `Chat.ReadWrite`), whatever `MgDelegatedPermission_RequestChatReadPermission` says; + the same holds for every setting that only chooses which delegated permission is asked for. +- A group chat with more than 25 members never matches, if the 25-member cap applies to `-All` paging too, so the + module would create a new chat on every send to it. +- A recipient given by an address other than the one Graph reports as the member's `email`, such as an alias, + never matches either, with the same result. + +### What this does not establish + +- **Unverified:** the module's own `New-MgChat -Members` call creating a group chat with valid members. A matching + chat already existed, so that path was not exercised. +- **Unverified:** whether the Teams client ever creates a second group chat with the same members, and whether + anything besides a rename or a member change moves `LastUpdatedDateTime` (only the documentation says so). +- **Unverified:** the 25-member cap and the alias mismatch in the inferences above. Neither was tested. +- **Unverified:** whether permissions a user consented to for themselves, rather than ones an administrator granted + for the organization, are carried the same way. Only administrator grants were measured. +- Nothing in this section was measured on Windows PowerShell 5.1 or with an SDK release other than 2.25.0. diff --git a/ScriptMessage/Services/MicrosoftGraph.ps1 b/ScriptMessage/Services/MicrosoftGraph.ps1 index 8e95b0e..4026e92 100644 --- a/ScriptMessage/Services/MicrosoftGraph.ps1 +++ b/ScriptMessage/Services/MicrosoftGraph.ps1 @@ -1016,7 +1016,7 @@ function Send-ScriptMessage_MicrosoftGraph 'LastUpdatedDateTime' ) - # If the script has 'Chat.Read' or 'Chat.ReadWrite', then sort by the message preview (last time a message was sent). Otherwise, sort by the last time the chat OBJECT was updated. + # If the script has 'Chat.Read' or 'Chat.ReadWrite', then sort by the message preview (last time a message was sent). Otherwise, sort by LastUpdatedDateTime, which Graph sets when the chat is created, renamed, or its members change, but not when a message is sent. [array]$MicrosoftGraphScopes = $MicrosoftGraphContext | Select-Object -ExpandProperty Scopes if (@($MicrosoftGraphScopes) -contains 'Chat.Read' -or @($MicrosoftGraphScopes) -contains 'Chat.ReadWrite') { @@ -1031,13 +1031,14 @@ function Send-ScriptMessage_MicrosoftGraph $ExistingGroupChats = $ExistingGroupChats | Sort-Object -Property LastUpdatedDateTime -Descending } - # Reset the variable and then do a compare\search + # Graph allows several group chats with the same members. The chats are sorted most recent first, so use the first match. $LatestExistingGroupChatMatch = $null foreach ($existingGroupChat in $ExistingGroupChats) { if (-not (Compare-Object -ReferenceObject @($existingGroupChat.Members.AdditionalProperties.email) -DifferenceObject $AllChatParticipants)) { $LatestExistingGroupChatMatch = $existingGroupChat + break } } diff --git a/Tests/Send-ScriptMessage.Tests.ps1 b/Tests/Send-ScriptMessage.Tests.ps1 index f317772..1a0ba35 100644 --- a/Tests/Send-ScriptMessage.Tests.ps1 +++ b/Tests/Send-ScriptMessage.Tests.ps1 @@ -242,6 +242,63 @@ Describe 'Send-ScriptMessage chat message' { } } + Context 'A group chat whose members match more than one existing chat' { + BeforeAll { + Mock -ModuleName ScriptMessage Get-ScriptMessageConfig { + $Config = [pscustomobject]@{ + MicrosoftGraph = [pscustomobject]@{ + AllowableMessageTypes = @('Chat') + IncludeBCCInGroupChat = $false + MgPermissionType = 'Delegated' + MgDisconnectWhenDone = $false + } + } + if ($null -ne $Service) { $Config.$Service } else { $Config } + } + + # Graph allows several group chats with the same members. The two matching chats disagree on which is + # most recent: 'active-chat' has the latest message, and 'renamed-chat' was renamed or had its members + # changed more recently. 'other-chat' is newer than both but has different members. + Mock -ModuleName ScriptMessage Get-MgChat { + function New-Chat($Id, $Emails, $LastUpdated, $LastMessage) + { + [pscustomobject]@{ + Id = $Id + LastUpdatedDateTime = [datetime]$LastUpdated + LastMessagePreview = [pscustomobject]@{ CreatedDateTime = [datetime]$LastMessage } + Members = @($Emails | ForEach-Object { [pscustomobject]@{ AdditionalProperties = @{ email = $_ } } }) + } + } + New-Chat 'renamed-chat' @('sender@example.org', 'recipient@example.org') '2026-06-01' '2026-08-01' + New-Chat 'other-chat' @('sender@example.org', 'someone@example.org') '2026-09-15' '2026-09-15' + New-Chat 'active-chat' @('recipient@example.org', 'sender@example.org') '2025-01-01' '2026-09-01' + } + } + + It 'with Chat.Read, posts into the chat with the most recent message' { + $Result = Send-ScriptMessage @MessageArguments -ChatType Group + + $Result.Error | Should -BeNullOrEmpty + Should -Invoke -ModuleName ScriptMessage New-MgChat -Times 0 -Exactly + Should -Invoke -ModuleName ScriptMessage New-MgChatMessage -Times 1 -Exactly -ParameterFilter { $ChatId -eq 'active-chat' } + } + + It 'with only Chat.ReadBasic, posts into the chat most recently created, renamed, or changed' { + Mock -ModuleName ScriptMessage Get-ScriptMessageContext { + [pscustomobject]@{ + Account = 'sender@example.org' + Scopes = @('Chat.ReadBasic') + } + } + + $Result = Send-ScriptMessage @MessageArguments -ChatType Group + + $Result.Error | Should -BeNullOrEmpty + Should -Invoke -ModuleName ScriptMessage New-MgChat -Times 0 -Exactly + Should -Invoke -ModuleName ScriptMessage New-MgChatMessage -Times 1 -Exactly -ParameterFilter { $ChatId -eq 'renamed-chat' } + } + } + Context 'Mail and Chat in the same call' { BeforeAll { Mock -ModuleName ScriptMessage Get-ScriptMessageConfig { From a32d958ca532ea4111950a355f4076cec271ca55 Mon Sep 17 00:00:00 2001 From: Sekers <46898253+Sekers@users.noreply.github.com> Date: Wed, 30 Sep 2026 13:40:31 -0500 Subject: [PATCH 2/3] Reword the group chat changelog entry in plainer terms The Chat.ReadBasic sub-bullet of the group chat entry says messages "don't count" and describes the permission grant in terms the reader has to work out. Say plainly which chat each permission picks, and state the case where both permissions are granted directly. --- CHANGELOG.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 0815fe1..644e752 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -30,8 +30,8 @@ - `Send-ScriptMessage` now sends mail without a `ChatType` setting in the configuration file. A Mail-only send failed without it, reporting "Cannot convert null to type ChatType", even though `ChatType` only affects chat; a configuration file listing only `Mail` in `AllowableMessageTypes`, or one written before the setting was added in version 1.0.8, was affected. A `Chat` send with no chat type, because the setting is missing or blank and `-ChatType` was not passed, now reports that in the returned result's `Error` property and names both places it can be set. - `Get-ScriptMessageContext -ReturnCachedContext` no longer reports a connection that `Disconnect-ScriptMessage` has already ended. Disconnecting left the cached context in place, so a cached read kept returning the account and scopes of the finished session, and only a read without `-ReturnCachedContext` showed the truth. Disconnecting now clears that service's cached context, so the next cached read asks the service again. - `Send-ScriptMessage` with `ChatType` `Group` now posts into the most recent of several group chats that have exactly the same members, instead of the least recent, so a script's messages no longer land in a chat nobody uses while people talk in a newer one. Which chat counts as the most recent depends on whether the sign-in can read chat messages: - - With `Chat.Read`, which `MgDelegatedPermission_RequestChatReadPermission` set to `true` asks for: the chat with the latest message, whoever sent it. - - With only `Chat.ReadBasic`: the chat most recently created, renamed, or given different members. Messages don't count, so the same chat keeps being chosen until one of those changes. A sign-in gets every permission already granted to the app registration, so this applies only when the app registration hasn't been granted `Chat.Read`, whatever `MgDelegatedPermission_RequestChatReadPermission` says. + - With `Chat.Read`, which `MgDelegatedPermission_RequestChatReadPermission` set to `true` asks for: the chat with the most recent message. + - With only `Chat.ReadBasic`: the chat created most recently, or most recently renamed or had people added or removed. New messages don't affect the choice. If the app registration has both permissions granted, ScriptMessage gets `Chat.Read` even with `MgDelegatedPermission_RequestChatReadPermission` set to `false`. - Minor: `Send-ScriptMessage` now reads every true/false setting in the configuration file the same way, so a value other than `true` no longer turns a setting on. - Minor: The help for `Get-ScriptMessageConfig` and `Set-ScriptMessageConfigFilePath` now describes the `-Path` parameter, which it left blank. - Minor: The fourth `Send-ScriptMessage` help example, which sends attachments held in variables, now runs as written. It failed with "Missing closing ')' in subexpression" when copied, and it ended without calling `Send-ScriptMessage`. From 524f8def11c8f88aeb55b2ece22cfc2fddcb5931 Mon Sep 17 00:00:00 2001 From: Sekers <46898253+Sekers@users.noreply.github.com> Date: Wed, 30 Sep 2026 15:37:40 -0500 Subject: [PATCH 3/3] Correct the address mismatch inference in the Graph research notes Section 9 says a recipient given by an alias never matches an existing group chat. A chat member is named by users('
'), which takes a user ID or UPN, so a true alias more likely fails when the chat is created; the case that never matches is a UPN that differs from the primary email. Describe the UPN case, note that matching on userId would avoid it, and mark the alias case as a likely creation failure. --- Research_Notes/Microsoft-Graph-Behavior.md | 10 +++++++--- 1 file changed, 7 insertions(+), 3 deletions(-) diff --git a/Research_Notes/Microsoft-Graph-Behavior.md b/Research_Notes/Microsoft-Graph-Behavior.md index 539d506..c7b8b2b 100644 --- a/Research_Notes/Microsoft-Graph-Behavior.md +++ b/Research_Notes/Microsoft-Graph-Behavior.md @@ -381,8 +381,11 @@ values are exactly the sender plus the recipients, and creates a new group chat the same holds for every setting that only chooses which delegated permission is asked for. - A group chat with more than 25 members never matches, if the 25-member cap applies to `-All` paging too, so the module would create a new chat on every send to it. -- A recipient given by an address other than the one Graph reports as the member's `email`, such as an alias, - never matches either, with the same result. +- A recipient given by a UPN that differs from their primary email address never matches either, with the same + result: the module names each member as `users('')`, which going by Graph's + `/users/{id | userPrincipalName}` form takes a user ID or UPN, but matches on the member's `email`, the primary + address. Matching on each member's `userId` would avoid it. A true alias, neither the UPN nor the primary + address, probably fails when the chat is created instead. ### What this does not establish @@ -390,7 +393,8 @@ values are exactly the sender plus the recipients, and creates a new group chat chat already existed, so that path was not exercised. - **Unverified:** whether the Teams client ever creates a second group chat with the same members, and whether anything besides a rename or a member change moves `LastUpdatedDateTime` (only the documentation says so). -- **Unverified:** the 25-member cap and the alias mismatch in the inferences above. Neither was tested. +- **Unverified:** the 25-member cap, the UPN mismatch, and the alias failure in the inferences above. None was + tested. - **Unverified:** whether permissions a user consented to for themselves, rather than ones an administrator granted for the organization, are carried the same way. Only administrator grants were measured. - Nothing in this section was measured on Windows PowerShell 5.1 or with an SDK release other than 2.25.0.