@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 +22 -4
- package/docs/entra-app-registration.md +25 -0
- package/package.json +1 -1
- package/prompts/teams-schedule.md +23 -0
- package/screenshot.png +0 -0
- package/skills/teams-collaboration/SKILL.md +91 -2
- package/src/extension/index.ts +10 -2
- package/src/graph/calendar.ts +87 -0
- package/src/graph/chats.ts +134 -3
- package/src/graph/mappers.ts +20 -0
- package/src/graph/messages.ts +50 -36
- package/src/safety/index.ts +16 -2
- package/src/tools/shared.ts +24 -0
- package/src/tools/teams-availability.ts +224 -0
- package/src/tools/teams-chat-members.ts +148 -0
- package/src/tools/teams-delete-message.ts +21 -48
- package/src/tools/teams-inbox.ts +1 -0
- package/src/tools/teams-mark-read.ts +84 -0
- package/src/tools/teams-meetings.ts +98 -0
- package/src/tools/teams-update-message.ts +88 -0
- package/src/tools/tool-names.ts +4 -2
- package/src/types.ts +11 -0
- package/src/utils/errors.ts +1 -0
- package/src/utils/formatting.ts +62 -0
- package/src/utils/slots.ts +171 -0
- package/src/tools/teams-create-channel.ts +0 -89
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
|
+

|
|
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
|
|
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,
|
|
576
|
-
rules, the safety gates — and run without a tenant or a
|
|
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.
|
|
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
|
|
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
|
package/src/extension/index.ts
CHANGED
|
@@ -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
|
|
package/src/graph/calendar.ts
CHANGED
|
@@ -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,
|
package/src/graph/chats.ts
CHANGED
|
@@ -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,
|
package/src/graph/mappers.ts
CHANGED
|
@@ -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 [];
|