@patimweb/pi-teams 1.0.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.
Files changed (63) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +412 -0
  3. package/index.ts +1 -0
  4. package/package.json +61 -0
  5. package/prompts/teams-catch-up.md +24 -0
  6. package/prompts/teams-channel-digest.md +23 -0
  7. package/prompts/teams-doctor.md +21 -0
  8. package/prompts/teams-meeting-prep.md +22 -0
  9. package/prompts/teams-standup.md +21 -0
  10. package/prompts/teams-triage.md +22 -0
  11. package/skills/teams-collaboration/SKILL.md +143 -0
  12. package/src/auth/client-credentials.ts +72 -0
  13. package/src/auth/device-code.ts +276 -0
  14. package/src/auth/index.ts +105 -0
  15. package/src/auth/jwt.ts +50 -0
  16. package/src/auth/token-store.ts +156 -0
  17. package/src/config/index.ts +685 -0
  18. package/src/config/scope.ts +272 -0
  19. package/src/extension/index.ts +336 -0
  20. package/src/graph/calendar.ts +165 -0
  21. package/src/graph/chats.ts +159 -0
  22. package/src/graph/client.ts +234 -0
  23. package/src/graph/mappers.ts +268 -0
  24. package/src/graph/me.ts +80 -0
  25. package/src/graph/messages.ts +383 -0
  26. package/src/graph/presence.ts +160 -0
  27. package/src/graph/search.ts +111 -0
  28. package/src/graph/teams.ts +271 -0
  29. package/src/safety/audit.ts +57 -0
  30. package/src/safety/index.ts +186 -0
  31. package/src/status.ts +90 -0
  32. package/src/tools/resolve.ts +138 -0
  33. package/src/tools/shared.ts +124 -0
  34. package/src/tools/teams-accounts.ts +169 -0
  35. package/src/tools/teams-create-channel.ts +89 -0
  36. package/src/tools/teams-create-chat.ts +112 -0
  37. package/src/tools/teams-delete-message.ts +114 -0
  38. package/src/tools/teams-doctor.ts +182 -0
  39. package/src/tools/teams-find-user.ts +64 -0
  40. package/src/tools/teams-inbox.ts +142 -0
  41. package/src/tools/teams-list-channels.ts +62 -0
  42. package/src/tools/teams-list-chats.ts +74 -0
  43. package/src/tools/teams-list-files.ts +54 -0
  44. package/src/tools/teams-list-members.ts +72 -0
  45. package/src/tools/teams-list-teams.ts +65 -0
  46. package/src/tools/teams-login.ts +199 -0
  47. package/src/tools/teams-meetings.ts +342 -0
  48. package/src/tools/teams-permissions.ts +169 -0
  49. package/src/tools/teams-presence.ts +216 -0
  50. package/src/tools/teams-react.ts +109 -0
  51. package/src/tools/teams-read-channel.ts +75 -0
  52. package/src/tools/teams-read-chat.ts +73 -0
  53. package/src/tools/teams-read-thread.ts +88 -0
  54. package/src/tools/teams-search-messages.ts +71 -0
  55. package/src/tools/teams-send-channel-message.ts +250 -0
  56. package/src/tools/teams-send-chat-message.ts +152 -0
  57. package/src/tools/teams-setup.ts +245 -0
  58. package/src/tools/teams-status.ts +73 -0
  59. package/src/tools/tool-names.ts +30 -0
  60. package/src/types.ts +192 -0
  61. package/src/utils/errors.ts +133 -0
  62. package/src/utils/formatting.ts +356 -0
  63. package/tsconfig.json +16 -0
@@ -0,0 +1,165 @@
1
+ /**
2
+ * Calendar and Teams meetings.
3
+ *
4
+ * Creating a meeting goes through a calendar event with `isOnlineMeeting:
5
+ * true` rather than `/me/onlineMeetings`: that way the meeting lands in
6
+ * everyone's calendar with invitations, which is what a person means when they
7
+ * say "set up a Teams meeting". `/me/onlineMeetings` is still exposed for the
8
+ * ad-hoc "just give me a join link" case.
9
+ */
10
+
11
+ import type { TeamsConnection } from "../config/index.ts";
12
+ import type { EventSummary, OnlineMeetingSummary } from "../types.ts";
13
+ import { graphDelete, graphGetOptional, graphList, graphPatch, graphPost } from "./client.ts";
14
+ import { mapEvent } from "./mappers.ts";
15
+
16
+ /* eslint-disable @typescript-eslint/no-explicit-any */
17
+ type Raw = Record<string, any>;
18
+
19
+ export interface EventInput {
20
+ subject: string;
21
+ /** ISO 8601 local date-time, e.g. "2026-09-15T10:00:00" */
22
+ start: string;
23
+ end: string;
24
+ /** IANA or Windows time zone name; defaults to the mailbox setting */
25
+ timeZone?: string;
26
+ /** SMTP addresses */
27
+ attendees?: string[];
28
+ /** Optional attendees */
29
+ optionalAttendees?: string[];
30
+ body?: string;
31
+ location?: string;
32
+ /** Create a Teams meeting link (default true) */
33
+ onlineMeeting?: boolean;
34
+ }
35
+
36
+ /** Events in a time range, ordered by start. */
37
+ export async function listEvents(
38
+ conn: TeamsConnection,
39
+ options: { start?: string; end?: string; max?: number; signal?: AbortSignal } = {},
40
+ ): Promise<EventSummary[]> {
41
+ const start = options.start ?? new Date().toISOString();
42
+ const end = options.end ?? new Date(Date.now() + 7 * 24 * 3600 * 1000).toISOString();
43
+
44
+ // calendarView expands recurring series into real occurrences; /me/events
45
+ // would return the series master and miss today's instance.
46
+ const raw = await graphList<Raw>(conn, "/me/calendarView", {
47
+ query: {
48
+ startDateTime: start,
49
+ endDateTime: end,
50
+ $orderby: "start/dateTime",
51
+ $top: Math.min(options.max ?? 25, 50),
52
+ $select:
53
+ "id,subject,start,end,isAllDay,isOnlineMeeting,onlineMeeting,organizer,attendees,location,bodyPreview,webLink",
54
+ },
55
+ headers: { Prefer: 'outlook.timezone="UTC"' },
56
+ max: options.max ?? 25,
57
+ signal: options.signal,
58
+ });
59
+
60
+ return raw.map((entry) => mapEvent(entry));
61
+ }
62
+
63
+ export async function getEvent(
64
+ conn: TeamsConnection,
65
+ eventId: string,
66
+ signal?: AbortSignal,
67
+ ): Promise<EventSummary | undefined> {
68
+ const raw = await graphGetOptional<Raw>(conn, `/me/events/${encodeURIComponent(eventId)}`, {
69
+ signal,
70
+ });
71
+ return raw ? mapEvent(raw) : undefined;
72
+ }
73
+
74
+ function attendeeEntry(address: string, type: "required" | "optional") {
75
+ return { emailAddress: { address }, type };
76
+ }
77
+
78
+ export async function createEvent(
79
+ conn: TeamsConnection,
80
+ input: EventInput,
81
+ signal?: AbortSignal,
82
+ ): Promise<EventSummary> {
83
+ const timeZone = input.timeZone ?? "UTC";
84
+
85
+ const body: Record<string, unknown> = {
86
+ subject: input.subject,
87
+ start: { dateTime: input.start, timeZone },
88
+ end: { dateTime: input.end, timeZone },
89
+ isOnlineMeeting: input.onlineMeeting !== false,
90
+ onlineMeetingProvider: "teamsForBusiness",
91
+ attendees: [
92
+ ...(input.attendees ?? []).map((a) => attendeeEntry(a, "required")),
93
+ ...(input.optionalAttendees ?? []).map((a) => attendeeEntry(a, "optional")),
94
+ ],
95
+ };
96
+ if (input.body) body.body = { contentType: "html", content: input.body };
97
+ if (input.location) body.location = { displayName: input.location };
98
+
99
+ const raw = await graphPost<Raw>(conn, "/me/events", body, { signal });
100
+ return mapEvent(raw ?? {});
101
+ }
102
+
103
+ export async function updateEvent(
104
+ conn: TeamsConnection,
105
+ eventId: string,
106
+ changes: Partial<EventInput>,
107
+ signal?: AbortSignal,
108
+ ): Promise<EventSummary> {
109
+ const timeZone = changes.timeZone ?? "UTC";
110
+ const body: Record<string, unknown> = {};
111
+
112
+ if (changes.subject) body.subject = changes.subject;
113
+ if (changes.start) body.start = { dateTime: changes.start, timeZone };
114
+ if (changes.end) body.end = { dateTime: changes.end, timeZone };
115
+ if (changes.body) body.body = { contentType: "html", content: changes.body };
116
+ if (changes.location) body.location = { displayName: changes.location };
117
+ if (changes.attendees || changes.optionalAttendees) {
118
+ body.attendees = [
119
+ ...(changes.attendees ?? []).map((a) => attendeeEntry(a, "required")),
120
+ ...(changes.optionalAttendees ?? []).map((a) => attendeeEntry(a, "optional")),
121
+ ];
122
+ }
123
+
124
+ const raw = await graphPatch<Raw>(conn, `/me/events/${encodeURIComponent(eventId)}`, body, {
125
+ signal,
126
+ });
127
+ return mapEvent(raw ?? {});
128
+ }
129
+
130
+ /** Cancel an event you organize (sends cancellations), or decline/delete otherwise. */
131
+ export async function cancelEvent(
132
+ conn: TeamsConnection,
133
+ eventId: string,
134
+ comment: string | undefined,
135
+ signal?: AbortSignal,
136
+ ): Promise<void> {
137
+ try {
138
+ await graphPost(conn, `/me/events/${encodeURIComponent(eventId)}/cancel`, { comment }, { signal });
139
+ } catch {
140
+ // Only the organizer may cancel; for everyone else removing it from the
141
+ // calendar is the closest equivalent.
142
+ await graphDelete(conn, `/me/events/${encodeURIComponent(eventId)}`, { signal });
143
+ }
144
+ }
145
+
146
+ /** Ad-hoc meeting link with no calendar entry. */
147
+ export async function createOnlineMeeting(
148
+ conn: TeamsConnection,
149
+ input: { subject?: string; start?: string; end?: string },
150
+ signal?: AbortSignal,
151
+ ): Promise<OnlineMeetingSummary> {
152
+ const body: Record<string, unknown> = {};
153
+ if (input.subject) body.subject = input.subject;
154
+ if (input.start) body.startDateTime = input.start;
155
+ if (input.end) body.endDateTime = input.end;
156
+
157
+ const raw = await graphPost<Raw>(conn, "/me/onlineMeetings", body, { signal });
158
+ return {
159
+ id: raw?.id ?? "",
160
+ subject: raw?.subject ?? input.subject,
161
+ joinUrl: raw?.joinWebUrl ?? raw?.joinUrl ?? "",
162
+ start: raw?.startDateTime,
163
+ end: raw?.endDateTime,
164
+ };
165
+ }
@@ -0,0 +1,159 @@
1
+ /**
2
+ * 1:1, group and meeting chats.
3
+ */
4
+
5
+ import type { TeamsConnection } from "../config/index.ts";
6
+ import type { ChatSummary, PersonRef } from "../types.ts";
7
+ import { graphGetOptional, graphList, graphPost } from "./client.ts";
8
+ import { GraphError } from "../utils/errors.ts";
9
+ import { mapChat } from "./mappers.ts";
10
+ import { resolveUserId } from "./me.ts";
11
+
12
+ /**
13
+ * The signed-in user's chats, newest activity first.
14
+ *
15
+ * `$expand=members` is what turns an opaque `19:…@thread.v2` into "Anna Schmidt,
16
+ * Tom Weber", which is both what a person recognizes and what the scope rules
17
+ * match on — so it is always expanded, never optional.
18
+ */
19
+ export async function listChats(
20
+ conn: TeamsConnection,
21
+ options: { max?: number; meId?: string; signal?: AbortSignal } = {},
22
+ ): Promise<ChatSummary[]> {
23
+ const max = options.max ?? 25;
24
+
25
+ const fetchChats = (orderBy: boolean) =>
26
+ graphList<Record<string, unknown>>(conn, "/me/chats", {
27
+ query: {
28
+ $expand: "members,lastMessagePreview",
29
+ $orderby: orderBy ? "lastMessagePreview/createdDateTime desc" : undefined,
30
+ $top: 50,
31
+ },
32
+ max,
33
+ signal: options.signal,
34
+ });
35
+
36
+ let raw: Record<string, unknown>[];
37
+ try {
38
+ raw = await fetchChats(true);
39
+ } catch (err) {
40
+ // $orderby combined with $expand is not accepted in every tenant. Losing
41
+ // the server-side sort is survivable; losing the chat list is not.
42
+ if (err instanceof GraphError && err.status === 400) {
43
+ raw = await fetchChats(false);
44
+ } else {
45
+ throw err;
46
+ }
47
+ }
48
+
49
+ const chats = raw.map((entry) => mapChat(entry as Record<string, never>, options.meId));
50
+
51
+ // Sort defensively: the fallback path is unsorted, and even the ordered one
52
+ // puts chats without a preview in an arbitrary place.
53
+ return chats.sort((a, b) => {
54
+ const left = a.lastUpdated ? new Date(a.lastUpdated).getTime() : 0;
55
+ const right = b.lastUpdated ? new Date(b.lastUpdated).getTime() : 0;
56
+ return right - left;
57
+ });
58
+ }
59
+
60
+ export async function getChat(
61
+ conn: TeamsConnection,
62
+ chatId: string,
63
+ options: { meId?: string; signal?: AbortSignal } = {},
64
+ ): Promise<ChatSummary | undefined> {
65
+ const raw = await graphGetOptional<Record<string, unknown>>(
66
+ conn,
67
+ `/chats/${encodeURIComponent(chatId)}`,
68
+ { query: { $expand: "members" }, signal: options.signal },
69
+ );
70
+ return raw ? mapChat(raw as Record<string, never>, options.meId) : undefined;
71
+ }
72
+
73
+ /**
74
+ * Find an existing chat by label, topic or participant.
75
+ *
76
+ * Returns every match so the caller can refuse to act on an ambiguous one —
77
+ * "message Anna" must not land in the wrong Anna's chat.
78
+ */
79
+ export async function findChats(
80
+ conn: TeamsConnection,
81
+ reference: string,
82
+ options: { max?: number; meId?: string; signal?: AbortSignal } = {},
83
+ ): Promise<ChatSummary[]> {
84
+ if (reference.startsWith("19:")) {
85
+ const direct = await getChat(conn, reference, options);
86
+ return direct ? [direct] : [];
87
+ }
88
+
89
+ const chats = await listChats(conn, { max: options.max ?? 100, meId: options.meId, signal: options.signal });
90
+ const lower = reference.toLowerCase();
91
+
92
+ const exact = chats.filter(
93
+ (chat) =>
94
+ chat.label.toLowerCase() === lower ||
95
+ chat.topic?.toLowerCase() === lower ||
96
+ chat.members.some(
97
+ (m) => m.upn?.toLowerCase() === lower || m.mail?.toLowerCase() === lower,
98
+ ),
99
+ );
100
+ if (exact.length > 0) return exact;
101
+
102
+ return chats.filter(
103
+ (chat) =>
104
+ chat.label.toLowerCase().includes(lower) ||
105
+ chat.members.some((m) => m.displayName.toLowerCase().includes(lower)),
106
+ );
107
+ }
108
+
109
+ /**
110
+ * Create a 1:1 or group chat with the given people.
111
+ *
112
+ * Teams reuses the existing 1:1 chat when one is already there, so this is
113
+ * safe to call for "send Anna a message" without checking first.
114
+ */
115
+ export async function createChat(
116
+ conn: TeamsConnection,
117
+ participants: PersonRef[],
118
+ options: { topic?: string; signal?: AbortSignal } = {},
119
+ ): Promise<ChatSummary> {
120
+ const chatType = participants.length > 1 ? "group" : "oneOnOne";
121
+
122
+ const members = [
123
+ // The signed-in user must be listed explicitly.
124
+ {
125
+ "@odata.type": "#microsoft.graph.aadUserConversationMember",
126
+ roles: ["owner"],
127
+ "user@odata.bind": "https://graph.microsoft.com/v1.0/me",
128
+ },
129
+ ...participants.map((person) => ({
130
+ "@odata.type": "#microsoft.graph.aadUserConversationMember",
131
+ roles: ["owner"],
132
+ "user@odata.bind": `https://graph.microsoft.com/v1.0/users('${person.id ?? person.upn ?? person.mail}')`,
133
+ })),
134
+ ];
135
+
136
+ const body: Record<string, unknown> = { chatType, members };
137
+ if (chatType === "group" && options.topic) body.topic = options.topic;
138
+
139
+ const raw = await graphPost<Record<string, unknown>>(conn, "/chats", body, { signal: options.signal });
140
+ return mapChat((raw ?? {}) as Record<string, never>);
141
+ }
142
+
143
+ /** Resolve people references (UPN, e-mail, name) to PersonRefs. */
144
+ export async function resolveParticipants(
145
+ conn: TeamsConnection,
146
+ references: string[],
147
+ signal?: AbortSignal,
148
+ ): Promise<{ resolved: PersonRef[]; unresolved: string[] }> {
149
+ const resolved: PersonRef[] = [];
150
+ const unresolved: string[] = [];
151
+
152
+ for (const reference of references) {
153
+ const person = await resolveUserId(conn, reference, signal);
154
+ if (person) resolved.push(person);
155
+ else unresolved.push(reference);
156
+ }
157
+
158
+ return { resolved, unresolved };
159
+ }
@@ -0,0 +1,234 @@
1
+ /**
2
+ * Microsoft Graph HTTP client.
3
+ *
4
+ * A thin, dependency-free wrapper around fetch that handles the three things
5
+ * every Graph call needs and every hand-rolled call forgets: a fresh bearer
6
+ * token, `@odata.nextLink` paging, and 429/503 back-off honouring
7
+ * `Retry-After`.
8
+ */
9
+
10
+ import type { TeamsConnection } from "../config/index.ts";
11
+ import { getAccessToken } from "../auth/index.ts";
12
+ import { GraphError } from "../utils/errors.ts";
13
+
14
+ // ---------------------------------------------------------------------------
15
+ // Types
16
+ // ---------------------------------------------------------------------------
17
+
18
+ export type HttpMethod = "GET" | "POST" | "PATCH" | "PUT" | "DELETE";
19
+
20
+ export interface RequestOptions {
21
+ /** Query string parameters — `$select`, `$filter`, `$top`, … */
22
+ query?: Record<string, string | number | boolean | undefined>;
23
+ /** JSON request body */
24
+ body?: unknown;
25
+ signal?: AbortSignal;
26
+ /** Use the beta endpoint instead of v1.0 */
27
+ beta?: boolean;
28
+ /** Extra headers, e.g. Prefer: include-unknown-enum-members */
29
+ headers?: Record<string, string>;
30
+ /** Max retries for throttling responses (default 3) */
31
+ maxRetries?: number;
32
+ }
33
+
34
+ interface GraphErrorBody {
35
+ error?: {
36
+ code?: string;
37
+ message?: string;
38
+ innerError?: { "request-id"?: string; date?: string };
39
+ };
40
+ }
41
+
42
+ interface GraphCollection<T> {
43
+ value: T[];
44
+ "@odata.nextLink"?: string;
45
+ "@odata.count"?: number;
46
+ }
47
+
48
+ // ---------------------------------------------------------------------------
49
+ // URL building
50
+ // ---------------------------------------------------------------------------
51
+
52
+ function buildUrl(conn: TeamsConnection, path: string, options: RequestOptions): string {
53
+ const base = options.beta
54
+ ? conn.graphBaseUrl.replace(/\/v1\.0$/, "/beta")
55
+ : conn.graphBaseUrl;
56
+ const url = new URL(path.startsWith("http") ? path : `${base}${path.startsWith("/") ? "" : "/"}${path}`);
57
+
58
+ for (const [key, value] of Object.entries(options.query ?? {})) {
59
+ if (value === undefined) continue;
60
+ url.searchParams.set(key, String(value));
61
+ }
62
+
63
+ return url.toString();
64
+ }
65
+
66
+ // ---------------------------------------------------------------------------
67
+ // Core request
68
+ // ---------------------------------------------------------------------------
69
+
70
+ function sleep(ms: number): Promise<void> {
71
+ return new Promise((resolve) => setTimeout(resolve, ms));
72
+ }
73
+
74
+ /**
75
+ * Perform a Graph request and return the parsed body.
76
+ *
77
+ * Returns `undefined` for 204 No Content, which Graph uses for most writes
78
+ * (reactions, deletes, presence).
79
+ *
80
+ * @throws {GraphError} for any non-2xx response that survives the retries
81
+ */
82
+ export async function graphRequest<T = unknown>(
83
+ conn: TeamsConnection,
84
+ method: HttpMethod,
85
+ path: string,
86
+ options: RequestOptions = {},
87
+ ): Promise<T | undefined> {
88
+ const maxRetries = options.maxRetries ?? 3;
89
+ const url = buildUrl(conn, path, options);
90
+
91
+ let attempt = 0;
92
+ for (;;) {
93
+ const token = await getAccessToken(conn, options.signal);
94
+
95
+ const headers: Record<string, string> = {
96
+ Authorization: `Bearer ${token.accessToken}`,
97
+ Accept: "application/json",
98
+ ...options.headers,
99
+ };
100
+ if (options.body !== undefined) headers["Content-Type"] = "application/json";
101
+
102
+ const response = await fetch(url, {
103
+ method,
104
+ headers,
105
+ body: options.body === undefined ? undefined : JSON.stringify(options.body),
106
+ signal: options.signal,
107
+ });
108
+
109
+ if (response.status === 204 || response.status === 202) {
110
+ return undefined;
111
+ }
112
+
113
+ if (response.ok) {
114
+ const text = await response.text();
115
+ if (!text) return undefined;
116
+ return JSON.parse(text) as T;
117
+ }
118
+
119
+ // Throttling and transient service errors: back off and retry.
120
+ if ((response.status === 429 || response.status === 503) && attempt < maxRetries) {
121
+ const retryAfter = Number(response.headers.get("Retry-After") ?? "0");
122
+ // Exponential fallback when the header is missing, capped so a tool
123
+ // call cannot hang for minutes.
124
+ const waitMs = retryAfter > 0 ? retryAfter * 1000 : Math.min(2 ** attempt * 1000, 8000);
125
+ attempt += 1;
126
+ await sleep(waitMs);
127
+ continue;
128
+ }
129
+
130
+ throw await toGraphError(response);
131
+ }
132
+ }
133
+
134
+ async function toGraphError(response: Response): Promise<GraphError> {
135
+ let code = `HTTP${response.status}`;
136
+ let message = response.statusText || `Request failed with status ${response.status}`;
137
+ let requestId: string | undefined;
138
+ let raw: unknown;
139
+
140
+ try {
141
+ const text = await response.text();
142
+ if (text) {
143
+ raw = JSON.parse(text) as GraphErrorBody;
144
+ const body = raw as GraphErrorBody;
145
+ if (body.error?.code) code = body.error.code;
146
+ if (body.error?.message) message = body.error.message;
147
+ requestId = body.error?.innerError?.["request-id"];
148
+ }
149
+ } catch {
150
+ /* keep the status-based defaults */
151
+ }
152
+
153
+ return new GraphError(response.status, code, message, requestId, raw);
154
+ }
155
+
156
+ // ---------------------------------------------------------------------------
157
+ // Collections
158
+ // ---------------------------------------------------------------------------
159
+
160
+ /**
161
+ * Fetch a collection, following `@odata.nextLink` until `max` items are
162
+ * collected. `max` exists because a chat history can be tens of thousands of
163
+ * messages and nobody wants that in a tool result.
164
+ */
165
+ export async function graphList<T>(
166
+ conn: TeamsConnection,
167
+ path: string,
168
+ options: RequestOptions & { max?: number } = {},
169
+ ): Promise<T[]> {
170
+ const max = options.max ?? 100;
171
+ const items: T[] = [];
172
+ let nextPath: string | undefined = path;
173
+ let query: RequestOptions["query"] | undefined = options.query;
174
+
175
+ while (nextPath && items.length < max) {
176
+ const page: GraphCollection<T> | undefined = await graphRequest<GraphCollection<T>>(
177
+ conn,
178
+ "GET",
179
+ nextPath,
180
+ { ...options, query },
181
+ );
182
+ if (!page?.value) break;
183
+
184
+ items.push(...page.value);
185
+ nextPath = page["@odata.nextLink"];
186
+ // nextLink already carries the query string.
187
+ query = undefined;
188
+ }
189
+
190
+ return items.slice(0, max);
191
+ }
192
+
193
+ /** GET a single resource; `undefined` on 404 instead of throwing. */
194
+ export async function graphGetOptional<T>(
195
+ conn: TeamsConnection,
196
+ path: string,
197
+ options: RequestOptions = {},
198
+ ): Promise<T | undefined> {
199
+ try {
200
+ return await graphRequest<T>(conn, "GET", path, options);
201
+ } catch (err) {
202
+ if (err instanceof GraphError && err.status === 404) return undefined;
203
+ throw err;
204
+ }
205
+ }
206
+
207
+ /** POST a JSON body. */
208
+ export function graphPost<T = unknown>(
209
+ conn: TeamsConnection,
210
+ path: string,
211
+ body: unknown,
212
+ options: RequestOptions = {},
213
+ ): Promise<T | undefined> {
214
+ return graphRequest<T>(conn, "POST", path, { ...options, body });
215
+ }
216
+
217
+ /** PATCH a JSON body. */
218
+ export function graphPatch<T = unknown>(
219
+ conn: TeamsConnection,
220
+ path: string,
221
+ body: unknown,
222
+ options: RequestOptions = {},
223
+ ): Promise<T | undefined> {
224
+ return graphRequest<T>(conn, "PATCH", path, { ...options, body });
225
+ }
226
+
227
+ /** DELETE a resource. */
228
+ export function graphDelete(
229
+ conn: TeamsConnection,
230
+ path: string,
231
+ options: RequestOptions = {},
232
+ ): Promise<unknown> {
233
+ return graphRequest(conn, "DELETE", path, options);
234
+ }