@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.
@@ -0,0 +1,229 @@
1
+ /**
2
+ * teams_watch — switch listen mode on and off, and report what it is doing.
3
+ *
4
+ * The actual polling lives in the extension, because only it can inject a
5
+ * message into the session and update the status line. This tool owns the
6
+ * configuration and the explanation; the extension restarts the loop when the
7
+ * config changes.
8
+ */
9
+
10
+ import { Type } from "typebox";
11
+ import {
12
+ getConfigPath,
13
+ resolveConnection,
14
+ setWatchConfig,
15
+ type WatchConfig,
16
+ } from "../config/index.ts";
17
+ import { getActiveWatchLoop, type WatchRuntimeStatus } from "../watch/loop.ts";
18
+ import { errorResult, run, textResult, type ToolContext, type ToolResult } from "./shared.ts";
19
+
20
+ interface WatchParams {
21
+ action?: string;
22
+ account?: string;
23
+ tenant?: string;
24
+ intervalSeconds?: number;
25
+ chats?: string[];
26
+ from?: string[];
27
+ mentionOnly?: boolean;
28
+ cooldownSeconds?: number;
29
+ maxTriggersPerHour?: number;
30
+ }
31
+
32
+ /** Renders the effective settings the same way the config file would. */
33
+ function describe(watch: {
34
+ enabled: boolean;
35
+ intervalSeconds: number;
36
+ chats: string[];
37
+ from: string[];
38
+ mentionOnly: boolean;
39
+ cooldownSeconds: number;
40
+ maxTriggersPerHour: number;
41
+ }): string {
42
+ return [
43
+ `- enabled: ${watch.enabled}`,
44
+ `- poll every: ${watch.intervalSeconds} s`,
45
+ `- chats: ${watch.chats.length > 0 ? watch.chats.map((c) => `\`${c}\``).join(", ") : "every chat with recent activity"}`,
46
+ `- people: ${watch.from.length > 0 ? watch.from.map((p) => `\`${p}\``).join(", ") : "any sender"}`,
47
+ `- mentions only: ${watch.mentionOnly}`,
48
+ `- cooldown per chat: ${watch.cooldownSeconds} s`,
49
+ `- wake limit: ${watch.maxTriggersPerHour} per hour`,
50
+ ].join("\n");
51
+ }
52
+
53
+ /**
54
+ * The live facts about a running watcher, for the status action.
55
+ *
56
+ * Distinguishes "configured on" from "actually polling": after an edit to the
57
+ * config file the two can differ until the extension picks it up.
58
+ */
59
+ function runtimeLines(runtime: WatchRuntimeStatus | undefined): string[] {
60
+ if (!runtime) {
61
+ return [
62
+ "Nothing is polling in this session.",
63
+ "",
64
+ "Listen mode is read when a pi session starts. If the config says `enabled: true` and this "
65
+ + "says otherwise, start a new session — or switch it with the `/teams-listen` command.",
66
+ ];
67
+ }
68
+
69
+ const lines = [
70
+ "### Running in this session",
71
+ "",
72
+ `- running: ${runtime.running}`,
73
+ `- chats tracked: ${runtime.trackedChats}`,
74
+ `- woken this hour: ${runtime.wakesThisHour}`,
75
+ ];
76
+ if (runtime.lastTickAt) {
77
+ lines.push(`- last poll: ${new Date(runtime.lastTickAt).toLocaleTimeString()}`);
78
+ }
79
+ if (runtime.lastWakeChat) {
80
+ lines.push(`- last wake: ${runtime.lastWakeChat}`);
81
+ }
82
+ if (runtime.lastError) {
83
+ lines.push(`- ⚠️ last error: ${runtime.lastError}`);
84
+ }
85
+ return lines;
86
+ }
87
+
88
+ export const teamsWatchTool = {
89
+ name: "teams_watch",
90
+ description:
91
+ "Control listen mode: pi polls your Teams chats and turns an incoming message into a prompt it answers. " +
92
+ "Use action 'status' to see the current settings, 'enable' or 'disable' to switch it, and the optional " +
93
+ "fields to narrow what may wake pi (chat patterns, mentions only, interval, cooldown, hourly limit). " +
94
+ "The watcher runs in the extension session; this tool changes the configuration it reads.",
95
+ parameters: Type.Object({
96
+ action: Type.Optional(
97
+ Type.String({ description: "'status' (default), 'enable' or 'disable'" }),
98
+ ),
99
+ account: Type.Optional(
100
+ Type.String({ description: "Account the settings apply to; omit to set them globally" }),
101
+ ),
102
+ tenant: Type.Optional(
103
+ Type.String({
104
+ description:
105
+ "Tenant beneath that account. Used by action 'status' to report the settings in force for it — listen mode itself is configured per account, not per tenant.",
106
+ }),
107
+ ),
108
+ intervalSeconds: Type.Optional(
109
+ Type.Number({ description: "Seconds between polls (minimum 15, default 60)" }),
110
+ ),
111
+ chats: Type.Optional(
112
+ Type.Array(Type.String(), {
113
+ description:
114
+ "Glob patterns for the chats to watch — matched against topic, label, chat ID and participant names",
115
+ }),
116
+ ),
117
+ from: Type.Optional(
118
+ Type.Array(Type.String(), {
119
+ description:
120
+ "Glob patterns for the people pi listens to — matched against display name, UPN and e-mail. Omit for any sender.",
121
+ }),
122
+ ),
123
+ mentionOnly: Type.Optional(
124
+ Type.Boolean({ description: "Only wake pi where the signed-in user is mentioned" }),
125
+ ),
126
+ cooldownSeconds: Type.Optional(
127
+ Type.Number({ description: "Seconds to stay quiet in a chat after waking pi for it (default 300)" }),
128
+ ),
129
+ maxTriggersPerHour: Type.Optional(
130
+ Type.Number({ description: "Hard cap on wakes per hour (default 10)" }),
131
+ ),
132
+ }),
133
+ promptSnippet: "Turn Teams listen mode on or off",
134
+ promptGuidelines: [
135
+ "Use teams_watch when the user wants pi to notice incoming Teams messages on its own.",
136
+ "Tell the user plainly that listen mode spends a model turn per incoming message, and that pi answers in their name.",
137
+ "Enabling listen mode takes effect immediately; the settings survive a restart.",
138
+ ],
139
+
140
+ async execute(
141
+ _toolCallId: string,
142
+ params: WatchParams,
143
+ _signal: AbortSignal | undefined,
144
+ _onUpdate: undefined,
145
+ _ctx: ToolContext,
146
+ ): Promise<ToolResult> {
147
+ return run(async () => {
148
+ const action = (params.action ?? "status").toLowerCase();
149
+
150
+ // Reported from the resolved connection, so the answer shows what is in
151
+ // force for that account and tenant — not just what the file says.
152
+ const inspect = () => {
153
+ try {
154
+ return resolveConnection(params.account, params.tenant).watch;
155
+ } catch {
156
+ return undefined;
157
+ }
158
+ };
159
+
160
+ const patch: WatchConfig = {};
161
+ if (params.intervalSeconds !== undefined) patch.intervalSeconds = params.intervalSeconds;
162
+ if (params.chats !== undefined) patch.chats = params.chats;
163
+ if (params.from !== undefined) patch.from = params.from;
164
+ if (params.mentionOnly !== undefined) patch.mentionOnly = params.mentionOnly;
165
+ if (params.cooldownSeconds !== undefined) patch.cooldownSeconds = params.cooldownSeconds;
166
+ if (params.maxTriggersPerHour !== undefined) {
167
+ patch.maxTriggersPerHour = params.maxTriggersPerHour;
168
+ }
169
+
170
+ switch (action) {
171
+ case "status": {
172
+ const watch = inspect();
173
+ if (!watch) {
174
+ return errorResult(
175
+ `No usable Teams account for these settings. Check ${getConfigPath()}.`,
176
+ );
177
+ }
178
+ const loop = getActiveWatchLoop();
179
+ const runtime = loop?.status();
180
+ return textResult(
181
+ [
182
+ `## Listen mode — ${watch.enabled ? "on" : "off"}`,
183
+ "",
184
+ describe(watch),
185
+ "",
186
+ ...runtimeLines(runtime),
187
+ ].join("\n"),
188
+ { watch, runtime },
189
+ );
190
+ }
191
+
192
+ case "enable": {
193
+ const watch = setWatchConfig({ ...patch, enabled: true }, params.account);
194
+ return textResult(
195
+ [
196
+ "## Listen mode — on",
197
+ "",
198
+ describe(watch),
199
+ "",
200
+ "pi now polls these chats and treats a new message as a prompt. It answers in your name " +
201
+ "and under the safety level in force, so anything it decides to send still follows the " +
202
+ "usual confirmation rules.",
203
+ "",
204
+ `Written to ${getConfigPath()}.`,
205
+ ].join("\n"),
206
+ { watch },
207
+ );
208
+ }
209
+
210
+ case "disable": {
211
+ const watch = setWatchConfig({ ...patch, enabled: false }, params.account);
212
+ return textResult(
213
+ [
214
+ "## Listen mode — off",
215
+ "",
216
+ describe(watch),
217
+ "",
218
+ "Polling stops now; the settings stay in the file for the next time.",
219
+ ].join("\n"),
220
+ { watch },
221
+ );
222
+ }
223
+
224
+ default:
225
+ return errorResult(`Unknown action "${action}". Use 'status', 'enable' or 'disable'.`);
226
+ }
227
+ });
228
+ },
229
+ };
@@ -15,16 +15,19 @@ export const MUTATION_TOOLS = new Set<string>([
15
15
  "teams_create_chat",
16
16
  "teams_delete_message",
17
17
  "teams_react",
18
- // Structure
19
- "teams_create_channel",
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",
29
31
  "teams_logout",
32
+ "teams_watch",
30
33
  ]);
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
  // ---------------------------------------------------------------------------
@@ -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",
@@ -82,18 +82,6 @@ export function htmlToText(content: string, contentType = "html"): string {
82
82
  .trim();
83
83
  }
84
84
 
85
- /** Convert plain text to the minimal HTML Teams expects for a message body. */
86
- export function textToHtml(text: string): string {
87
- const escaped = text
88
- .replace(/&/g, "&amp;")
89
- .replace(/</g, "&lt;")
90
- .replace(/>/g, "&gt;");
91
- return escaped
92
- .split(/\n{2,}/)
93
- .map((para) => `<p>${para.replace(/\n/g, "<br>")}</p>`)
94
- .join("");
95
- }
96
-
97
85
  // ---------------------------------------------------------------------------
98
86
  // Dates
99
87
  // ---------------------------------------------------------------------------
@@ -126,6 +114,68 @@ export function formatRelative(iso: string | undefined): string {
126
114
  return formatDate(iso);
127
115
  }
128
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
+
129
179
  /** Shorten a string for previews, without cutting mid-escape. */
130
180
  export function truncate(text: string, max: number): string {
131
181
  const oneLine = text.replace(/\s*\n\s*/g, " ").trim();
@@ -0,0 +1,150 @@
1
+ /**
2
+ * Markdown-ish text → the HTML subset Microsoft Teams renders in a message.
3
+ *
4
+ * Why this exists: a message body is HTML, but the model writes markdown. Sent
5
+ * raw, `**wichtig**` arrives as literal asterisks and `- Punkt` as literal
6
+ * dashes — which is exactly the "wall of text" a person sees in a chat when
7
+ * nothing was formatted for the medium.
8
+ *
9
+ * Teams supports far less HTML than a browser. This translates only what
10
+ * survives in a chat bubble:
11
+ *
12
+ * `**bold**` → `<b>` `- item` → `<ul><li>`
13
+ * `*italic*` / `_italic_` → `<i>` `1. item` → `<ol><li>`
14
+ * `` `code` `` → `<code>` ``` fences → `<pre>`
15
+ * `~~struck~~` → `<s>` `# Heading` → a bold line
16
+ * `[label](url)` → `<a href>`
17
+ *
18
+ * Tables and images are deliberately unsupported: Teams renders neither in a
19
+ * chat, so pretending otherwise would produce worse output than plain text.
20
+ * Everything else is escaped and kept as written.
21
+ */
22
+
23
+ const HTML_ESCAPES: Record<string, string> = {
24
+ "&": "&amp;",
25
+ "<": "&lt;",
26
+ ">": "&gt;",
27
+ };
28
+
29
+ function escapeHtml(text: string): string {
30
+ return text.replace(/[&<>]/g, (char) => HTML_ESCAPES[char] ?? char);
31
+ }
32
+
33
+ /**
34
+ * Inline markup of a single line.
35
+ *
36
+ * Order matters: code spans are protected first (so `**` inside them stays
37
+ * literal), then links, then emphasis. The underscore rule requires a boundary
38
+ * on both sides so `snake_case_name` survives untouched.
39
+ */
40
+ function inline(raw: string): string {
41
+ let text = escapeHtml(raw);
42
+
43
+ text = text.replace(/`([^`]+)`/g, "<code>$1</code>");
44
+ text = text.replace(
45
+ /\[([^\]]+)\]\((https?:\/\/[^\s)]+|mailto:[^\s)]+)\)/g,
46
+ '<a href="$2">$1</a>',
47
+ );
48
+ text = text.replace(/\*\*([^*]+)\*\*/g, "<b>$1</b>");
49
+ text = text.replace(/(^|[^*\w])\*([^*\n]+)\*/g, "$1<i>$2</i>");
50
+ text = text.replace(/(^|[\s(])_([^_\n]+)_(?=$|[\s).,;:!?])/g, "$1<i>$2</i>");
51
+ text = text.replace(/~~([^~]+)~~/g, "<s>$1</s>");
52
+
53
+ return text;
54
+ }
55
+
56
+ const BULLET = /^\s*[-*+]\s+(.*)$/;
57
+ const NUMBERED = /^\s*\d+[.)]\s+(.*)$/;
58
+ const HEADING = /^\s{0,3}#{1,6}\s+(.*)$/;
59
+
60
+ /**
61
+ * Convert a message body to Teams HTML.
62
+ *
63
+ * Blank lines separate paragraphs, single newlines become `<br>` inside one —
64
+ * which is what a person typing into the chat box gets, and the safest
65
+ * assumption for text that was generated, not composed.
66
+ */
67
+ export function markdownToTeamsHtml(text: string): string {
68
+ const lines = text.replace(/\r\n?/g, "\n").split("\n");
69
+ const blocks: string[] = [];
70
+
71
+ let paragraph: string[] = [];
72
+ let list: { ordered: boolean; items: string[] } | undefined;
73
+ let code: string[] | undefined;
74
+
75
+ const flushParagraph = () => {
76
+ if (paragraph.length === 0) return;
77
+ blocks.push(`<p>${paragraph.map(inline).join("<br>")}</p>`);
78
+ paragraph = [];
79
+ };
80
+
81
+ const flushList = () => {
82
+ if (!list) return;
83
+ const tag = list.ordered ? "ol" : "ul";
84
+ const items = list.items.map((item) => `<li>${inline(item)}</li>`).join("");
85
+ blocks.push(`<${tag}>${items}</${tag}>`);
86
+ list = undefined;
87
+ };
88
+
89
+ const flushBlock = () => {
90
+ flushParagraph();
91
+ flushList();
92
+ };
93
+
94
+ for (const line of lines) {
95
+ // A fence swallows everything until the next one, verbatim.
96
+ if (/^\s*```/.test(line)) {
97
+ if (code) {
98
+ blocks.push(`<pre>${escapeHtml(code.join("\n"))}</pre>`);
99
+ code = undefined;
100
+ } else {
101
+ flushBlock();
102
+ code = [];
103
+ }
104
+ continue;
105
+ }
106
+ if (code) {
107
+ code.push(line);
108
+ continue;
109
+ }
110
+
111
+ if (line.trim() === "") {
112
+ flushBlock();
113
+ continue;
114
+ }
115
+
116
+ const heading = HEADING.exec(line);
117
+ if (heading) {
118
+ flushBlock();
119
+ blocks.push(`<p><b>${inline(heading[1])}</b></p>`);
120
+ continue;
121
+ }
122
+
123
+ const bullet = BULLET.exec(line);
124
+ if (bullet) {
125
+ flushParagraph();
126
+ if (list && list.ordered) flushList();
127
+ list ??= { ordered: false, items: [] };
128
+ list.items.push(bullet[1]);
129
+ continue;
130
+ }
131
+
132
+ const numbered = NUMBERED.exec(line);
133
+ if (numbered) {
134
+ flushParagraph();
135
+ if (list && !list.ordered) flushList();
136
+ list ??= { ordered: true, items: [] };
137
+ list.items.push(numbered[1]);
138
+ continue;
139
+ }
140
+
141
+ flushList();
142
+ paragraph.push(line);
143
+ }
144
+
145
+ // An unterminated fence is still code, not lost text.
146
+ if (code) blocks.push(`<pre>${escapeHtml(code.join("\n"))}</pre>`);
147
+ flushBlock();
148
+
149
+ return blocks.join("");
150
+ }
@@ -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
+ }