@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/src/tools/teams-inbox.ts
CHANGED
|
@@ -47,6 +47,7 @@ export const teamsInboxTool = {
|
|
|
47
47
|
promptGuidelines: [
|
|
48
48
|
"Use teams_inbox for open-ended catch-up questions about Teams.",
|
|
49
49
|
"Summarize what is waiting and who is waiting on it; do not reply to anything unless the user asks.",
|
|
50
|
+
"After a catch-up, offer to clear the list with teams_mark_read — never mark a chat read unasked.",
|
|
50
51
|
],
|
|
51
52
|
|
|
52
53
|
async execute(
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* teams_mark_read — move the read cursor of a chat.
|
|
3
|
+
*
|
|
4
|
+
* "Mark as read" is the one piece of chat state a delegated token may change
|
|
5
|
+
* on the user's behalf, and it is the natural end of a catch-up: pi summarizes
|
|
6
|
+
* the inbox, then clears it. It is a mutation all the same — the chat stops
|
|
7
|
+
* being bold, and where the tenant shows read receipts, the sender sees that
|
|
8
|
+
* it was opened.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
import { Type } from "typebox";
|
|
12
|
+
import { setChatReadState } from "../graph/chats.ts";
|
|
13
|
+
import { auditWrite } from "../safety/audit.ts";
|
|
14
|
+
import { requireChat } from "./resolve.ts";
|
|
15
|
+
import {
|
|
16
|
+
AccountParam,
|
|
17
|
+
TenantParam,
|
|
18
|
+
connectionFor,
|
|
19
|
+
currentUser,
|
|
20
|
+
run,
|
|
21
|
+
textResult,
|
|
22
|
+
type ToolContext,
|
|
23
|
+
type ToolResult,
|
|
24
|
+
} from "./shared.ts";
|
|
25
|
+
|
|
26
|
+
export const teamsMarkReadTool = {
|
|
27
|
+
name: "teams_mark_read",
|
|
28
|
+
description:
|
|
29
|
+
"Mark a Microsoft Teams chat as read — or unread again — as the signed-in user, exactly like opening " +
|
|
30
|
+
"or re-marking it in the app. Accepts a chat ID, a group chat topic, or a participant's name/e-mail. " +
|
|
31
|
+
"Set read: false to make the chat unread again. Channels have no equivalent: channel posts cannot be " +
|
|
32
|
+
"marked read or unread.",
|
|
33
|
+
parameters: Type.Object({
|
|
34
|
+
chat: Type.String({
|
|
35
|
+
description: "Chat ID, group chat topic, or a participant's name/e-mail",
|
|
36
|
+
}),
|
|
37
|
+
read: Type.Optional(
|
|
38
|
+
Type.Boolean({
|
|
39
|
+
description: "true (default) marks the chat read, false marks it unread again",
|
|
40
|
+
}),
|
|
41
|
+
),
|
|
42
|
+
account: AccountParam,
|
|
43
|
+
tenant: TenantParam,
|
|
44
|
+
}),
|
|
45
|
+
promptSnippet: "Mark a Teams chat read or unread",
|
|
46
|
+
promptGuidelines: [
|
|
47
|
+
"Use teams_mark_read for 'mark that as read', 'clear my inbox', 'mark it unread again'.",
|
|
48
|
+
"Marking a chat read is visible to the sender where the tenant shows read receipts, so do it when asked, not on your own initiative.",
|
|
49
|
+
],
|
|
50
|
+
|
|
51
|
+
async execute(
|
|
52
|
+
_toolCallId: string,
|
|
53
|
+
params: { chat: string; read?: boolean; account?: string; tenant?: string },
|
|
54
|
+
signal: AbortSignal | undefined,
|
|
55
|
+
_onUpdate: undefined,
|
|
56
|
+
ctx: ToolContext,
|
|
57
|
+
): Promise<ToolResult> {
|
|
58
|
+
return run(async () => {
|
|
59
|
+
const conn = connectionFor(ctx, params.account, params.tenant);
|
|
60
|
+
const me = await currentUser(conn, signal);
|
|
61
|
+
|
|
62
|
+
// "write": this changes state in Teams, so the write rules apply —
|
|
63
|
+
// a chat pi may read but not write is one whose unread marker stays.
|
|
64
|
+
const chat = await requireChat(conn, params.chat, "write", signal);
|
|
65
|
+
const read = params.read !== false;
|
|
66
|
+
|
|
67
|
+
await setChatReadState(conn, chat.id, me, read, { signal });
|
|
68
|
+
|
|
69
|
+
auditWrite(conn.audit, {
|
|
70
|
+
tool: "teams_mark_read",
|
|
71
|
+
account: conn.account,
|
|
72
|
+
tenant: conn.tenant,
|
|
73
|
+
actor: me.upn,
|
|
74
|
+
target: `chat:${chat.label}`,
|
|
75
|
+
summary: `marked chat as ${read ? "read" : "unread"}`,
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
return textResult(
|
|
79
|
+
`✅ "${chat.label}" marked as ${read ? "read" : "unread"}.`,
|
|
80
|
+
{ chatId: chat.id, label: chat.label, read },
|
|
81
|
+
);
|
|
82
|
+
});
|
|
83
|
+
},
|
|
84
|
+
};
|
|
@@ -13,7 +13,9 @@ import {
|
|
|
13
13
|
createOnlineMeeting,
|
|
14
14
|
getEvent,
|
|
15
15
|
listEvents,
|
|
16
|
+
respondToEvent,
|
|
16
17
|
updateEvent,
|
|
18
|
+
type EventResponse,
|
|
17
19
|
} from "../graph/calendar.ts";
|
|
18
20
|
import { auditWrite } from "../safety/audit.ts";
|
|
19
21
|
import { assertAccess } from "../safety/index.ts";
|
|
@@ -302,6 +304,102 @@ export const teamsUpdateMeetingTool = {
|
|
|
302
304
|
},
|
|
303
305
|
};
|
|
304
306
|
|
|
307
|
+
export const teamsRespondInviteTool = {
|
|
308
|
+
name: "teams_respond_invite",
|
|
309
|
+
description:
|
|
310
|
+
"Answer a Microsoft Teams meeting invitation as the signed-in user: accept, decline, or mark as " +
|
|
311
|
+
"tentative. The organizer is notified unless sendResponse is false. Get the eventId from " +
|
|
312
|
+
"teams_list_meetings. Only invitations can be answered this way — a meeting the user organizes is " +
|
|
313
|
+
"changed with teams_update_meeting or cancelled with teams_cancel_meeting.",
|
|
314
|
+
parameters: Type.Object({
|
|
315
|
+
eventId: Type.String({ description: "Calendar event ID of the invitation" }),
|
|
316
|
+
response: Type.Union(
|
|
317
|
+
[Type.Literal("accept"), Type.Literal("decline"), Type.Literal("tentative")],
|
|
318
|
+
{ description: "'accept', 'decline', or 'tentative'" },
|
|
319
|
+
),
|
|
320
|
+
comment: Type.Optional(
|
|
321
|
+
Type.String({ description: "Note sent to the organizer with the answer" }),
|
|
322
|
+
),
|
|
323
|
+
sendResponse: Type.Optional(
|
|
324
|
+
Type.Boolean({
|
|
325
|
+
description: "Notify the organizer (default true). Set false to answer silently",
|
|
326
|
+
}),
|
|
327
|
+
),
|
|
328
|
+
account: AccountParam,
|
|
329
|
+
tenant: TenantParam,
|
|
330
|
+
}),
|
|
331
|
+
promptSnippet: "Accept or decline a Teams meeting invitation",
|
|
332
|
+
promptGuidelines: [
|
|
333
|
+
"Answering an invitation notifies the organizer, so only respond to an invitation the user actually decided on.",
|
|
334
|
+
],
|
|
335
|
+
|
|
336
|
+
async execute(
|
|
337
|
+
_toolCallId: string,
|
|
338
|
+
params: {
|
|
339
|
+
eventId: string;
|
|
340
|
+
response: "accept" | "decline" | "tentative";
|
|
341
|
+
comment?: string;
|
|
342
|
+
sendResponse?: boolean;
|
|
343
|
+
account?: string;
|
|
344
|
+
tenant?: string;
|
|
345
|
+
},
|
|
346
|
+
signal: AbortSignal | undefined,
|
|
347
|
+
_onUpdate: undefined,
|
|
348
|
+
ctx: ToolContext,
|
|
349
|
+
): Promise<ToolResult> {
|
|
350
|
+
return run(async () => {
|
|
351
|
+
const conn = connectionFor(ctx, params.account, params.tenant);
|
|
352
|
+
const me = await currentUser(conn, signal);
|
|
353
|
+
|
|
354
|
+
const event = await getEvent(conn, params.eventId, signal);
|
|
355
|
+
if (!event) return errorResult(`Event ${params.eventId} not found. Check the ID from teams_list_meetings.`);
|
|
356
|
+
|
|
357
|
+
// Graph would answer with a bare error; the real problem is that the user
|
|
358
|
+
// is the organizer, and answering their own invitation is a no-op.
|
|
359
|
+
const organizer = [event.organizer?.mail, event.organizer?.upn].filter(Boolean) as string[];
|
|
360
|
+
const mine = [me.mail, me.upn].filter(Boolean) as string[];
|
|
361
|
+
if (
|
|
362
|
+
organizer.length > 0 &&
|
|
363
|
+
mine.some((address) => organizer.some((other) => other.toLowerCase() === address.toLowerCase()))
|
|
364
|
+
) {
|
|
365
|
+
return errorResult(
|
|
366
|
+
`You organize "${event.subject}" — there is no invitation to answer. ` +
|
|
367
|
+
`Use teams_update_meeting to change it, or teams_cancel_meeting to call it off.`,
|
|
368
|
+
);
|
|
369
|
+
}
|
|
370
|
+
|
|
371
|
+
const response: EventResponse = params.response === "tentative" ? "tentativelyAccept" : params.response;
|
|
372
|
+
await respondToEvent(conn, params.eventId, response, {
|
|
373
|
+
comment: params.comment,
|
|
374
|
+
sendResponse: params.sendResponse,
|
|
375
|
+
signal,
|
|
376
|
+
});
|
|
377
|
+
|
|
378
|
+
auditWrite(conn.audit, {
|
|
379
|
+
tool: "teams_respond_invite",
|
|
380
|
+
account: conn.account,
|
|
381
|
+
tenant: conn.tenant,
|
|
382
|
+
actor: me.upn,
|
|
383
|
+
target: `event:${event.subject}`,
|
|
384
|
+
summary: `answered the invitation with "${params.response}"`,
|
|
385
|
+
});
|
|
386
|
+
|
|
387
|
+
const verb =
|
|
388
|
+
params.response === "accept"
|
|
389
|
+
? "accepted"
|
|
390
|
+
: params.response === "decline"
|
|
391
|
+
? "declined"
|
|
392
|
+
: "marked as tentative";
|
|
393
|
+
const notified = params.sendResponse === false ? " (organizer not notified)" : "";
|
|
394
|
+
|
|
395
|
+
return textResult(`✅ "${event.subject}" ${verb}${notified}.`, {
|
|
396
|
+
eventId: params.eventId,
|
|
397
|
+
response: params.response,
|
|
398
|
+
});
|
|
399
|
+
});
|
|
400
|
+
},
|
|
401
|
+
};
|
|
402
|
+
|
|
305
403
|
export const teamsCancelMeetingTool = {
|
|
306
404
|
name: "teams_cancel_meeting",
|
|
307
405
|
description:
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* teams_update_message — rewrite a message the user already sent.
|
|
3
|
+
*
|
|
4
|
+
* Editing beats delete-and-resend: the message keeps its ID and its place in
|
|
5
|
+
* the conversation, and Teams shows it as edited rather than as a gap followed
|
|
6
|
+
* by a near-duplicate. Only the sender's own messages can be edited, which is
|
|
7
|
+
* the only case that makes sense for something pi wrote in the user's name.
|
|
8
|
+
*
|
|
9
|
+
* Chats only. Editing a channel post needs the `ChannelMessage.ReadWrite`
|
|
10
|
+
* scope, which this package does not request and does not assume will be
|
|
11
|
+
* granted — so the tool does not offer a path that could not run.
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
import { Type } from "typebox";
|
|
15
|
+
import { updateMessage } from "../graph/messages.ts";
|
|
16
|
+
import { auditWrite } from "../safety/audit.ts";
|
|
17
|
+
import { requireChat } from "./resolve.ts";
|
|
18
|
+
import {
|
|
19
|
+
AccountParam,
|
|
20
|
+
TenantParam,
|
|
21
|
+
connectionFor,
|
|
22
|
+
currentUser,
|
|
23
|
+
errorResult,
|
|
24
|
+
run,
|
|
25
|
+
textResult,
|
|
26
|
+
type ToolContext,
|
|
27
|
+
type ToolResult,
|
|
28
|
+
} from "./shared.ts";
|
|
29
|
+
|
|
30
|
+
export const teamsUpdateMessageTool = {
|
|
31
|
+
name: "teams_update_message",
|
|
32
|
+
description:
|
|
33
|
+
"Edit a message the signed-in user already sent in a Microsoft Teams chat: replaces the text and keeps " +
|
|
34
|
+
"the message in place, so Teams shows it as edited instead of as a new message. " +
|
|
35
|
+
"Use this instead of deleting a message and sending a corrected copy.",
|
|
36
|
+
parameters: Type.Object({
|
|
37
|
+
messageId: Type.String({ description: "ID of the message to edit" }),
|
|
38
|
+
body: Type.String({ description: "The new message text (lightweight markdown by default)" }),
|
|
39
|
+
chat: Type.String({ description: "Chat ID, group chat topic, or a participant's name/e-mail" }),
|
|
40
|
+
html: Type.Optional(Type.Boolean({ description: "Treat 'body' as raw HTML instead of markdown" })),
|
|
41
|
+
account: AccountParam,
|
|
42
|
+
tenant: TenantParam,
|
|
43
|
+
}),
|
|
44
|
+
promptSnippet: "Edit a message the user sent in Teams",
|
|
45
|
+
promptGuidelines: [
|
|
46
|
+
"Use teams_update_message to correct a message the user already sent, rather than deleting it and posting a replacement.",
|
|
47
|
+
],
|
|
48
|
+
|
|
49
|
+
async execute(
|
|
50
|
+
_toolCallId: string,
|
|
51
|
+
params: {
|
|
52
|
+
messageId: string;
|
|
53
|
+
body: string;
|
|
54
|
+
chat: string;
|
|
55
|
+
html?: boolean;
|
|
56
|
+
account?: string;
|
|
57
|
+
tenant?: string;
|
|
58
|
+
},
|
|
59
|
+
signal: AbortSignal | undefined,
|
|
60
|
+
_onUpdate: undefined,
|
|
61
|
+
ctx: ToolContext,
|
|
62
|
+
): Promise<ToolResult> {
|
|
63
|
+
return run(async () => {
|
|
64
|
+
const conn = connectionFor(ctx, params.account, params.tenant);
|
|
65
|
+
const me = await currentUser(conn, signal);
|
|
66
|
+
const chat = await requireChat(conn, params.chat, "write", signal);
|
|
67
|
+
|
|
68
|
+
await updateMessage(conn, chat.id, params.messageId, me.id, {
|
|
69
|
+
body: params.body,
|
|
70
|
+
html: params.html,
|
|
71
|
+
}, { signal });
|
|
72
|
+
|
|
73
|
+
auditWrite(conn.audit, {
|
|
74
|
+
tool: "teams_update_message",
|
|
75
|
+
account: conn.account,
|
|
76
|
+
tenant: conn.tenant,
|
|
77
|
+
actor: me.upn,
|
|
78
|
+
target: `chat:${chat.label}`,
|
|
79
|
+
summary: `edited message ${params.messageId}: ${params.body.slice(0, 120)}`,
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
return textResult(`✅ Message ${params.messageId} edited in "${chat.label}".`, {
|
|
83
|
+
messageId: params.messageId,
|
|
84
|
+
chatId: chat.id,
|
|
85
|
+
});
|
|
86
|
+
});
|
|
87
|
+
},
|
|
88
|
+
};
|
package/src/tools/tool-names.ts
CHANGED
|
@@ -15,14 +15,16 @@ export const MUTATION_TOOLS = new Set<string>([
|
|
|
15
15
|
"teams_create_chat",
|
|
16
16
|
"teams_delete_message",
|
|
17
17
|
"teams_react",
|
|
18
|
-
|
|
19
|
-
"
|
|
18
|
+
"teams_mark_read",
|
|
19
|
+
"teams_update_message",
|
|
20
|
+
"teams_chat_members",
|
|
20
21
|
// Presence
|
|
21
22
|
"teams_set_presence",
|
|
22
23
|
"teams_set_status_message",
|
|
23
24
|
// Calendar
|
|
24
25
|
"teams_create_meeting",
|
|
25
26
|
"teams_update_meeting",
|
|
27
|
+
"teams_respond_invite",
|
|
26
28
|
"teams_cancel_meeting",
|
|
27
29
|
// Local configuration and credentials
|
|
28
30
|
"teams_setup",
|
package/src/types.ts
CHANGED
|
@@ -86,6 +86,17 @@ export interface ChatSummary {
|
|
|
86
86
|
webUrl?: string;
|
|
87
87
|
}
|
|
88
88
|
|
|
89
|
+
export interface ChatMemberSummary {
|
|
90
|
+
/** conversationMember id — the identifier DELETE /chats/{id}/members/{id} needs */
|
|
91
|
+
membershipId: string;
|
|
92
|
+
/** Object id of the person behind the membership */
|
|
93
|
+
userId?: string;
|
|
94
|
+
displayName: string;
|
|
95
|
+
upn?: string;
|
|
96
|
+
mail?: string;
|
|
97
|
+
roles: string[];
|
|
98
|
+
}
|
|
99
|
+
|
|
89
100
|
// ---------------------------------------------------------------------------
|
|
90
101
|
// Messages
|
|
91
102
|
// ---------------------------------------------------------------------------
|
package/src/utils/errors.ts
CHANGED
|
@@ -40,6 +40,7 @@ const CONSENT_HINTS: Record<string, string> = {
|
|
|
40
40
|
"ChannelMessage.Send": "posting in channels",
|
|
41
41
|
"Chat.ReadWrite": "reading and sending chat messages",
|
|
42
42
|
"ChatMessage.Send": "sending chat messages",
|
|
43
|
+
"ChatMember.ReadWrite": "removing someone from a chat",
|
|
43
44
|
"Presence.ReadWrite": "reading and setting your presence",
|
|
44
45
|
"Calendars.ReadWrite": "reading and creating calendar events",
|
|
45
46
|
"OnlineMeetings.ReadWrite": "creating Teams meetings",
|
package/src/utils/formatting.ts
CHANGED
|
@@ -114,6 +114,68 @@ export function formatRelative(iso: string | undefined): string {
|
|
|
114
114
|
return formatDate(iso);
|
|
115
115
|
}
|
|
116
116
|
|
|
117
|
+
const WEEKDAYS = ["Sun", "Mon", "Tue", "Wed", "Thu", "Fri", "Sat"];
|
|
118
|
+
const MONTHS = [
|
|
119
|
+
"Jan",
|
|
120
|
+
"Feb",
|
|
121
|
+
"Mar",
|
|
122
|
+
"Apr",
|
|
123
|
+
"May",
|
|
124
|
+
"Jun",
|
|
125
|
+
"Jul",
|
|
126
|
+
"Aug",
|
|
127
|
+
"Sep",
|
|
128
|
+
"Oct",
|
|
129
|
+
"Nov",
|
|
130
|
+
"Dec",
|
|
131
|
+
];
|
|
132
|
+
|
|
133
|
+
/**
|
|
134
|
+
* Split a naive Graph date-time into its parts, as UTC.
|
|
135
|
+
*
|
|
136
|
+
* Availability views and free/busy blocks arrive already localised to the zone
|
|
137
|
+
* the caller asked Graph for, so re-interpreting them in the machine's zone
|
|
138
|
+
* would shift every time by the offset between the two.
|
|
139
|
+
*/
|
|
140
|
+
function naiveParts(value: string | undefined):
|
|
141
|
+
| { weekday: string; day: number; month: string; year: number; time: string }
|
|
142
|
+
| undefined {
|
|
143
|
+
if (!value) return undefined;
|
|
144
|
+
const match = /^(\d{4})-(\d{2})-(\d{2})[T ](\d{2}):(\d{2})/.exec(value.trim());
|
|
145
|
+
if (!match) return undefined;
|
|
146
|
+
const [, year, month, day, hour, minute] = match;
|
|
147
|
+
const asUtc = new Date(Date.UTC(Number(year), Number(month) - 1, Number(day)));
|
|
148
|
+
return {
|
|
149
|
+
weekday: WEEKDAYS[asUtc.getUTCDay()] ?? "",
|
|
150
|
+
day: Number(day),
|
|
151
|
+
month: MONTHS[Number(month) - 1] ?? "",
|
|
152
|
+
year: Number(year),
|
|
153
|
+
time: `${hour}:${minute}`,
|
|
154
|
+
};
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
/** "Mon 15 Sep 2026" — a window's day, without the clock time. */
|
|
158
|
+
export function formatNaiveDay(value: string | undefined): string {
|
|
159
|
+
const parts = naiveParts(value);
|
|
160
|
+
if (!parts) return value ?? "";
|
|
161
|
+
return `${parts.weekday} ${parts.day} ${parts.month} ${parts.year}`;
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
/** "09:00–09:30" — the clock part of a free/busy block. */
|
|
165
|
+
export function formatTimeRange(start: string | undefined, end: string | undefined): string {
|
|
166
|
+
const from = naiveParts(start);
|
|
167
|
+
const to = naiveParts(end);
|
|
168
|
+
if (!from || !to) return "unknown time";
|
|
169
|
+
return `${from.time}–${to.time}`;
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
/** "Mon 15 Sep, 09:00–09:30" — one proposed slot. */
|
|
173
|
+
export function formatSlotRange(start: string | undefined, end: string | undefined): string {
|
|
174
|
+
const parts = naiveParts(start);
|
|
175
|
+
if (!parts) return `${start ?? "?"}–${end ?? "?"}`;
|
|
176
|
+
return `${parts.weekday} ${parts.day} ${parts.month}, ${formatTimeRange(start, end)}`;
|
|
177
|
+
}
|
|
178
|
+
|
|
117
179
|
/** Shorten a string for previews, without cutting mid-escape. */
|
|
118
180
|
export function truncate(text: string, max: number): string {
|
|
119
181
|
const oneLine = text.replace(/\s*\n\s*/g, " ").trim();
|
|
@@ -0,0 +1,171 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Finding a slot everyone is free in.
|
|
3
|
+
*
|
|
4
|
+
* Graph returns availability as a *string*, one character per time slot since
|
|
5
|
+
* the window start — so the one thing that can go silently wrong is the mapping
|
|
6
|
+
* from character index back to a clock time. That mapping lives here, pure and
|
|
7
|
+
* testable, instead of inside a tool where the only test would be a live
|
|
8
|
+
* calendar.
|
|
9
|
+
*
|
|
10
|
+
* Naive date-times ("2026-09-15T09:00:00", no offset) are parsed as UTC on
|
|
11
|
+
* purpose: they already carry the zone the caller asked Graph for, and reading
|
|
12
|
+
* them as UTC keeps the arithmetic independent of the machine's own zone.
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
/** One person's availability, as Graph's availability view expresses it. */
|
|
16
|
+
export interface BusyView {
|
|
17
|
+
/** Name to report the person by */
|
|
18
|
+
name: string;
|
|
19
|
+
/** One character per slot: `0` free, `1` tentative, `2` busy, `3` out of office, `4` working elsewhere */
|
|
20
|
+
view: string;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export interface SlotWindow {
|
|
24
|
+
/** First slot, naive ISO, aligned with index 0 of every view */
|
|
25
|
+
start: string;
|
|
26
|
+
/** Slot width in minutes — the same value the Graph call used */
|
|
27
|
+
intervalMinutes: number;
|
|
28
|
+
/** Ignore slots that begin before this naive ISO (used to skip the past) */
|
|
29
|
+
notBefore?: string;
|
|
30
|
+
/** Also ignore slots outside Mon–Fri, 09:00–17:00 */
|
|
31
|
+
workingHoursOnly?: boolean;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export interface FreeSlot {
|
|
35
|
+
start: string;
|
|
36
|
+
end: string;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
const FREE = "0";
|
|
40
|
+
const WORKDAY_START = 9 * 60;
|
|
41
|
+
const WORKDAY_END = 17 * 60;
|
|
42
|
+
const MINUTE_MS = 60_000;
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* Parse a naive Graph date-time into a UTC timestamp.
|
|
46
|
+
*
|
|
47
|
+
* Returns undefined for anything without a date and time rather than guessing:
|
|
48
|
+
* a missing value must not become "1 January 1970, which looks free".
|
|
49
|
+
*/
|
|
50
|
+
export function parseNaive(value: string | undefined): number | undefined {
|
|
51
|
+
if (!value) return undefined;
|
|
52
|
+
const match = /^(\d{4})-(\d{2})-(\d{2})[T ](\d{2}):(\d{2})(?::(\d{2}))?/.exec(value.trim());
|
|
53
|
+
if (!match) return undefined;
|
|
54
|
+
const [, year, month, day, hour, minute, second] = match;
|
|
55
|
+
return Date.UTC(
|
|
56
|
+
Number(year),
|
|
57
|
+
Number(month) - 1,
|
|
58
|
+
Number(day),
|
|
59
|
+
Number(hour),
|
|
60
|
+
Number(minute),
|
|
61
|
+
Number(second ?? 0),
|
|
62
|
+
);
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/** UTC timestamp → naive ISO without milliseconds. */
|
|
66
|
+
export function formatNaive(timestamp: number): string {
|
|
67
|
+
return new Date(timestamp).toISOString().slice(0, 19);
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/** Shift a naive date-time by whole minutes. */
|
|
71
|
+
export function addMinutes(naive: string, minutes: number): string | undefined {
|
|
72
|
+
const parsed = parseNaive(naive);
|
|
73
|
+
return parsed === undefined ? undefined : formatNaive(parsed + minutes * MINUTE_MS);
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/** `YYYY-MM-DDT00:00:00` for today + offset, in the machine's time zone. */
|
|
77
|
+
export function localDayStart(offsetDays = 0): string {
|
|
78
|
+
const date = new Date();
|
|
79
|
+
date.setDate(date.getDate() + offsetDays);
|
|
80
|
+
return `${localDate(date)}T00:00:00`;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/** The current time as a naive date-time, in the machine's time zone. */
|
|
84
|
+
export function localNow(): string {
|
|
85
|
+
return `${localDate(new Date())}T${localTime(new Date())}`;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/** The machine's time zone, for calls that would otherwise default to UTC. */
|
|
89
|
+
export function localTimeZone(): string {
|
|
90
|
+
try {
|
|
91
|
+
return Intl.DateTimeFormat().resolvedOptions().timeZone || "UTC";
|
|
92
|
+
} catch {
|
|
93
|
+
return "UTC";
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
function localDate(date: Date): string {
|
|
98
|
+
const pad = (n: number) => String(n).padStart(2, "0");
|
|
99
|
+
return `${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())}`;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
function localTime(date: Date): string {
|
|
103
|
+
const pad = (n: number) => String(n).padStart(2, "0");
|
|
104
|
+
return `${pad(date.getHours())}:${pad(date.getMinutes())}:00`;
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
function insideWorkingHours(start: number, end: number): boolean {
|
|
108
|
+
const from = new Date(start);
|
|
109
|
+
const to = new Date(end);
|
|
110
|
+
const weekday = from.getUTCDay();
|
|
111
|
+
if (weekday === 0 || weekday === 6) return false;
|
|
112
|
+
// A slot has to fit inside one working day; a window across midnight is not
|
|
113
|
+
// a 30-minute meeting anyway.
|
|
114
|
+
if (from.toISOString().slice(0, 10) !== to.toISOString().slice(0, 10)) return false;
|
|
115
|
+
const startMinutes = from.getUTCHours() * 60 + from.getUTCMinutes();
|
|
116
|
+
const endMinutes = to.getUTCHours() * 60 + to.getUTCMinutes();
|
|
117
|
+
return startMinutes >= WORKDAY_START && endMinutes <= WORKDAY_END;
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
/** The first slots in which nobody has anything. */
|
|
121
|
+
export function findCommonFreeSlots(
|
|
122
|
+
views: readonly BusyView[],
|
|
123
|
+
window: SlotWindow,
|
|
124
|
+
limit = 5,
|
|
125
|
+
): FreeSlot[] {
|
|
126
|
+
const base = parseNaive(window.start);
|
|
127
|
+
if (base === undefined || views.length === 0) return [];
|
|
128
|
+
|
|
129
|
+
// The shortest view decides how far the search goes: where Graph truncated
|
|
130
|
+
// a person's availability the tail is unknown, and unknown is not free.
|
|
131
|
+
const horizon = Math.min(...views.map((entry) => entry.view.length));
|
|
132
|
+
const earliest = parseNaive(window.notBefore);
|
|
133
|
+
const step = window.intervalMinutes * MINUTE_MS;
|
|
134
|
+
|
|
135
|
+
const slots: FreeSlot[] = [];
|
|
136
|
+
for (let index = 0; index < horizon && slots.length < limit; index += 1) {
|
|
137
|
+
if (views.some((entry) => entry.view[index] !== FREE)) continue;
|
|
138
|
+
|
|
139
|
+
const start = base + index * step;
|
|
140
|
+
const end = start + step;
|
|
141
|
+
if (earliest !== undefined && start < earliest) continue;
|
|
142
|
+
if (window.workingHoursOnly && !insideWorkingHours(start, end)) continue;
|
|
143
|
+
|
|
144
|
+
slots.push({ start: formatNaive(start), end: formatNaive(end) });
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
return slots;
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
/**
|
|
151
|
+
* The statuses keeping everyone apart, for when no slot is free.
|
|
152
|
+
*
|
|
153
|
+
* Turns "nothing works" into something a person can act on, and reports only
|
|
154
|
+
* times and statuses — never what the appointment is.
|
|
155
|
+
*/
|
|
156
|
+
export function describeBusy(
|
|
157
|
+
busy: readonly {
|
|
158
|
+
scheduleId: string;
|
|
159
|
+
busy: Array<{ status: string; start: string; end: string }>;
|
|
160
|
+
}[],
|
|
161
|
+
names: Map<string, string>,
|
|
162
|
+
): string[] {
|
|
163
|
+
return busy.map((entry) => {
|
|
164
|
+
const name = names.get(entry.scheduleId.toLowerCase()) ?? entry.scheduleId;
|
|
165
|
+
const blocks = entry.busy
|
|
166
|
+
.slice(0, 6)
|
|
167
|
+
.map((block) => `${block.start.slice(11, 16)}–${block.end.slice(11, 16)} (${block.status})`)
|
|
168
|
+
.join(", ");
|
|
169
|
+
return `- ${name}: ${blocks || "no busy blocks reported"}`;
|
|
170
|
+
});
|
|
171
|
+
}
|
|
@@ -1,89 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* teams_create_channel — add a channel to a team.
|
|
3
|
-
*/
|
|
4
|
-
|
|
5
|
-
import { Type } from "typebox";
|
|
6
|
-
import { createChannel } from "../graph/teams.ts";
|
|
7
|
-
import { auditWrite } from "../safety/audit.ts";
|
|
8
|
-
import { requireTeam } from "./resolve.ts";
|
|
9
|
-
import {
|
|
10
|
-
AccountParam,
|
|
11
|
-
TenantParam,
|
|
12
|
-
connectionFor,
|
|
13
|
-
currentUser,
|
|
14
|
-
run,
|
|
15
|
-
textResult,
|
|
16
|
-
type ToolContext,
|
|
17
|
-
type ToolResult,
|
|
18
|
-
} from "./shared.ts";
|
|
19
|
-
|
|
20
|
-
export const teamsCreateChannelTool = {
|
|
21
|
-
name: "teams_create_channel",
|
|
22
|
-
description:
|
|
23
|
-
"Create a new channel in a Microsoft Teams team. membershipType 'standard' (default) is visible to the " +
|
|
24
|
-
"whole team, 'private' only to the members you add afterwards.",
|
|
25
|
-
parameters: Type.Object({
|
|
26
|
-
team: Type.String({ description: "Team name or ID" }),
|
|
27
|
-
name: Type.String({ description: "Channel display name" }),
|
|
28
|
-
description: Type.Optional(Type.String({ description: "Channel description" })),
|
|
29
|
-
membershipType: Type.Optional(
|
|
30
|
-
Type.String({ description: "'standard' (default) or 'private'" }),
|
|
31
|
-
),
|
|
32
|
-
account: AccountParam,
|
|
33
|
-
tenant: TenantParam,
|
|
34
|
-
}),
|
|
35
|
-
promptSnippet: "Create a channel in a Teams team",
|
|
36
|
-
|
|
37
|
-
async execute(
|
|
38
|
-
_toolCallId: string,
|
|
39
|
-
params: {
|
|
40
|
-
team: string;
|
|
41
|
-
name: string;
|
|
42
|
-
description?: string;
|
|
43
|
-
membershipType?: string;
|
|
44
|
-
account?: string;
|
|
45
|
-
tenant?: string;
|
|
46
|
-
},
|
|
47
|
-
signal: AbortSignal | undefined,
|
|
48
|
-
_onUpdate: undefined,
|
|
49
|
-
ctx: ToolContext,
|
|
50
|
-
): Promise<ToolResult> {
|
|
51
|
-
return run(async () => {
|
|
52
|
-
const conn = connectionFor(ctx, params.account, params.tenant);
|
|
53
|
-
const team = await requireTeam(conn, params.team, "write", signal);
|
|
54
|
-
|
|
55
|
-
const channel = await createChannel(
|
|
56
|
-
conn,
|
|
57
|
-
team,
|
|
58
|
-
{
|
|
59
|
-
displayName: params.name,
|
|
60
|
-
description: params.description,
|
|
61
|
-
membershipType: params.membershipType,
|
|
62
|
-
},
|
|
63
|
-
signal,
|
|
64
|
-
);
|
|
65
|
-
|
|
66
|
-
const me = await currentUser(conn, signal).catch(() => undefined);
|
|
67
|
-
auditWrite(conn.audit, {
|
|
68
|
-
tool: "teams_create_channel",
|
|
69
|
-
account: conn.account,
|
|
70
|
-
tenant: conn.tenant,
|
|
71
|
-
actor: me?.upn,
|
|
72
|
-
target: `team:${team.displayName}`,
|
|
73
|
-
summary: `created channel "${params.name}" (${params.membershipType ?? "standard"})`,
|
|
74
|
-
});
|
|
75
|
-
|
|
76
|
-
return textResult(
|
|
77
|
-
[
|
|
78
|
-
`✅ Channel **${team.displayName}/${channel.displayName}** created.`,
|
|
79
|
-
"",
|
|
80
|
-
`channelId: ${channel.id}`,
|
|
81
|
-
channel.webUrl ? `Open in Teams: ${channel.webUrl}` : "",
|
|
82
|
-
]
|
|
83
|
-
.filter(Boolean)
|
|
84
|
-
.join("\n"),
|
|
85
|
-
{ teamId: team.id, channelId: channel.id },
|
|
86
|
-
);
|
|
87
|
-
});
|
|
88
|
-
},
|
|
89
|
-
};
|