@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/graph/messages.ts
CHANGED
|
@@ -9,9 +9,9 @@
|
|
|
9
9
|
|
|
10
10
|
import type { TeamsConnection } from "../config/index.ts";
|
|
11
11
|
import type { ChannelSummary, MessageLocation, MessageSummary, PersonRef } from "../types.ts";
|
|
12
|
-
import { graphGetOptional, graphList, graphPost } from "./client.ts";
|
|
12
|
+
import { graphGetOptional, graphList, graphPatch, graphPost } from "./client.ts";
|
|
13
13
|
import { mapMessage } from "./mappers.ts";
|
|
14
|
-
import {
|
|
14
|
+
import { markdownToTeamsHtml } from "../utils/richtext.ts";
|
|
15
15
|
|
|
16
16
|
/* eslint-disable @typescript-eslint/no-explicit-any */
|
|
17
17
|
type Raw = Record<string, any>;
|
|
@@ -21,9 +21,15 @@ type Raw = Record<string, any>;
|
|
|
21
21
|
// ---------------------------------------------------------------------------
|
|
22
22
|
|
|
23
23
|
export interface MessageInput {
|
|
24
|
-
/**
|
|
24
|
+
/**
|
|
25
|
+
* Message text.
|
|
26
|
+
*
|
|
27
|
+
* Treated as lightweight markdown by default — bold, italic, code, bullets,
|
|
28
|
+
* numbered lists, links and `# headings` are converted to the HTML Teams
|
|
29
|
+
* renders. Set `html` to true to send raw HTML instead.
|
|
30
|
+
*/
|
|
25
31
|
body: string;
|
|
26
|
-
/** Treat `body` as raw HTML instead of
|
|
32
|
+
/** Treat `body` as raw HTML instead of converting markdown */
|
|
27
33
|
html?: boolean;
|
|
28
34
|
/** Channel messages only */
|
|
29
35
|
subject?: string;
|
|
@@ -39,10 +45,14 @@ export interface MessageInput {
|
|
|
39
45
|
* Mentions are the fiddly part: Teams requires an `<at id="N">` span in the
|
|
40
46
|
* HTML *and* a matching entry in the `mentions` array. The display name is
|
|
41
47
|
* replaced in the text so the caller can just write "@Anna Schmidt".
|
|
48
|
+
*
|
|
49
|
+
* Markdown is converted unless the caller says the body is already HTML: what
|
|
50
|
+
* comes back from a model is markdown, and markdown sent raw arrives as
|
|
51
|
+
* asterisks and dashes.
|
|
42
52
|
*/
|
|
43
53
|
export function buildMessageBody(input: MessageInput): Record<string, unknown> {
|
|
44
54
|
const mentions = input.mentions ?? [];
|
|
45
|
-
let content = input.html ? input.body :
|
|
55
|
+
let content = input.html ? input.body : markdownToTeamsHtml(input.body);
|
|
46
56
|
|
|
47
57
|
const mentionEntries: Record<string, unknown>[] = [];
|
|
48
58
|
mentions.forEach((person, index) => {
|
|
@@ -282,62 +292,76 @@ export async function replyToChannelMessage(
|
|
|
282
292
|
}
|
|
283
293
|
|
|
284
294
|
/**
|
|
285
|
-
* Path
|
|
295
|
+
* Path to one message in a chat — the base for editing and deleting.
|
|
296
|
+
*
|
|
297
|
+
* The user id is explicit: Graph does not accept `/me` in this position, which
|
|
298
|
+
* is why every caller resolves the signed-in user first.
|
|
286
299
|
*
|
|
287
|
-
*
|
|
288
|
-
*
|
|
300
|
+
* There is no channel form. Editing or deleting a channel post needs the
|
|
301
|
+
* `ChannelMessage.ReadWrite` scope, and this package deliberately does not ask
|
|
302
|
+
* for a permission it cannot rely on being granted.
|
|
289
303
|
*/
|
|
290
|
-
export function
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
)
|
|
296
|
-
if (location.kind === "chat") {
|
|
297
|
-
return (
|
|
298
|
-
`/users/${encodeURIComponent(userId)}` +
|
|
299
|
-
`/chats/${encodeURIComponent(location.chatId!)}` +
|
|
300
|
-
`/messages/${encodeURIComponent(messageId)}`
|
|
301
|
-
);
|
|
302
|
-
}
|
|
303
|
-
const base = `${channelPath(location.teamId!, location.channelId!)}/${encodeURIComponent(messageId)}`;
|
|
304
|
-
return replyId ? `${base}/replies/${encodeURIComponent(replyId)}` : base;
|
|
304
|
+
export function messagePath(chatId: string, messageId: string, userId: string): string {
|
|
305
|
+
return (
|
|
306
|
+
`/users/${encodeURIComponent(userId)}` +
|
|
307
|
+
`/chats/${encodeURIComponent(chatId)}` +
|
|
308
|
+
`/messages/${encodeURIComponent(messageId)}`
|
|
309
|
+
);
|
|
305
310
|
}
|
|
306
311
|
|
|
307
312
|
/**
|
|
308
|
-
* Delete one of the signed-in user's own messages (soft delete, as in the app).
|
|
313
|
+
* Delete one of the signed-in user's own chat messages (soft delete, as in the app).
|
|
309
314
|
*
|
|
310
315
|
* Graph exposes this as an action, not an HTTP DELETE, and only for the sender.
|
|
311
316
|
*/
|
|
312
317
|
export async function softDeleteMessage(
|
|
313
318
|
conn: TeamsConnection,
|
|
314
|
-
|
|
319
|
+
chatId: string,
|
|
315
320
|
messageId: string,
|
|
316
321
|
userId: string,
|
|
317
|
-
options: {
|
|
322
|
+
options: { signal?: AbortSignal } = {},
|
|
318
323
|
): Promise<void> {
|
|
319
|
-
await graphPost(
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
undefined,
|
|
323
|
-
{ signal: options.signal },
|
|
324
|
-
);
|
|
324
|
+
await graphPost(conn, `${messagePath(chatId, messageId, userId)}/softDelete`, undefined, {
|
|
325
|
+
signal: options.signal,
|
|
326
|
+
});
|
|
325
327
|
}
|
|
326
328
|
|
|
327
329
|
/** Restore a message that was soft-deleted. */
|
|
328
330
|
export async function undoSoftDeleteMessage(
|
|
329
331
|
conn: TeamsConnection,
|
|
330
|
-
|
|
332
|
+
chatId: string,
|
|
331
333
|
messageId: string,
|
|
332
334
|
userId: string,
|
|
333
|
-
options: {
|
|
335
|
+
options: { signal?: AbortSignal } = {},
|
|
334
336
|
): Promise<void> {
|
|
335
|
-
await graphPost(
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
337
|
+
await graphPost(conn, `${messagePath(chatId, messageId, userId)}/undoSoftDelete`, undefined, {
|
|
338
|
+
signal: options.signal,
|
|
339
|
+
});
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
// ---------------------------------------------------------------------------
|
|
343
|
+
// Editing
|
|
344
|
+
// ---------------------------------------------------------------------------
|
|
345
|
+
|
|
346
|
+
/**
|
|
347
|
+
* Rewrite one of the signed-in user's own chat messages.
|
|
348
|
+
*
|
|
349
|
+
* Only the body is patched: an edit replaces the text as it stands, and sending
|
|
350
|
+
* a fresh set of mention entities would silently re-notify people who were
|
|
351
|
+
* already notified by the original message.
|
|
352
|
+
*/
|
|
353
|
+
export async function updateMessage(
|
|
354
|
+
conn: TeamsConnection,
|
|
355
|
+
chatId: string,
|
|
356
|
+
messageId: string,
|
|
357
|
+
userId: string,
|
|
358
|
+
input: { body: string; html?: boolean },
|
|
359
|
+
options: { signal?: AbortSignal } = {},
|
|
360
|
+
): Promise<void> {
|
|
361
|
+
const { body } = buildMessageBody({ body: input.body, html: input.html });
|
|
362
|
+
await graphPatch(conn, messagePath(chatId, messageId, userId), { body }, {
|
|
363
|
+
signal: options.signal,
|
|
364
|
+
});
|
|
341
365
|
}
|
|
342
366
|
|
|
343
367
|
// ---------------------------------------------------------------------------
|
package/src/safety/index.ts
CHANGED
|
@@ -39,7 +39,7 @@ export function isMutationTool(toolName: string): boolean {
|
|
|
39
39
|
* needs a way to add an account or sign out. They also work before anything is
|
|
40
40
|
* configured at all, which is the whole point of `teams_setup`.
|
|
41
41
|
*/
|
|
42
|
-
export const LOCAL_CONFIG_TOOLS = new Set<string>(["teams_setup", "teams_logout"]);
|
|
42
|
+
export const LOCAL_CONFIG_TOOLS = new Set<string>(["teams_setup", "teams_logout", "teams_watch"]);
|
|
43
43
|
|
|
44
44
|
/**
|
|
45
45
|
* Why a mutation must be refused outright, or undefined when it may proceed
|
|
@@ -82,6 +82,14 @@ const APP_ONLY_FORBIDDEN = new Set<string>([
|
|
|
82
82
|
"teams_create_chat",
|
|
83
83
|
"teams_delete_message",
|
|
84
84
|
"teams_react",
|
|
85
|
+
// The read-cursor actions are delegated-only in Graph ("Application: not
|
|
86
|
+
// supported"), so an app-only token gets a 403 with no explanation.
|
|
87
|
+
"teams_mark_read",
|
|
88
|
+
// An app-only token may only touch messages for migration and DLP, and a
|
|
89
|
+
// membership change with no person behind it is exactly the kind of write
|
|
90
|
+
// this gate exists to stop.
|
|
91
|
+
"teams_update_message",
|
|
92
|
+
"teams_chat_members",
|
|
85
93
|
"teams_set_presence",
|
|
86
94
|
"teams_set_status_message",
|
|
87
95
|
]);
|
|
@@ -120,12 +128,18 @@ export function formatMutationSummary(
|
|
|
120
128
|
const people = Array.isArray(params.participants) ? params.participants.join(", ") : text(params.participants);
|
|
121
129
|
return `Create a new chat with: ${text(people, 200)}`;
|
|
122
130
|
}
|
|
123
|
-
case "teams_create_channel":
|
|
124
|
-
return `Create channel "${text(params.name, 60)}" in team "${text(params.team, 60)}"`;
|
|
125
131
|
case "teams_delete_message":
|
|
126
132
|
return `Delete your message ${text(params.messageId, 60)}`;
|
|
127
133
|
case "teams_react":
|
|
128
134
|
return `${params.remove ? "Remove" : "Add"} reaction ${text(params.reaction, 10)} on message ${text(params.messageId, 50)}`;
|
|
135
|
+
case "teams_mark_read":
|
|
136
|
+
return `${params.read === false ? "Mark as unread" : "Mark as read"}: chat "${text(params.chat, 60)}"`;
|
|
137
|
+
case "teams_update_message":
|
|
138
|
+
return `Edit your message ${text(params.messageId, 50)} in ${text(params.chat ?? params.channel, 60)}:\n\n${text(params.body, 400)}`;
|
|
139
|
+
case "teams_chat_members":
|
|
140
|
+
return `${params.action === "remove" ? "Remove" : "Add"} ${text(params.person, 80)} ${params.action === "remove" ? "from" : "to"} the chat "${text(params.chat, 60)}"`;
|
|
141
|
+
case "teams_respond_invite":
|
|
142
|
+
return `${text(params.response, 20)} the invitation ${text(params.eventId, 50)}${params.comment ? `: "${text(params.comment, 160)}"` : ""}`;
|
|
129
143
|
case "teams_set_presence":
|
|
130
144
|
return `Set your Teams presence to ${text(params.availability, 30)}${params.expiresIn ? ` for ${text(params.expiresIn, 20)}` : ""}`;
|
|
131
145
|
case "teams_set_status_message":
|
package/src/status.ts
CHANGED
|
@@ -27,6 +27,10 @@ export interface ConnectionCard {
|
|
|
27
27
|
otherAccounts: string[];
|
|
28
28
|
/** Rendered scope rules */
|
|
29
29
|
permissionSummary: string;
|
|
30
|
+
/** Listen mode is switched on */
|
|
31
|
+
watching?: boolean;
|
|
32
|
+
/** One line on what the watcher is doing, when it runs */
|
|
33
|
+
watchSummary?: string;
|
|
30
34
|
}
|
|
31
35
|
|
|
32
36
|
export function buildConnectionCard(
|
|
@@ -50,6 +54,19 @@ export function buildConnectionCard(
|
|
|
50
54
|
expiresAt: cache.expiresAt ? new Date(cache.expiresAt).toISOString() : undefined,
|
|
51
55
|
otherAccounts: conn.allAccounts.map((a) => a.name).filter((name) => name !== conn.account),
|
|
52
56
|
permissionSummary: formatPermissions(conn.permissions),
|
|
57
|
+
// What the configuration asks for; a caller that knows whether the watcher
|
|
58
|
+
// is actually running overrides this with the runtime truth.
|
|
59
|
+
watching: conn.watch.enabled,
|
|
60
|
+
watchSummary: conn.watch.enabled
|
|
61
|
+
? [
|
|
62
|
+
`every ${conn.watch.intervalSeconds} s`,
|
|
63
|
+
conn.watch.chats.length > 0 ? conn.watch.chats.join(", ") : "all recent chats",
|
|
64
|
+
conn.watch.from.length > 0 ? `from ${conn.watch.from.join(", ")}` : undefined,
|
|
65
|
+
`max ${conn.watch.maxTriggersPerHour}/h`,
|
|
66
|
+
]
|
|
67
|
+
.filter(Boolean)
|
|
68
|
+
.join(" · ")
|
|
69
|
+
: undefined,
|
|
53
70
|
};
|
|
54
71
|
}
|
|
55
72
|
|
|
@@ -59,7 +76,8 @@ export function buildConnectionLabel(card: ConnectionCard | undefined): string {
|
|
|
59
76
|
if (!card.signedIn) return `Teams · ${card.account} · not signed in`;
|
|
60
77
|
|
|
61
78
|
const scope = card.tenant === card.account ? card.account : `${card.account}/${card.tenant}`;
|
|
62
|
-
|
|
79
|
+
const listening = card.watching ? " · 👂 listening" : "";
|
|
80
|
+
return `✓ Teams · ${scope}${card.user ? ` · ${card.user}` : ""}${listening}`;
|
|
63
81
|
}
|
|
64
82
|
|
|
65
83
|
/** Rich markdown for `/teams-status` and the expanded card. */
|
|
@@ -98,6 +116,9 @@ export function formatStatusText(card: ConnectionCard | undefined): string {
|
|
|
98
116
|
if (card.otherAccounts.length > 0) {
|
|
99
117
|
lines.push(`- **Other accounts:** ${card.otherAccounts.join(", ")}`);
|
|
100
118
|
}
|
|
119
|
+
lines.push(
|
|
120
|
+
`- **Listen mode:** ${card.watching ? `on — ${card.watchSummary ?? "running"}` : "off"}`,
|
|
121
|
+
);
|
|
101
122
|
|
|
102
123
|
lines.push("", "### What pi may do", "", card.permissionSummary);
|
|
103
124
|
return lines.join("\n");
|
package/src/tools/shared.ts
CHANGED
|
@@ -88,6 +88,30 @@ export function connectionFor(
|
|
|
88
88
|
return resolveConnection(account, tenant);
|
|
89
89
|
}
|
|
90
90
|
|
|
91
|
+
// ---------------------------------------------------------------------------
|
|
92
|
+
// Consent gates
|
|
93
|
+
// ---------------------------------------------------------------------------
|
|
94
|
+
|
|
95
|
+
/**
|
|
96
|
+
* Whether this account asks for a delegated scope.
|
|
97
|
+
*
|
|
98
|
+
* A delegated scope cannot be granted unless it was requested, so the
|
|
99
|
+
* configuration is a reliable gate: if it is missing here, the Graph call
|
|
100
|
+
* cannot succeed, and its failure would be a consent error that says nothing
|
|
101
|
+
* about the one-line fix.
|
|
102
|
+
*/
|
|
103
|
+
export function hasScope(conn: TeamsConnection, scope: string): boolean {
|
|
104
|
+
return conn.scopes.some((entry) => entry.toLowerCase() === scope.toLowerCase());
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
/** The message to return when a tool needs a scope the account does not request. */
|
|
108
|
+
export function missingScopeError(scope: string, feature: string): string {
|
|
109
|
+
return (
|
|
110
|
+
`${feature} needs the Microsoft Graph scope "${scope}", which this account does not request. ` +
|
|
111
|
+
`Add it to "scopes" in pi-teams.json (or have an admin grant it in Entra ID) and run teams_login again.`
|
|
112
|
+
);
|
|
113
|
+
}
|
|
114
|
+
|
|
91
115
|
// ---------------------------------------------------------------------------
|
|
92
116
|
// Identity cache
|
|
93
117
|
// ---------------------------------------------------------------------------
|
|
@@ -0,0 +1,224 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* teams_availability — when everybody is free.
|
|
3
|
+
*
|
|
4
|
+
* Reads *free/busy*, not calendars: `getSchedule` is what a delegated token may
|
|
5
|
+
* see about a colleague's day, and it is also the reason this works with the
|
|
6
|
+
* scopes already granted — `findMeetingTimes` demands `Calendars.Read.Shared`,
|
|
7
|
+
* a scope nobody has consented to just to ask "Tuesday at two?".
|
|
8
|
+
*
|
|
9
|
+
* Scheduling is then a second step on purpose. This tool answers the question;
|
|
10
|
+
* `teams_create_meeting` acts on it once the user has agreed to a slot.
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
import { Type } from "typebox";
|
|
14
|
+
import { getSchedule } from "../graph/calendar.ts";
|
|
15
|
+
import { resolveUserId } from "../graph/me.ts";
|
|
16
|
+
import { assertAccess } from "../safety/index.ts";
|
|
17
|
+
import {
|
|
18
|
+
formatNaiveDay,
|
|
19
|
+
formatSlotRange,
|
|
20
|
+
truncate,
|
|
21
|
+
} from "../utils/formatting.ts";
|
|
22
|
+
import {
|
|
23
|
+
describeBusy,
|
|
24
|
+
findCommonFreeSlots,
|
|
25
|
+
localDayStart,
|
|
26
|
+
localNow,
|
|
27
|
+
localTimeZone,
|
|
28
|
+
type BusyView,
|
|
29
|
+
} from "../utils/slots.ts";
|
|
30
|
+
import {
|
|
31
|
+
AccountParam,
|
|
32
|
+
LimitParam,
|
|
33
|
+
TenantParam,
|
|
34
|
+
connectionFor,
|
|
35
|
+
currentUser,
|
|
36
|
+
run,
|
|
37
|
+
textResult,
|
|
38
|
+
type ToolContext,
|
|
39
|
+
type ToolResult,
|
|
40
|
+
} from "./shared.ts";
|
|
41
|
+
|
|
42
|
+
/** How far ahead the default search looks. */
|
|
43
|
+
const DEFAULT_SEARCH_DAYS = 7;
|
|
44
|
+
|
|
45
|
+
export const teamsAvailabilityTool = {
|
|
46
|
+
name: "teams_availability",
|
|
47
|
+
description:
|
|
48
|
+
"Find when the signed-in user and other people are all free, using Microsoft Graph free/busy. " +
|
|
49
|
+
"Only availability is read — never the appointments themselves. " +
|
|
50
|
+
"By default it searches the next 7 days for slots on weekdays between 09:00 and 17:00; pass 'start' and " +
|
|
51
|
+
"'end' (ISO 8601 local date-times) to look at an exact window instead. " +
|
|
52
|
+
"Use it before teams_create_meeting when the goal is to find a time rather than to book one.",
|
|
53
|
+
parameters: Type.Object({
|
|
54
|
+
people: Type.Optional(
|
|
55
|
+
Type.Array(Type.String(), {
|
|
56
|
+
description:
|
|
57
|
+
"Names, e-mail addresses or UPNs of everyone who has to be there; the signed-in user is always included",
|
|
58
|
+
}),
|
|
59
|
+
),
|
|
60
|
+
start: Type.Optional(
|
|
61
|
+
Type.String({
|
|
62
|
+
description:
|
|
63
|
+
"ISO 8601 local date-time, e.g. '2026-09-15T09:00:00'. Default: today, in 'timeZone'",
|
|
64
|
+
}),
|
|
65
|
+
),
|
|
66
|
+
end: Type.Optional(
|
|
67
|
+
Type.String({
|
|
68
|
+
description:
|
|
69
|
+
"ISO 8601 local date-time. Default: 7 days after 'start'",
|
|
70
|
+
}),
|
|
71
|
+
),
|
|
72
|
+
durationMinutes: Type.Optional(
|
|
73
|
+
Type.Number({ description: "Length of the meeting to look for, in minutes (default 30)" }),
|
|
74
|
+
),
|
|
75
|
+
timeZone: Type.Optional(
|
|
76
|
+
Type.String({
|
|
77
|
+
description:
|
|
78
|
+
"IANA or Windows time zone the times are expressed in. Defaults to this machine's time zone",
|
|
79
|
+
}),
|
|
80
|
+
),
|
|
81
|
+
account: AccountParam,
|
|
82
|
+
tenant: TenantParam,
|
|
83
|
+
limit: LimitParam,
|
|
84
|
+
}),
|
|
85
|
+
promptSnippet: "Find a free slot for several people",
|
|
86
|
+
promptGuidelines: [
|
|
87
|
+
"Use teams_availability to find a time before creating a meeting; propose the slots it returns and let the user pick.",
|
|
88
|
+
"Availability is other people's data: pass it on as times, not as an account of how busy someone is.",
|
|
89
|
+
],
|
|
90
|
+
|
|
91
|
+
async execute(
|
|
92
|
+
_toolCallId: string,
|
|
93
|
+
params: {
|
|
94
|
+
people?: string[];
|
|
95
|
+
start?: string;
|
|
96
|
+
end?: string;
|
|
97
|
+
durationMinutes?: number;
|
|
98
|
+
timeZone?: string;
|
|
99
|
+
account?: string;
|
|
100
|
+
tenant?: string;
|
|
101
|
+
limit?: number;
|
|
102
|
+
},
|
|
103
|
+
signal: AbortSignal | undefined,
|
|
104
|
+
_onUpdate: undefined,
|
|
105
|
+
ctx: ToolContext,
|
|
106
|
+
): Promise<ToolResult> {
|
|
107
|
+
return run(async () => {
|
|
108
|
+
const conn = connectionFor(ctx, params.account, params.tenant);
|
|
109
|
+
const me = await currentUser(conn, signal);
|
|
110
|
+
|
|
111
|
+
const timeZone = params.timeZone ?? localTimeZone();
|
|
112
|
+
const duration = Math.min(Math.max(params.durationMinutes ?? 30, 5), 1440);
|
|
113
|
+
|
|
114
|
+
// Default: the coming week, searched inside working hours. An explicit
|
|
115
|
+
// window is honoured as-is — asking "are we free at 20:00?" is a
|
|
116
|
+
// legitimate question that a 09:00–17:00 filter would answer wrongly.
|
|
117
|
+
const explicit = !!params.start || !!params.end;
|
|
118
|
+
const start = params.start ?? localDayStart(0);
|
|
119
|
+
const end = params.end ?? localDayStart(DEFAULT_SEARCH_DAYS);
|
|
120
|
+
|
|
121
|
+
/** scheduleId (lower case) → what to call that person */
|
|
122
|
+
const names = new Map<string, string>();
|
|
123
|
+
const schedules: string[] = [];
|
|
124
|
+
|
|
125
|
+
const selfAddress = me.mail ?? me.upn;
|
|
126
|
+
schedules.push(selfAddress);
|
|
127
|
+
names.set(selfAddress.toLowerCase(), `${me.displayName} (you)`);
|
|
128
|
+
|
|
129
|
+
const unresolved: string[] = [];
|
|
130
|
+
for (const reference of params.people ?? []) {
|
|
131
|
+
const person = await resolveUserId(conn, reference, signal);
|
|
132
|
+
if (!person) {
|
|
133
|
+
unresolved.push(reference);
|
|
134
|
+
continue;
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
// Free/busy is other people's data, so the people rules apply.
|
|
138
|
+
assertAccess(conn, "read", "people", person.displayName, [
|
|
139
|
+
person.displayName,
|
|
140
|
+
person.upn,
|
|
141
|
+
person.mail,
|
|
142
|
+
person.id,
|
|
143
|
+
]);
|
|
144
|
+
|
|
145
|
+
const address = person.mail ?? person.upn;
|
|
146
|
+
if (!address) {
|
|
147
|
+
unresolved.push(reference);
|
|
148
|
+
continue;
|
|
149
|
+
}
|
|
150
|
+
if (!schedules.some((entry) => entry.toLowerCase() === address.toLowerCase())) {
|
|
151
|
+
schedules.push(address);
|
|
152
|
+
names.set(address.toLowerCase(), person.displayName);
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
const windows = await getSchedule(conn, {
|
|
157
|
+
schedules,
|
|
158
|
+
start,
|
|
159
|
+
end,
|
|
160
|
+
timeZone,
|
|
161
|
+
intervalMinutes: duration,
|
|
162
|
+
signal,
|
|
163
|
+
});
|
|
164
|
+
|
|
165
|
+
const views: BusyView[] = windows.map((window) => ({
|
|
166
|
+
name: names.get(window.scheduleId.toLowerCase()) ?? window.scheduleId,
|
|
167
|
+
view: window.availabilityView,
|
|
168
|
+
}));
|
|
169
|
+
|
|
170
|
+
const slots = findCommonFreeSlots(
|
|
171
|
+
views,
|
|
172
|
+
{
|
|
173
|
+
start,
|
|
174
|
+
intervalMinutes: duration,
|
|
175
|
+
notBefore: explicit ? undefined : localNow(),
|
|
176
|
+
workingHoursOnly: !explicit,
|
|
177
|
+
},
|
|
178
|
+
params.limit ?? 5,
|
|
179
|
+
);
|
|
180
|
+
|
|
181
|
+
const who = [...names.values()].map((name) => truncate(name, 60)).join(", ");
|
|
182
|
+
const windowLabel = explicit
|
|
183
|
+
? `${formatSlotRange(start, end)}`
|
|
184
|
+
: `${formatNaiveDay(start)} – ${formatNaiveDay(end)} on weekdays, 09:00–17:00`;
|
|
185
|
+
|
|
186
|
+
const lines = [
|
|
187
|
+
`## Free/busy — ${windowLabel} (${timeZone}), ${duration} min`,
|
|
188
|
+
"",
|
|
189
|
+
`Checked: ${who}`,
|
|
190
|
+
"",
|
|
191
|
+
];
|
|
192
|
+
|
|
193
|
+
if (slots.length === 0) {
|
|
194
|
+
lines.push("**No slot where everybody is free.**", "");
|
|
195
|
+
if (windows.length > 0) {
|
|
196
|
+
lines.push("Busy blocks:", ...describeBusy(windows, names), "");
|
|
197
|
+
}
|
|
198
|
+
} else {
|
|
199
|
+
lines.push(`**${slots.length} slot(s) with everybody free:**`, "");
|
|
200
|
+
for (const slot of slots) {
|
|
201
|
+
lines.push(`- ${formatSlotRange(slot.start, slot.end)}`);
|
|
202
|
+
}
|
|
203
|
+
lines.push("");
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
if (unresolved.length > 0) {
|
|
207
|
+
lines.push(`_Could not resolve: ${unresolved.join(", ")} — use teams_find_user._`, "");
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
// The individual blocks are only useful when a slot was proposed and the
|
|
211
|
+
// reader wants to sanity-check it, or when nothing was found at all.
|
|
212
|
+
if (slots.length > 0 && windows.some((window) => window.busy.length > 0)) {
|
|
213
|
+
lines.push("Busy blocks in the window:", ...describeBusy(windows, names), "");
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
return textResult(lines.join("\n").trimEnd(), {
|
|
217
|
+
slots: slots.length,
|
|
218
|
+
people: schedules.length,
|
|
219
|
+
unresolved: unresolved.length,
|
|
220
|
+
timeZone,
|
|
221
|
+
});
|
|
222
|
+
});
|
|
223
|
+
},
|
|
224
|
+
};
|
|
@@ -0,0 +1,148 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* teams_chat_members — bring someone into a group chat, or take them out.
|
|
3
|
+
*
|
|
4
|
+
* Teams has no "invite" for a chat: either the person is a member of the
|
|
5
|
+
* conversation or they cannot see it. That makes adding a real change to who
|
|
6
|
+
* can read the history, which is why it goes through the safety gates and the
|
|
7
|
+
* person is checked against the member rules before Graph is called.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
import { Type } from "typebox";
|
|
11
|
+
import { addChatMember, listChatMembers, matchChatMember, removeChatMember } from "../graph/chats.ts";
|
|
12
|
+
import { resolveUserId } from "../graph/me.ts";
|
|
13
|
+
import { auditWrite } from "../safety/audit.ts";
|
|
14
|
+
import { assertAccess } from "../safety/index.ts";
|
|
15
|
+
import { requireChat } from "./resolve.ts";
|
|
16
|
+
import {
|
|
17
|
+
AccountParam,
|
|
18
|
+
TenantParam,
|
|
19
|
+
connectionFor,
|
|
20
|
+
currentUser,
|
|
21
|
+
errorResult,
|
|
22
|
+
hasScope,
|
|
23
|
+
missingScopeError,
|
|
24
|
+
run,
|
|
25
|
+
textResult,
|
|
26
|
+
type ToolContext,
|
|
27
|
+
type ToolResult,
|
|
28
|
+
} from "./shared.ts";
|
|
29
|
+
|
|
30
|
+
export const teamsChatMembersTool = {
|
|
31
|
+
name: "teams_chat_members",
|
|
32
|
+
description:
|
|
33
|
+
"Add a person to an existing Microsoft Teams group chat, or remove one. " +
|
|
34
|
+
"Takes a chat ID, the group chat topic, or a participant's name/e-mail, plus who to add or remove. " +
|
|
35
|
+
"A 1:1 chat cannot be extended — use teams_create_chat with everyone in it to make it a group chat. " +
|
|
36
|
+
"Removing someone additionally needs the ChatMember.ReadWrite scope.",
|
|
37
|
+
parameters: Type.Object({
|
|
38
|
+
chat: Type.String({ description: "Chat ID, group chat topic, or a participant's name/e-mail" }),
|
|
39
|
+
action: Type.Union([Type.Literal("add"), Type.Literal("remove")], {
|
|
40
|
+
description: "'add' to bring someone in, 'remove' to take them out",
|
|
41
|
+
}),
|
|
42
|
+
person: Type.String({
|
|
43
|
+
description: "Who to add or remove, by name, e-mail address or user principal name",
|
|
44
|
+
}),
|
|
45
|
+
account: AccountParam,
|
|
46
|
+
tenant: TenantParam,
|
|
47
|
+
}),
|
|
48
|
+
promptSnippet: "Add or remove a member of a Teams chat",
|
|
49
|
+
promptGuidelines: [
|
|
50
|
+
"Adding a member gives them the chat's history from that point on, so confirm the person with the user when the name is ambiguous.",
|
|
51
|
+
],
|
|
52
|
+
|
|
53
|
+
async execute(
|
|
54
|
+
_toolCallId: string,
|
|
55
|
+
params: {
|
|
56
|
+
chat: string;
|
|
57
|
+
action: "add" | "remove";
|
|
58
|
+
person: string;
|
|
59
|
+
account?: string;
|
|
60
|
+
tenant?: string;
|
|
61
|
+
},
|
|
62
|
+
signal: AbortSignal | undefined,
|
|
63
|
+
_onUpdate: undefined,
|
|
64
|
+
ctx: ToolContext,
|
|
65
|
+
): Promise<ToolResult> {
|
|
66
|
+
return run(async () => {
|
|
67
|
+
const conn = connectionFor(ctx, params.account, params.tenant);
|
|
68
|
+
const me = await currentUser(conn, signal);
|
|
69
|
+
const chat = await requireChat(conn, params.chat, "write", signal);
|
|
70
|
+
|
|
71
|
+
if (params.action === "add") {
|
|
72
|
+
if (chat.chatType === "oneOnOne") {
|
|
73
|
+
return errorResult(
|
|
74
|
+
`"${chat.label}" is a 1:1 chat and cannot be extended. Use teams_create_chat with ` +
|
|
75
|
+
`everyone who should be in it to start a group chat.`,
|
|
76
|
+
);
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
const person = await resolveUserId(conn, params.person, signal);
|
|
80
|
+
if (!person) {
|
|
81
|
+
return errorResult(
|
|
82
|
+
`No person matching "${params.person}". Use teams_find_user to find the exact name or e-mail.`,
|
|
83
|
+
);
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
// The people already in the chat were checked by requireChat; the one
|
|
87
|
+
// about to join has to be checked on its own.
|
|
88
|
+
assertAccess(conn, "write", "people", person.displayName, [
|
|
89
|
+
person.displayName,
|
|
90
|
+
person.upn,
|
|
91
|
+
person.mail,
|
|
92
|
+
person.id,
|
|
93
|
+
]);
|
|
94
|
+
|
|
95
|
+
await addChatMember(conn, chat.id, person, { signal });
|
|
96
|
+
|
|
97
|
+
auditWrite(conn.audit, {
|
|
98
|
+
tool: "teams_chat_members",
|
|
99
|
+
account: conn.account,
|
|
100
|
+
tenant: conn.tenant,
|
|
101
|
+
actor: me.upn,
|
|
102
|
+
target: `chat:${chat.label}`,
|
|
103
|
+
summary: `added ${person.displayName} to the chat`,
|
|
104
|
+
});
|
|
105
|
+
|
|
106
|
+
return textResult(`✅ ${person.displayName} added to "${chat.label}".`, {
|
|
107
|
+
chatId: chat.id,
|
|
108
|
+
action: "add",
|
|
109
|
+
});
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
// Removal takes the membership id, not the user id — a scope the default
|
|
113
|
+
// configuration leaves out, so the answer is the fix rather than a 403.
|
|
114
|
+
if (!hasScope(conn, "ChatMember.ReadWrite")) {
|
|
115
|
+
return errorResult(
|
|
116
|
+
missingScopeError("ChatMember.ReadWrite", "Removing a member from a chat"),
|
|
117
|
+
);
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
const person = await resolveUserId(conn, params.person, signal).catch(() => undefined);
|
|
121
|
+
const members = await listChatMembers(conn, chat.id, { signal });
|
|
122
|
+
const reference = person?.id ?? person?.upn ?? person?.mail ?? params.person;
|
|
123
|
+
const member = matchChatMember(members, reference) ?? matchChatMember(members, params.person);
|
|
124
|
+
|
|
125
|
+
if (!member) {
|
|
126
|
+
const known = members.map((entry) => entry.displayName).join(", ") || "(none visible)";
|
|
127
|
+
return errorResult(`"${params.person}" is not a member of "${chat.label}". Members: ${known}.`);
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
await removeChatMember(conn, chat.id, member.membershipId, { signal });
|
|
131
|
+
|
|
132
|
+
auditWrite(conn.audit, {
|
|
133
|
+
tool: "teams_chat_members",
|
|
134
|
+
account: conn.account,
|
|
135
|
+
tenant: conn.tenant,
|
|
136
|
+
actor: me.upn,
|
|
137
|
+
target: `chat:${chat.label}`,
|
|
138
|
+
summary: `removed ${member.displayName} from the chat`,
|
|
139
|
+
});
|
|
140
|
+
|
|
141
|
+
return textResult(`✅ ${member.displayName} removed from "${chat.label}".`, {
|
|
142
|
+
chatId: chat.id,
|
|
143
|
+
action: "remove",
|
|
144
|
+
membershipId: member.membershipId,
|
|
145
|
+
});
|
|
146
|
+
});
|
|
147
|
+
},
|
|
148
|
+
};
|