@patimweb/pi-teams 1.1.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 +137 -11
- package/docs/entra-app-registration.md +243 -0
- package/package.json +1 -1
- package/prompts/teams-schedule.md +23 -0
- package/screenshot.png +0 -0
- package/skills/teams-collaboration/SKILL.md +177 -2
- package/src/config/index.ts +145 -1
- package/src/extension/index.ts +186 -4
- 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 +64 -40
- package/src/safety/index.ts +17 -3
- package/src/status.ts +22 -1
- 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-send-channel-message.ts +7 -2
- package/src/tools/teams-send-chat-message.ts +7 -2
- package/src/tools/teams-update-message.ts +88 -0
- package/src/tools/teams-watch.ts +229 -0
- package/src/tools/tool-names.ts +5 -2
- package/src/types.ts +11 -0
- package/src/utils/errors.ts +1 -0
- package/src/utils/formatting.ts +62 -12
- package/src/utils/richtext.ts +150 -0
- package/src/utils/slots.ts +171 -0
- package/src/watch/index.ts +224 -0
- package/src/watch/loop.ts +304 -0
- package/src/watch/prompt.ts +59 -0
- package/src/tools/teams-create-channel.ts +0 -89
package/src/extension/index.ts
CHANGED
|
@@ -12,6 +12,7 @@ import { Box, Text } from "@earendil-works/pi-tui";
|
|
|
12
12
|
import {
|
|
13
13
|
ensureConfigTemplate,
|
|
14
14
|
getConfigPath,
|
|
15
|
+
setWatchConfig,
|
|
15
16
|
tryResolveConnection,
|
|
16
17
|
type TeamsConnection,
|
|
17
18
|
} from "../config/index.ts";
|
|
@@ -22,6 +23,13 @@ import {
|
|
|
22
23
|
isMutationTool,
|
|
23
24
|
LOCAL_CONFIG_TOOLS,
|
|
24
25
|
} from "../safety/index.ts";
|
|
26
|
+
import {
|
|
27
|
+
setActiveWatchLoop,
|
|
28
|
+
startWatchLoop,
|
|
29
|
+
stopActiveWatchLoop,
|
|
30
|
+
type WatchLoop,
|
|
31
|
+
} from "../watch/loop.ts";
|
|
32
|
+
import { composeWatchPrompt } from "../watch/prompt.ts";
|
|
25
33
|
|
|
26
34
|
import { teamsSetupTool } from "../tools/teams-setup.ts";
|
|
27
35
|
import { teamsLoginTool, teamsLogoutTool } from "../tools/teams-login.ts";
|
|
@@ -43,12 +51,16 @@ import {
|
|
|
43
51
|
teamsReplyChannelMessageTool,
|
|
44
52
|
teamsSendChannelMessageTool,
|
|
45
53
|
} from "../tools/teams-send-channel-message.ts";
|
|
46
|
-
import { teamsCreateChannelTool } from "../tools/teams-create-channel.ts";
|
|
47
54
|
import { teamsListFilesTool } from "../tools/teams-list-files.ts";
|
|
48
55
|
import { teamsSearchMessagesTool } from "../tools/teams-search-messages.ts";
|
|
49
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";
|
|
50
61
|
import { teamsDeleteMessageTool } from "../tools/teams-delete-message.ts";
|
|
51
62
|
import { teamsInboxTool } from "../tools/teams-inbox.ts";
|
|
63
|
+
import { teamsWatchTool } from "../tools/teams-watch.ts";
|
|
52
64
|
import {
|
|
53
65
|
teamsGetPresenceTool,
|
|
54
66
|
teamsSetPresenceTool,
|
|
@@ -59,6 +71,7 @@ import {
|
|
|
59
71
|
teamsCreateMeetingTool,
|
|
60
72
|
teamsGetMeetingTool,
|
|
61
73
|
teamsListMeetingsTool,
|
|
74
|
+
teamsRespondInviteTool,
|
|
62
75
|
teamsUpdateMeetingTool,
|
|
63
76
|
} from "../tools/teams-meetings.ts";
|
|
64
77
|
|
|
@@ -73,6 +86,7 @@ const tools = [
|
|
|
73
86
|
teamsAccountsTool,
|
|
74
87
|
teamsStatusTool,
|
|
75
88
|
teamsPermissionsTool,
|
|
89
|
+
teamsWatchTool,
|
|
76
90
|
teamsDoctorTool,
|
|
77
91
|
// Discovery
|
|
78
92
|
teamsListTeamsTool,
|
|
@@ -84,16 +98,18 @@ const tools = [
|
|
|
84
98
|
teamsReadChatTool,
|
|
85
99
|
teamsSendChatMessageTool,
|
|
86
100
|
teamsCreateChatTool,
|
|
101
|
+
teamsMarkReadTool,
|
|
102
|
+
teamsChatMembersTool,
|
|
87
103
|
// Channels
|
|
88
104
|
teamsReadChannelTool,
|
|
89
105
|
teamsReadThreadTool,
|
|
90
106
|
teamsSendChannelMessageTool,
|
|
91
107
|
teamsReplyChannelMessageTool,
|
|
92
|
-
teamsCreateChannelTool,
|
|
93
108
|
teamsListFilesTool,
|
|
94
109
|
// Cross-cutting
|
|
95
110
|
teamsSearchMessagesTool,
|
|
96
111
|
teamsReactTool,
|
|
112
|
+
teamsUpdateMessageTool,
|
|
97
113
|
teamsDeleteMessageTool,
|
|
98
114
|
teamsInboxTool,
|
|
99
115
|
// Presence
|
|
@@ -103,8 +119,10 @@ const tools = [
|
|
|
103
119
|
// Calendar
|
|
104
120
|
teamsListMeetingsTool,
|
|
105
121
|
teamsGetMeetingTool,
|
|
122
|
+
teamsAvailabilityTool,
|
|
106
123
|
teamsCreateMeetingTool,
|
|
107
124
|
teamsUpdateMeetingTool,
|
|
125
|
+
teamsRespondInviteTool,
|
|
108
126
|
teamsCancelMeetingTool,
|
|
109
127
|
];
|
|
110
128
|
|
|
@@ -118,6 +136,74 @@ export default function (pi: ExtensionAPI) {
|
|
|
118
136
|
return connection;
|
|
119
137
|
};
|
|
120
138
|
|
|
139
|
+
// -----------------------------------------------------------------------
|
|
140
|
+
// Listen mode
|
|
141
|
+
// -----------------------------------------------------------------------
|
|
142
|
+
|
|
143
|
+
/** The watcher running in this session, when listen mode is on. */
|
|
144
|
+
let watchLoop: WatchLoop | undefined;
|
|
145
|
+
|
|
146
|
+
/** The last polling error the user was told about, so it is said once. */
|
|
147
|
+
let reportedWatchError: string | undefined;
|
|
148
|
+
|
|
149
|
+
const stopWatch = () => {
|
|
150
|
+
watchLoop?.stop();
|
|
151
|
+
watchLoop = undefined;
|
|
152
|
+
setActiveWatchLoop(undefined);
|
|
153
|
+
};
|
|
154
|
+
|
|
155
|
+
/**
|
|
156
|
+
* Start the watcher for the session's account, if the config asks for it.
|
|
157
|
+
*
|
|
158
|
+
* Restarting is the same call: the old loop is stopped first, so a config
|
|
159
|
+
* change cannot leave two pollers running.
|
|
160
|
+
*/
|
|
161
|
+
const startWatch = (ctx: any) => {
|
|
162
|
+
stopWatch();
|
|
163
|
+
|
|
164
|
+
const conn = refresh();
|
|
165
|
+
if (!conn?.watch.enabled) return;
|
|
166
|
+
|
|
167
|
+
reportedWatchError = undefined;
|
|
168
|
+
const loop = startWatchLoop({
|
|
169
|
+
connection: conn,
|
|
170
|
+
onWake: (event) => {
|
|
171
|
+
pi.sendUserMessage(composeWatchPrompt(event, event.me), { deliverAs: "followUp" });
|
|
172
|
+
},
|
|
173
|
+
onTick: (status) => {
|
|
174
|
+
if (!status.lastError) reportedWatchError = undefined;
|
|
175
|
+
},
|
|
176
|
+
onError: (message) => {
|
|
177
|
+
// A watcher that fails every tick would otherwise say so every minute.
|
|
178
|
+
if (message === reportedWatchError) return;
|
|
179
|
+
reportedWatchError = message;
|
|
180
|
+
ctx.ui.notify(`Teams listen mode: ${message}`, "warning");
|
|
181
|
+
},
|
|
182
|
+
});
|
|
183
|
+
|
|
184
|
+
watchLoop = loop;
|
|
185
|
+
setActiveWatchLoop(loop);
|
|
186
|
+
};
|
|
187
|
+
|
|
188
|
+
/**
|
|
189
|
+
* Paint the footer status line from the current connection.
|
|
190
|
+
*
|
|
191
|
+
* The only place the status line is written, so the three surfaces —
|
|
192
|
+
* session start, `/teams-status`, and a tool result — cannot drift apart.
|
|
193
|
+
*/
|
|
194
|
+
const paintStatus = (ctx: any) => {
|
|
195
|
+
const card = buildConnectionCard(refresh());
|
|
196
|
+
if (!card) return;
|
|
197
|
+
const watching = !!watchLoop?.status().running;
|
|
198
|
+
ctx.ui.setStatus(
|
|
199
|
+
"teams",
|
|
200
|
+
ctx.ui.theme.fg(
|
|
201
|
+
card.signedIn ? "success" : "warning",
|
|
202
|
+
buildConnectionLabel({ ...card, watching }),
|
|
203
|
+
),
|
|
204
|
+
);
|
|
205
|
+
};
|
|
206
|
+
|
|
121
207
|
// -----------------------------------------------------------------------
|
|
122
208
|
// Status card — rendered in the transcript, never sent to the model
|
|
123
209
|
// -----------------------------------------------------------------------
|
|
@@ -162,7 +248,7 @@ export default function (pi: ExtensionAPI) {
|
|
|
162
248
|
return;
|
|
163
249
|
}
|
|
164
250
|
pi.appendEntry<ConnectionCard>("teams-connection", card);
|
|
165
|
-
|
|
251
|
+
paintStatus(ctx);
|
|
166
252
|
ctx.ui.notify(buildConnectionLabel(card), "info");
|
|
167
253
|
},
|
|
168
254
|
});
|
|
@@ -188,6 +274,56 @@ export default function (pi: ExtensionAPI) {
|
|
|
188
274
|
},
|
|
189
275
|
});
|
|
190
276
|
|
|
277
|
+
pi.registerCommand("teams-listen", {
|
|
278
|
+
description: "Let pi listen to Teams: on, off, or status (default)",
|
|
279
|
+
handler: async (args, ctx) => {
|
|
280
|
+
const conn = refresh();
|
|
281
|
+
if (!conn) {
|
|
282
|
+
ctx.ui.notify(
|
|
283
|
+
`Teams: no account configured yet. Use the teams_setup tool, or edit ${getConfigPath()}.`,
|
|
284
|
+
"warning",
|
|
285
|
+
);
|
|
286
|
+
return;
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
const action = args.trim().toLowerCase() || "status";
|
|
290
|
+
|
|
291
|
+
if (action === "on" || action === "off") {
|
|
292
|
+
// Written to the account, not globally: the watcher listens as one
|
|
293
|
+
// identity, and "which account" must not be a guess.
|
|
294
|
+
const watch = setWatchConfig({ enabled: action === "on" }, conn.account);
|
|
295
|
+
startWatch(ctx);
|
|
296
|
+
paintStatus(ctx);
|
|
297
|
+
ctx.ui.notify(
|
|
298
|
+
watch.enabled
|
|
299
|
+
? `Teams listen mode on — polling every ${watch.intervalSeconds} s, max ${watch.maxTriggersPerHour} wakes/hour.`
|
|
300
|
+
: "Teams listen mode off.",
|
|
301
|
+
"info",
|
|
302
|
+
);
|
|
303
|
+
return;
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
if (action !== "status") {
|
|
307
|
+
ctx.ui.notify(`Unknown argument "${action}". Use on, off, or status.`, "warning");
|
|
308
|
+
return;
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
const runtime = watchLoop?.status();
|
|
312
|
+
pi.appendEntry<ConnectionCard>("teams-connection", { ...buildConnectionCard(conn)!, watching: !!runtime?.running });
|
|
313
|
+
ctx.ui.notify(
|
|
314
|
+
[
|
|
315
|
+
`Listen mode: ${conn.watch.enabled ? "on" : "off"}`,
|
|
316
|
+
`every ${conn.watch.intervalSeconds} s`,
|
|
317
|
+
conn.watch.chats.length > 0 ? `chats: ${conn.watch.chats.join(", ")}` : "all recent chats",
|
|
318
|
+
conn.watch.from.length > 0 ? `people: ${conn.watch.from.join(", ")}` : "any sender",
|
|
319
|
+
conn.watch.mentionOnly ? "mentions only" : "every message",
|
|
320
|
+
runtime ? `${runtime.wakesThisHour} wake(s) this hour` : "not running in this session",
|
|
321
|
+
].join(" · "),
|
|
322
|
+
"info",
|
|
323
|
+
);
|
|
324
|
+
},
|
|
325
|
+
});
|
|
326
|
+
|
|
191
327
|
pi.registerCommand("teams-inbox", {
|
|
192
328
|
description: "Show what needs your attention in Microsoft Teams",
|
|
193
329
|
handler: async (_args, ctx) => {
|
|
@@ -246,7 +382,8 @@ export default function (pi: ExtensionAPI) {
|
|
|
246
382
|
const card = buildConnectionCard(connection);
|
|
247
383
|
if (!card) return;
|
|
248
384
|
|
|
249
|
-
|
|
385
|
+
paintStatus(ctx);
|
|
386
|
+
startWatch(ctx);
|
|
250
387
|
|
|
251
388
|
if (!card.signedIn) {
|
|
252
389
|
ctx.ui.notify(
|
|
@@ -294,6 +431,16 @@ export default function (pi: ExtensionAPI) {
|
|
|
294
431
|
});
|
|
295
432
|
}
|
|
296
433
|
|
|
434
|
+
// -----------------------------------------------------------------------
|
|
435
|
+
// Listen mode lifecycle
|
|
436
|
+
// -----------------------------------------------------------------------
|
|
437
|
+
|
|
438
|
+
pi.on("session_shutdown", () => {
|
|
439
|
+
// The timer would be unref'd anyway, but a poll in flight during shutdown
|
|
440
|
+
// would still be a Graph call for a session that is gone.
|
|
441
|
+
stopWatch();
|
|
442
|
+
});
|
|
443
|
+
|
|
297
444
|
// -----------------------------------------------------------------------
|
|
298
445
|
// Safety interceptor
|
|
299
446
|
// -----------------------------------------------------------------------
|
|
@@ -333,4 +480,39 @@ export default function (pi: ExtensionAPI) {
|
|
|
333
480
|
if (!approved) return { block: true, reason: `User declined: ${summary}` };
|
|
334
481
|
}
|
|
335
482
|
});
|
|
483
|
+
|
|
484
|
+
// -----------------------------------------------------------------------
|
|
485
|
+
// Status after a tool ran
|
|
486
|
+
// -----------------------------------------------------------------------
|
|
487
|
+
|
|
488
|
+
/**
|
|
489
|
+
* The status line is a snapshot of session start — but every change to the
|
|
490
|
+
* connection happens afterwards, in a tool, and tools have no UI context of
|
|
491
|
+
* their own. Without this the footer would still read "not signed in" after a
|
|
492
|
+
* successful login, and "listening" after listen mode was switched off.
|
|
493
|
+
*/
|
|
494
|
+
const STATUS_TOOLS = new Set([
|
|
495
|
+
"teams_login",
|
|
496
|
+
"teams_logout",
|
|
497
|
+
"teams_setup",
|
|
498
|
+
"teams_accounts",
|
|
499
|
+
"teams_watch",
|
|
500
|
+
]);
|
|
501
|
+
|
|
502
|
+
/** Tools after which the watcher has to be re-read from the config. */
|
|
503
|
+
const WATCH_CONFIG_TOOLS = new Set(["teams_watch", "teams_setup", "teams_accounts"]);
|
|
504
|
+
|
|
505
|
+
pi.on("tool_result", async (event, ctx) => {
|
|
506
|
+
if (!STATUS_TOOLS.has(event.toolName) || event.isError) return;
|
|
507
|
+
|
|
508
|
+
if (event.toolName === "teams_logout") {
|
|
509
|
+
// A watcher polling a session that was just removed only produces
|
|
510
|
+
// errors; leaving it running would be noise, not service.
|
|
511
|
+
stopWatch();
|
|
512
|
+
} else if (WATCH_CONFIG_TOOLS.has(event.toolName)) {
|
|
513
|
+
startWatch(ctx);
|
|
514
|
+
}
|
|
515
|
+
|
|
516
|
+
paintStatus(ctx);
|
|
517
|
+
});
|
|
336
518
|
}
|
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 [];
|