@patimweb/pi-teams 1.2.0 → 1.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -7,6 +7,8 @@ from your account, in your name, in the right thread. Multiple Teams accounts
7
7
  and multiple company tenants live side by side, and a configuration file
8
8
  decides exactly which teams, channels, chats and people pi may touch.
9
9
 
10
+ ![pi-teams in the pi coding agent](screenshot.png)
11
+
10
12
  ## Installation
11
13
 
12
14
  ```bash
@@ -414,7 +416,8 @@ teams_permissions:
414
416
  | Tool | Description |
415
417
  |------|-------------|
416
418
  | `teams_inbox` | Chats with new messages plus recent mentions of you |
417
- | `teams_list_chats` | Recent chats with a preview of the last message || `teams_read_chat` | Messages of one chat |
419
+ | `teams_list_chats` | Recent chats with a preview of the last message |
420
+ | `teams_read_chat` | Messages of one chat |
418
421
  | `teams_list_teams` | Teams you belong to |
419
422
  | `teams_list_channels` | Channels of a team |
420
423
  | `teams_read_channel` | Posts in a channel |
@@ -424,6 +427,7 @@ teams_permissions:
424
427
  | `teams_find_user` | Directory lookup by name, e-mail or UPN |
425
428
  | `teams_list_files` | Files in a channel's SharePoint folder |
426
429
  | `teams_get_presence` | Your status, a colleague's, or a whole chat's |
430
+ | `teams_availability` | When several people are all free, from calendar free/busy |
427
431
  | `teams_list_meetings` | Calendar events and meetings in a range |
428
432
  | `teams_get_meeting` | One meeting in full |
429
433
 
@@ -436,14 +440,23 @@ teams_permissions:
436
440
  | `teams_send_channel_message` | Post in a channel as you |
437
441
  | `teams_reply_channel_message` | Reply inside an existing thread |
438
442
  | `teams_react` | Add or remove an emoji reaction |
443
+ | `teams_mark_read` | Mark a chat as read (or unread again) — the natural end of a catch-up |
444
+ | `teams_update_message` | Edit a message you already sent |
445
+ | `teams_chat_members` | Add someone to a group chat, or remove them |
439
446
  | `teams_delete_message` | Soft-delete (or restore) a message you sent |
440
- | `teams_create_channel` | Add a channel to a team |
441
447
  | `teams_set_presence` | Set your Teams status |
442
448
  | `teams_set_status_message` | Set the note under your name |
443
449
  | `teams_create_meeting` | Schedule a Teams meeting with invitations |
444
450
  | `teams_update_meeting` | Change a meeting you organize |
451
+ | `teams_respond_invite` | Accept, decline or tentatively accept an invitation |
445
452
  | `teams_cancel_meeting` | Cancel a meeting |
446
453
 
454
+ Editing and deleting apply to **chat** messages. A channel post cannot be
455
+ created, edited or deleted through Graph with the permissions pi asks for
456
+ (`Channel.Create`, `ChannelMessage.ReadWrite`) — that stays in the Teams client.
457
+ Posting and replying in channels, and reacting to channel messages, work as
458
+ usual.
459
+
447
460
  ## Commands
448
461
 
449
462
  | Command | Description |
@@ -464,6 +477,7 @@ teams_permissions:
464
477
  | `/teams-channel-digest` | Decisions, open questions and action items in a channel |
465
478
  | `/teams-standup` | Draft and post a standup update |
466
479
  | `/teams-meeting-prep` | Context and talking points for your next meeting |
480
+ | `/teams-schedule` | Find a time everyone is free and book it |
467
481
  | `/teams-doctor` | Guided troubleshooting |
468
482
 
469
483
  ---
@@ -558,6 +572,9 @@ middle.
558
572
  (`prompt.ts`).
559
573
  - **`src/utils/formatting.ts`** — domain types → display strings. Pure.
560
574
  - **`src/utils/richtext.ts`** — markdown → the HTML subset Teams renders. Pure.
575
+ - **`src/utils/slots.ts`** — availability view → the free slots everybody
576
+ shares. Pure, and the part of scheduling that can be wrong without looking
577
+ wrong.
561
578
  - **`src/safety/`** — the three gates and the audit log.
562
579
  - **`src/tools/`** — one module per tool.
563
580
  - **`src/extension/index.ts`** — registration, commands, the `tool_call`
@@ -572,8 +589,9 @@ npm test
572
589
  ```
573
590
 
574
591
  Tests cover the pure logic — scope matching, the config cascade, message body
575
- construction, markdown rendering, HTML flattening, the listen-mode decision
576
- rules, the safety gates — and run without a tenant or a network connection.
592
+ construction, markdown rendering, HTML flattening, free-slot arithmetic, the
593
+ listen-mode decision rules, the safety gates — and run without a tenant or a
594
+ network connection.
577
595
 
578
596
  ## Troubleshooting
579
597
 
@@ -104,6 +104,31 @@ requests them and rejects them in an explicit scope list.
104
104
  **What is not needed:** no client secret, no certificate, no redirect URI beyond
105
105
  step 2, no application (as opposed to delegated) permissions.
106
106
 
107
+ ### Optional: one capability that needs one more permission
108
+
109
+ Everything above is what pi asks for by default. One action stays off until you
110
+ add the scope — to this list **and** to `scopes` in `pi-teams.json` — and sign in
111
+ again:
112
+
113
+ | Permission | Unlocks | Consent |
114
+ |---|---|---|
115
+ | `ChatMember.ReadWrite` | removing someone from a chat (`teams_chat_members`) | user |
116
+
117
+ Without it the tool answers with the exact scope it is missing instead of
118
+ failing at Graph with a consent error. Editing and deleting chat messages,
119
+ adding chat members and answering invitations all work with the default set.
120
+
121
+ **Deliberately not requested:** `Channel.Create` and
122
+ `ChannelMessage.ReadWrite`. They would unlock creating channels and editing or
123
+ deleting a **channel** post, and pi is built not to depend on permissions that
124
+ may never be granted. Channel posts are created, edited and deleted in the Teams
125
+ client; posting, replying and reacting in channels work without them.
126
+
127
+ **Also not covered:** `findMeetingTimes` and anything that reads another person's
128
+ *appointments* need `Calendars.Read.Shared`. `teams_availability` uses
129
+ `getSchedule` instead, which is free/busy only — no extra consent, and no view
130
+ of what people are actually doing.
131
+
107
132
  ## 4. Consent
108
133
 
109
134
  **API permissions** → **Grant admin consent** — only for the two scopes that
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@patimweb/pi-teams",
3
- "version": "1.2.0",
3
+ "version": "1.3.0",
4
4
  "description": "Microsoft Teams integration for pi coding agent — acts fully as the signed-in user across multiple accounts and tenants: chats, channels, meetings, presence, with configurable guardrails",
5
5
  "keywords": [
6
6
  "pi-package",
@@ -0,0 +1,23 @@
1
+ ---
2
+ description: Find a time everyone is free and book the meeting
3
+ argument-hint: "[who, what, how long]"
4
+ ---
5
+ Organize a Teams meeting: $@
6
+
7
+ 1. Work out who has to be there. Resolve unclear names with `teams_find_user` —
8
+ never guess an e-mail address. Ask me for anything you cannot derive from the
9
+ request.
10
+
11
+ 2. Run `teams_availability` with those people and the meeting length (30 minutes
12
+ unless I said otherwise). If it finds nothing, widen the window with
13
+ `start`/`end` before giving up, and tell me what you tried.
14
+
15
+ 3. Show me two or three slots that fit, as weekday, date and time. **Do not book
16
+ anything yet.**
17
+
18
+ 4. Once I pick one, create it with `teams_create_meeting` in my time zone, with a
19
+ subject that says what the meeting is about. Confirm the slot, the people and
20
+ the subject in one line. Offer to add two or three lines of context from
21
+ `teams_search_messages` to the invitation body — only if I say yes.
22
+
23
+ Do not invite anyone who was not named, and do not answer any invitation.
package/screenshot.png ADDED
Binary file
@@ -1,6 +1,6 @@
1
1
  ---
2
2
  name: teams-collaboration
3
- description: Microsoft Teams chats, channels, meetings and presence, acting as the signed-in user. Use when the user asks to read, search, summarize, send or reply to Teams messages; to catch up on what they missed; to post in a channel or a thread; to start a chat; to react to a message; to check or set their Teams status; to see who is available; to schedule, change or cancel a Teams meeting; or to make pi notice incoming Teams messages on its own (listen mode). Also covers how to format a message for a chat rather than a document, multiple Teams accounts and multiple company tenants, and the allow/deny rules that decide what pi may touch.
3
+ description: Microsoft Teams chats, channels, meetings and presence, acting as the signed-in user. Use when the user asks to read, search, summarize, send, edit, retract or reply to Teams messages; to add or remove someone from a chat; to catch up on what they missed and clear it; to post in a channel or a thread; to react to a message; to mark a chat read or unread; to check or set their Teams status; to see who is available; to find a time everyone is free; to schedule, change, answer, or cancel a Teams meeting; or to make pi notice incoming Teams messages on its own (listen mode). Also covers how to format a message for a chat rather than a document, multiple Teams accounts and multiple company tenants, and the allow/deny rules that decide what pi may touch.
4
4
  ---
5
5
 
6
6
  # Microsoft Teams
@@ -47,6 +47,20 @@ rule set. Show them the rule, do not work around it, and do not try a different
47
47
  tool to achieve the same thing. `teams_permissions` with `action: "test"`
48
48
  checks a target without touching it.
49
49
 
50
+ ## When a tool says a scope is missing
51
+
52
+ One optional action needs a permission the account does not request by default:
53
+ removing someone from a **chat** (`ChatMember.ReadWrite`). The tool names the
54
+ exact scope. Pass that sentence on as the fix — add it to `scopes` in
55
+ `pi-teams.json`, sign in again — or do it in Teams. Do not look for a roundabout
56
+ way to achieve the same thing, and do not report it as a permission bug.
57
+
58
+ A few things cannot be done at all with the permissions pi asks for: creating a
59
+ channel, and editing or deleting a **channel** post. Those need
60
+ `Channel.Create` / `ChannelMessage.ReadWrite`, they are not requested, and they
61
+ are not assumed to be grantable. Say so plainly and point at the Teams client
62
+ instead of trying anyway.
63
+
50
64
  ## Tools
51
65
 
52
66
  ### Session
@@ -76,6 +90,7 @@ checks a target without touching it.
76
90
  | `teams_find_user` | Resolving a first name to a real person |
77
91
  | `teams_list_files` | Files shared in a channel |
78
92
  | `teams_get_presence` | Is someone available right now |
93
+ | `teams_availability` | "When are we all free?" — before booking, not after |
79
94
  | `teams_list_meetings` / `teams_get_meeting` | Calendar and meeting details |
80
95
 
81
96
  ### Writing
@@ -87,10 +102,13 @@ checks a target without touching it.
87
102
  | `teams_send_channel_message` | Posting a new channel message |
88
103
  | `teams_reply_channel_message` | Answering inside an existing thread |
89
104
  | `teams_react` | Adding or removing an emoji reaction |
105
+ | `teams_update_message` | Fixing a message the user already sent |
106
+ | `teams_chat_members` | Bringing someone into a group chat, or taking them out |
107
+ | `teams_mark_read` | Clearing the unread marker after a catch-up |
90
108
  | `teams_delete_message` | Taking back something the user sent |
91
- | `teams_create_channel` | Adding a channel to a team |
92
109
  | `teams_set_presence` / `teams_set_status_message` | Changing the user's status |
93
110
  | `teams_create_meeting` / `teams_update_meeting` / `teams_cancel_meeting` | Scheduling work |
111
+ | `teams_respond_invite` | Accepting, declining or tentatively answering an invitation |
94
112
  | `teams_watch` | Letting pi notice incoming messages on its own |
95
113
 
96
114
  ## How to write as the user
@@ -187,6 +205,77 @@ Before replying anywhere, read the recent messages first. It prevents
187
205
  answering a question that was already answered, and it is what tells you the
188
206
  tone to match.
189
207
 
208
+ ## Read state
209
+
210
+ `teams_mark_read` moves the user's read cursor on a **chat** — the same thing
211
+ as opening it, or re-marking it unread, in the app. Two rules:
212
+
213
+ - It is a **write**, and it goes through the safety gates like any other. Mark
214
+ a chat read when the user asks, or when they clearly want the list cleared —
215
+ not on your own initiative after reading it.
216
+ - Where the tenant shows **read receipts**, the sender can see that the chat was
217
+ opened. That is not a reason to refuse, only a reason not to do it silently.
218
+
219
+ Channels have no equivalent: channel posts cannot be marked read or unread
220
+ through Graph, so "mark the channel as read" is done in the Teams client.
221
+
222
+ ## Correcting a message
223
+
224
+ Both actions work on **chat** messages the user themselves sent, which is also
225
+ the only case that makes sense: pi wrote them in the user's name. A channel post
226
+ cannot be edited or deleted with the permissions pi asks for — for that, point
227
+ at the Teams client.
228
+
229
+ - **`teams_update_message` replaces the text and keeps the message in place.**
230
+ Teams shows it as edited in the same bubble. This is the right answer to a
231
+ typo, a wrong time, a missing detail.
232
+ - **`teams_delete_message` takes it back.** Right when the message should not
233
+ exist at all: wrong chat, wrong content, sent too early.
234
+
235
+ Never correct by deleting and resending. The replacement arrives as a brand-new
236
+ notification, everyone who already read the first message has to notice the gap,
237
+ and the thread loses its order. Editing is the polite version.
238
+
239
+ An edit sends the text only: mentions and attachments from the original are not
240
+ re-created. That is deliberate — re-sending mention entities would ping people a
241
+ second time for a message they were already notified about.
242
+
243
+ ## Chat membership
244
+
245
+ `teams_chat_members` adds a person to a group chat or removes one. Adding
246
+ someone hands them the conversation, so:
247
+
248
+ - Resolve the person first (`teams_find_user`) when only a first name was given,
249
+ and say the full name in your reply. "Tom" is not an identity.
250
+ - A **1:1** chat cannot be extended — Graph refuses. Use `teams_create_chat`
251
+ with everyone who belongs in it.
252
+ - Removing someone needs an extra permission (`ChatMember.ReadWrite`); the tool
253
+ says so. Adding works with the default scopes.
254
+
255
+ ## Finding a time before booking one
256
+
257
+ `teams_availability` reads **free/busy**, never calendars: "is Tuesday at two
258
+ possible" is answerable, "what is Anna doing on Tuesday" is not, and must never
259
+ be presented as if it were.
260
+
261
+ 1. Run it with everybody who has to be there and the length of the meeting.
262
+ 2. Offer the user two or three of the slots it returns — not all of them.
263
+ 3. Only once they pick one, book it with `teams_create_meeting`.
264
+
265
+ Never book a slot because it looked free, and never dress the busy blocks up as
266
+ anything more than times: knowing that somebody is busy is the point, knowing why
267
+ is their business.
268
+
269
+ ## Answering an invitation
270
+
271
+ `teams_respond_invite` accepts, declines or marks an invitation as tentative,
272
+ and tells the organizer unless `sendResponse: false`. This fills the user's
273
+ calendar and answers another person, so it needs their decision — "say yes to
274
+ whatever looks relevant" is not one.
275
+
276
+ A meeting the user **organizes** has no invitation to answer: change it with
277
+ `teams_update_meeting`, cancel it with `teams_cancel_meeting`.
278
+
190
279
  ## Listen mode
191
280
 
192
281
  Listen mode is the only case where pi acts without being asked: it polls the
@@ -51,10 +51,13 @@ import {
51
51
  teamsReplyChannelMessageTool,
52
52
  teamsSendChannelMessageTool,
53
53
  } from "../tools/teams-send-channel-message.ts";
54
- import { teamsCreateChannelTool } from "../tools/teams-create-channel.ts";
55
54
  import { teamsListFilesTool } from "../tools/teams-list-files.ts";
56
55
  import { teamsSearchMessagesTool } from "../tools/teams-search-messages.ts";
57
56
  import { teamsReactTool } from "../tools/teams-react.ts";
57
+ import { teamsMarkReadTool } from "../tools/teams-mark-read.ts";
58
+ import { teamsUpdateMessageTool } from "../tools/teams-update-message.ts";
59
+ import { teamsChatMembersTool } from "../tools/teams-chat-members.ts";
60
+ import { teamsAvailabilityTool } from "../tools/teams-availability.ts";
58
61
  import { teamsDeleteMessageTool } from "../tools/teams-delete-message.ts";
59
62
  import { teamsInboxTool } from "../tools/teams-inbox.ts";
60
63
  import { teamsWatchTool } from "../tools/teams-watch.ts";
@@ -68,6 +71,7 @@ import {
68
71
  teamsCreateMeetingTool,
69
72
  teamsGetMeetingTool,
70
73
  teamsListMeetingsTool,
74
+ teamsRespondInviteTool,
71
75
  teamsUpdateMeetingTool,
72
76
  } from "../tools/teams-meetings.ts";
73
77
 
@@ -94,16 +98,18 @@ const tools = [
94
98
  teamsReadChatTool,
95
99
  teamsSendChatMessageTool,
96
100
  teamsCreateChatTool,
101
+ teamsMarkReadTool,
102
+ teamsChatMembersTool,
97
103
  // Channels
98
104
  teamsReadChannelTool,
99
105
  teamsReadThreadTool,
100
106
  teamsSendChannelMessageTool,
101
107
  teamsReplyChannelMessageTool,
102
- teamsCreateChannelTool,
103
108
  teamsListFilesTool,
104
109
  // Cross-cutting
105
110
  teamsSearchMessagesTool,
106
111
  teamsReactTool,
112
+ teamsUpdateMessageTool,
107
113
  teamsDeleteMessageTool,
108
114
  teamsInboxTool,
109
115
  // Presence
@@ -113,8 +119,10 @@ const tools = [
113
119
  // Calendar
114
120
  teamsListMeetingsTool,
115
121
  teamsGetMeetingTool,
122
+ teamsAvailabilityTool,
116
123
  teamsCreateMeetingTool,
117
124
  teamsUpdateMeetingTool,
125
+ teamsRespondInviteTool,
118
126
  teamsCancelMeetingTool,
119
127
  ];
120
128
 
@@ -143,6 +143,93 @@ export async function cancelEvent(
143
143
  }
144
144
  }
145
145
 
146
+ /** RSVP responses Graph accepts on an invitation. */
147
+ export type EventResponse = "accept" | "decline" | "tentativelyAccept";
148
+
149
+ /**
150
+ * Answer a meeting invitation.
151
+ *
152
+ * This is the calendar's equivalent of saying yes out loud: with
153
+ * `sendResponse` the organizer is notified, which is why the tool asks for
154
+ * confirmation rather than doing it quietly.
155
+ */
156
+ export async function respondToEvent(
157
+ conn: TeamsConnection,
158
+ eventId: string,
159
+ response: EventResponse,
160
+ options: { comment?: string; sendResponse?: boolean; signal?: AbortSignal } = {},
161
+ ): Promise<void> {
162
+ await graphPost(
163
+ conn,
164
+ `/me/events/${encodeURIComponent(eventId)}/${response}`,
165
+ { comment: options.comment, sendResponse: options.sendResponse !== false },
166
+ { signal: options.signal },
167
+ );
168
+ }
169
+
170
+ // ---------------------------------------------------------------------------
171
+ // Free / busy
172
+ // ---------------------------------------------------------------------------
173
+
174
+ /** One person's availability over the requested window. */
175
+ export interface FreeBusyWindow {
176
+ /** The address Graph matched the schedule to */
177
+ scheduleId: string;
178
+ /**
179
+ * One character per slot, starting at the window start:
180
+ * `0` free, `1` tentative, `2` busy, `3` out of office, `4` working elsewhere.
181
+ */
182
+ availabilityView: string;
183
+ /** Everything that was not free, with subject omitted on purpose */
184
+ busy: Array<{ status: string; start: string; end: string }>;
185
+ }
186
+
187
+ /**
188
+ * Free/busy for several people in one call.
189
+ *
190
+ * `getSchedule` is what a delegated token may read about another person's
191
+ * calendar — free/busy, not the appointments — and it is the reason
192
+ * availability works without the `.Shared` scopes that `findMeetingTimes`
193
+ * insists on. Subjects are reported by Graph but dropped here: knowing that
194
+ * somebody is busy is the point, knowing why is their business.
195
+ */
196
+ export async function getSchedule(
197
+ conn: TeamsConnection,
198
+ input: {
199
+ schedules: string[];
200
+ start: string;
201
+ end: string;
202
+ timeZone: string;
203
+ /** Slot width in minutes; Graph allows 5–1440 */
204
+ intervalMinutes?: number;
205
+ signal?: AbortSignal;
206
+ },
207
+ ): Promise<FreeBusyWindow[]> {
208
+ const raw = await graphPost<Raw>(conn, "/me/calendar/getSchedule", {
209
+ schedules: input.schedules,
210
+ startTime: { dateTime: input.start, timeZone: input.timeZone },
211
+ endTime: { dateTime: input.end, timeZone: input.timeZone },
212
+ availabilityViewInterval: Math.min(Math.max(input.intervalMinutes ?? 30, 5), 1440),
213
+ }, {
214
+ signal: input.signal,
215
+ // Without this the busy blocks come back in UTC while the view is indexed
216
+ // in the requested zone, and the two would not line up.
217
+ headers: { Prefer: `outlook.timezone="${input.timeZone}"` },
218
+ });
219
+
220
+ return (raw?.value ?? []).map((entry: Raw) => ({
221
+ scheduleId: entry.scheduleId ?? "",
222
+ availabilityView: entry.availabilityView ?? "",
223
+ busy: (entry.scheduleItems ?? [])
224
+ .filter((item: Raw) => item.status && item.status !== "free")
225
+ .map((item: Raw) => ({
226
+ status: item.status,
227
+ start: item.start?.dateTime ?? "",
228
+ end: item.end?.dateTime ?? "",
229
+ })),
230
+ }));
231
+ }
232
+
146
233
  /** Ad-hoc meeting link with no calendar entry. */
147
234
  export async function createOnlineMeeting(
148
235
  conn: TeamsConnection,
@@ -3,10 +3,10 @@
3
3
  */
4
4
 
5
5
  import type { TeamsConnection } from "../config/index.ts";
6
- import type { ChatSummary, PersonRef } from "../types.ts";
7
- import { graphGetOptional, graphList, graphPost } from "./client.ts";
6
+ import type { ChatMemberSummary, ChatSummary, PersonRef } from "../types.ts";
7
+ import { graphDelete, graphGetOptional, graphList, graphPost } from "./client.ts";
8
8
  import { GraphError } from "../utils/errors.ts";
9
- import { mapChat } from "./mappers.ts";
9
+ import { mapChat, mapChatMember } from "./mappers.ts";
10
10
  import { resolveUserId } from "./me.ts";
11
11
 
12
12
  /**
@@ -140,6 +140,137 @@ export async function createChat(
140
140
  return mapChat((raw ?? {}) as Record<string, never>);
141
141
  }
142
142
 
143
+ // ---------------------------------------------------------------------------
144
+ // Read state
145
+ // ---------------------------------------------------------------------------
146
+
147
+ /**
148
+ * Endpoint and body for the two read-cursor actions.
149
+ *
150
+ * Graph takes the user in the **body**, not in the URL: the delegated token
151
+ * says who is allowed to act, the body says whose read state changes. Kept as
152
+ * a pure function (like `deletePath`) because this is the part worth testing.
153
+ */
154
+ export function readStateRequest(
155
+ chatId: string,
156
+ user: { id: string; tenantId?: string },
157
+ read: boolean,
158
+ ): { path: string; body: Record<string, unknown> } {
159
+ return {
160
+ path: `/chats/${encodeURIComponent(chatId)}/${
161
+ read ? "markChatReadForUser" : "markChatUnreadForUser"
162
+ }`,
163
+ // `tenantId` may be undefined; JSON.stringify drops it, and Graph accepts
164
+ // the id alone.
165
+ body: { user: { id: user.id, tenantId: user.tenantId } },
166
+ };
167
+ }
168
+
169
+ /**
170
+ * Mark a chat as read — or unread again — for the signed-in user.
171
+ *
172
+ * This is the only read-state a delegated token may move, and it moves the
173
+ * same cursor the app moves when you open a chat: it does not touch what
174
+ * anyone else sees, but read receipts (where the tenant has them on) do make
175
+ * the chat look seen. Channel messages have no equivalent action.
176
+ */
177
+ export async function setChatReadState(
178
+ conn: TeamsConnection,
179
+ chatId: string,
180
+ user: { id: string; tenantId?: string },
181
+ read: boolean,
182
+ options: { signal?: AbortSignal } = {},
183
+ ): Promise<void> {
184
+ const { path, body } = readStateRequest(chatId, user, read);
185
+ await graphPost(conn, path, body, { signal: options.signal });
186
+ }
187
+
188
+ // ---------------------------------------------------------------------------
189
+ // Membership
190
+ // ---------------------------------------------------------------------------
191
+
192
+ /**
193
+ * The members of a chat, with the membership id each one is removed by.
194
+ *
195
+ * Read raw rather than through `mapChat`, because a `ChatSummary` keeps only
196
+ * the people (what a label and the scope rules need) and drops the membership
197
+ * id that removing someone requires.
198
+ */
199
+ export async function listChatMembers(
200
+ conn: TeamsConnection,
201
+ chatId: string,
202
+ options: { signal?: AbortSignal } = {},
203
+ ): Promise<ChatMemberSummary[]> {
204
+ const raw = await graphList<Record<string, never>>(
205
+ conn,
206
+ `/chats/${encodeURIComponent(chatId)}/members`,
207
+ { max: 100, signal: options.signal },
208
+ );
209
+ return raw.map((entry) => mapChatMember(entry));
210
+ }
211
+
212
+ /**
213
+ * Add a person to an existing chat.
214
+ *
215
+ * Graph binds the member by user id or UPN; a 1:1 chat cannot be extended (the
216
+ * Teams client converts it to a group chat, Graph refuses), so callers should
217
+ * check the chat type first.
218
+ */
219
+ export async function addChatMember(
220
+ conn: TeamsConnection,
221
+ chatId: string,
222
+ person: PersonRef,
223
+ options: { signal?: AbortSignal } = {},
224
+ ): Promise<void> {
225
+ const bind = person.id ?? person.upn ?? person.mail;
226
+ if (!bind) throw new Error(`Cannot add ${person.displayName}: no user id, UPN or e-mail address.`);
227
+
228
+ await graphPost(
229
+ conn,
230
+ `/chats/${encodeURIComponent(chatId)}/members`,
231
+ {
232
+ "@odata.type": "#microsoft.graph.aadUserConversationMember",
233
+ "user@odata.bind": `https://graph.microsoft.com/v1.0/users/${encodeURIComponent(bind)}`,
234
+ roles: [],
235
+ },
236
+ { signal: options.signal },
237
+ );
238
+ }
239
+
240
+ /** Remove one membership. Graph takes the membership id, not the user id. */
241
+ export async function removeChatMember(
242
+ conn: TeamsConnection,
243
+ chatId: string,
244
+ membershipId: string,
245
+ options: { signal?: AbortSignal } = {},
246
+ ): Promise<void> {
247
+ await graphDelete(
248
+ conn,
249
+ `/chats/${encodeURIComponent(chatId)}/members/${encodeURIComponent(membershipId)}`,
250
+ { signal: options.signal },
251
+ );
252
+ }
253
+
254
+ /**
255
+ * Find the membership of one person in a chat.
256
+ *
257
+ * Matching goes through the identifiers a caller can actually have: object id,
258
+ * UPN, e-mail, display name.
259
+ */
260
+ export function matchChatMember(
261
+ members: readonly ChatMemberSummary[],
262
+ reference: string,
263
+ ): ChatMemberSummary | undefined {
264
+ const needle = reference.trim().toLowerCase();
265
+ if (!needle) return undefined;
266
+
267
+ return members.find((member) =>
268
+ [member.userId, member.upn, member.mail, member.displayName]
269
+ .filter((value): value is string => !!value)
270
+ .some((value) => value.toLowerCase() === needle),
271
+ );
272
+ }
273
+
143
274
  /** Resolve people references (UPN, e-mail, name) to PersonRefs. */
144
275
  export async function resolveParticipants(
145
276
  conn: TeamsConnection,
@@ -9,6 +9,7 @@
9
9
 
10
10
  import type {
11
11
  ChannelSummary,
12
+ ChatMemberSummary,
12
13
  ChatSummary,
13
14
  DriveItemSummary,
14
15
  EventSummary,
@@ -67,6 +68,25 @@ export function mapMember(raw: Raw): MemberSummary {
67
68
  };
68
69
  }
69
70
 
71
+ /**
72
+ * A chat member.
73
+ *
74
+ * Graph exposes two identifiers per member and they are not interchangeable:
75
+ * the membership id is what removes someone, the user id is what says who they
76
+ * are. Keeping both here is cheaper than looking them up again later.
77
+ */
78
+ export function mapChatMember(raw: Raw): ChatMemberSummary {
79
+ const user = raw.user ?? raw;
80
+ return {
81
+ membershipId: raw.id ?? "",
82
+ userId: user?.id ?? undefined,
83
+ displayName: raw.displayName ?? user?.displayName ?? "(unknown)",
84
+ upn: user?.userPrincipalName ?? raw.userPrincipalName ?? undefined,
85
+ mail: user?.email ?? raw.email ?? undefined,
86
+ roles: Array.isArray(raw.roles) ? raw.roles : [],
87
+ };
88
+ }
89
+
70
90
  /** Identifiers a person can be matched against by the scope rules. */
71
91
  export function personCandidates(person: PersonRef | MemberSummary | undefined): (string | undefined)[] {
72
92
  if (!person) return [];