@patimweb/pi-teams 1.2.0 → 1.3.1
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 +29 -3
- package/src/graph/calendar.ts +87 -0
- package/src/graph/chats.ts +134 -3
- package/src/graph/mappers.ts +40 -1
- 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
|
@@ -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
|
-
};
|