@patimweb/pi-teams 1.0.0 → 1.2.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,224 @@
1
+ /**
2
+ * Listen mode — the pure half.
3
+ *
4
+ * "Watching" Teams means answering one question over and over: *is there
5
+ * anything here that should wake pi up?* That question is decided entirely
6
+ * from data — the chat list, one message, and what we have already seen — so it
7
+ * lives here as pure functions. The polling, the Graph calls and the prompt
8
+ * injection are in ./loop.ts.
9
+ *
10
+ * Two things are deliberately not pure: the clock and the fetch. Both are
11
+ * injected, which is what makes the decision rules testable without a tenant.
12
+ */
13
+
14
+ import type { ChatSummary, MessageSummary, PersonRef, SignedInUser } from "../types.ts";
15
+ import type { ResolvedWatchConfig } from "../config/index.ts";
16
+ import { matchesPattern } from "../config/scope.ts";
17
+ import { chatCandidates } from "../graph/mappers.ts";
18
+
19
+ // ---------------------------------------------------------------------------
20
+ // State
21
+ // ---------------------------------------------------------------------------
22
+
23
+ /**
24
+ * What the watcher remembers between ticks.
25
+ *
26
+ * `seen` is keyed by chat and holds the activity marker of the last message we
27
+ * looked at, so a tick can skip every chat that has not moved. It is a
28
+ * high-water mark, not a read receipt: nothing here is ever sent to Teams.
29
+ */
30
+ export interface WatchState {
31
+ /** chatId → activity marker of the last examined message */
32
+ seen: Map<string, string>;
33
+ /** chatId → epoch ms of the last wake in that chat */
34
+ lastTriggered: Map<string, number>;
35
+ /** epoch ms of every wake, pruned to the last hour */
36
+ triggers: number[];
37
+ }
38
+
39
+ export function createWatchState(): WatchState {
40
+ return { seen: new Map(), lastTriggered: new Map(), triggers: [] };
41
+ }
42
+
43
+ /** Marker for "this is the newest state of that chat we have looked at". */
44
+ export function activityMarker(chat: ChatSummary): string {
45
+ return chat.lastUpdated ?? "";
46
+ }
47
+
48
+ /** How many wakes happened within the last hour. */
49
+ export function wakesThisHour(state: WatchState, now: number): number {
50
+ return state.triggers.filter((at) => now - at < 3600_000).length;
51
+ }
52
+
53
+ /**
54
+ * Drop bookkeeping that can no longer influence a decision.
55
+ *
56
+ * A session can run for days; without this the two maps grow with every chat
57
+ * the user ever touches. The hour is the longest window any rule looks at.
58
+ */
59
+ export function pruneState(state: WatchState, now: number): void {
60
+ state.triggers = state.triggers.filter((at) => now - at < 3600_000);
61
+ for (const [chatId, at] of state.lastTriggered) {
62
+ if (now - at > 24 * 3600_000) state.lastTriggered.delete(chatId);
63
+ }
64
+ }
65
+
66
+ // ---------------------------------------------------------------------------
67
+ // Which chats are worth a look
68
+ // ---------------------------------------------------------------------------
69
+
70
+ /**
71
+ * Chats that are both allowed by the watch filter and have moved since we last
72
+ * looked.
73
+ *
74
+ * @param freshnessMs How far back "new" reaches. Enabling listen mode must not
75
+ * wake pi for every conversation that happened to be active this morning, so
76
+ * anything older than the window is recorded as seen and then ignored.
77
+ */
78
+ export function chatsToExamine(
79
+ state: WatchState,
80
+ chats: ChatSummary[],
81
+ watch: ResolvedWatchConfig,
82
+ now: number,
83
+ freshnessMs: number,
84
+ ): ChatSummary[] {
85
+ const cutoff = now - freshnessMs;
86
+
87
+ return chats.filter((chat) => {
88
+ if (!isWatchedChat(chat, watch)) return false;
89
+ if (state.seen.get(chat.id) === activityMarker(chat)) return false;
90
+
91
+ const updated = chat.lastUpdated ? new Date(chat.lastUpdated).getTime() : 0;
92
+ // A chat with no activity timestamp cannot be dated, so it is never new.
93
+ if (!updated || updated < cutoff) return false;
94
+
95
+ return true;
96
+ });
97
+ }
98
+
99
+ /** Does this chat fall inside the configured chat filter? */
100
+ export function isWatchedChat(chat: ChatSummary, watch: ResolvedWatchConfig): boolean {
101
+ if (watch.chats.length === 0) return true;
102
+ const candidates = chatCandidates(chat).filter((value): value is string => !!value);
103
+ return watch.chats.some((pattern) => candidates.some((candidate) => matchesPattern(pattern, candidate)));
104
+ }
105
+
106
+ // ---------------------------------------------------------------------------
107
+ // Who pi listens to
108
+ // ---------------------------------------------------------------------------
109
+
110
+ /**
111
+ * Does the sender fall inside the configured listen list?
112
+ *
113
+ * `chats` says where pi listens, this says to whom — the person filter is what
114
+ * keeps listen mode from becoming "answer the whole company". Matched against
115
+ * every identifier the message carries, like the scope rules do.
116
+ */
117
+ export function isWatchedSender(message: MessageSummary, watch: ResolvedWatchConfig): boolean {
118
+ if (watch.from.length === 0) return true;
119
+
120
+ const sender = message.from;
121
+ if (!sender) return false;
122
+
123
+ const candidates = [sender.displayName, sender.upn, sender.mail, sender.id].filter(
124
+ (value): value is string => !!value,
125
+ );
126
+ return watch.from.some((pattern) => candidates.some((candidate) => matchesPattern(pattern, candidate)));
127
+ }
128
+
129
+ // ---------------------------------------------------------------------------
130
+ // Whether a message should wake pi
131
+ // ---------------------------------------------------------------------------
132
+
133
+ export type WakeDecision = { wake: true } | { wake: false; reason: string };
134
+
135
+ const WAKE: WakeDecision = { wake: true };
136
+ const skip = (reason: string): WakeDecision => ({ wake: false, reason });
137
+
138
+ /**
139
+ * Should this message wake pi?
140
+ *
141
+ * Ordered so the cheapest and most obvious reason to stay quiet is also the
142
+ * one that gets reported — a wrong wake costs a model turn, a wrong silence
143
+ * costs nothing but the reason in the log.
144
+ */
145
+ export function shouldWake(
146
+ chat: ChatSummary,
147
+ message: MessageSummary,
148
+ me: SignedInUser | undefined,
149
+ watch: ResolvedWatchConfig,
150
+ state: WatchState,
151
+ now: number,
152
+ ): WakeDecision {
153
+ if (message.deletedDateTime) return skip("the message was deleted");
154
+ if (isFromMe(message.from, me)) return skip("the last message is my own");
155
+
156
+ if (!isWatchedChat(chat, watch)) return skip("the chat is outside the configured watch list");
157
+ if (!isWatchedSender(message, watch)) return skip("the sender is outside the configured listen list");
158
+ if (watch.mentionOnly && !mentionsMe(message, me)) return skip("the chat is set to mentions only");
159
+ if (watch.maxTriggersPerHour > 0 && wakesThisHour(state, now) >= watch.maxTriggersPerHour) {
160
+ return skip("the hourly wake limit is reached");
161
+ }
162
+
163
+ const last = state.lastTriggered.get(chat.id);
164
+ if (last !== undefined && now - last < watch.cooldownSeconds * 1000) {
165
+ return skip("this chat is inside its cooldown");
166
+ }
167
+
168
+ return WAKE;
169
+ }
170
+
171
+ /**
172
+ * Record that pi was woken for a chat.
173
+ *
174
+ * Split from the decision so `shouldWake` stays free of side effects and can be
175
+ * called speculatively.
176
+ */
177
+ export function noteWake(state: WatchState, chatId: string, now: number): void {
178
+ state.lastTriggered.set(chatId, now);
179
+ state.triggers.push(now);
180
+ }
181
+
182
+ /**
183
+ * Was this message sent by the signed-in user?
184
+ *
185
+ * pi posts as the user, so its own replies come back as "my own message" on the
186
+ * next tick — which is what stops the watcher from waking on itself. Matching
187
+ * on the ID first and the display name second covers both a chat member entry
188
+ * that carries an ID and one that only carries a name.
189
+ */
190
+ export function isFromMe(from: PersonRef | undefined, me: SignedInUser | undefined): boolean {
191
+ if (!from || !me) return false;
192
+ if (from.id && me.id && from.id === me.id) return true;
193
+ return !!from.displayName && from.displayName === me.displayName;
194
+ }
195
+
196
+ /**
197
+ * Does this message mention the signed-in user?
198
+ *
199
+ * Graph fills `mentions` for real @-mentions; the text fallback covers the
200
+ * common case of someone typing a first name, which is what "mentions only"
201
+ * means to a person waiting for an answer.
202
+ */
203
+ export function mentionsMe(message: MessageSummary, me: SignedInUser | undefined): boolean {
204
+ if (!me) return false;
205
+
206
+ const mentioned = message.mentions.some(
207
+ (person) =>
208
+ (person.id && person.id === me.id) ||
209
+ (person.displayName && person.displayName === me.displayName),
210
+ );
211
+ if (mentioned) return true;
212
+
213
+ const text = message.text ?? "";
214
+ if (me.displayName && text.includes(`@${me.displayName}`)) return true;
215
+
216
+ // First name only: "Patrick, kannst du…". Word-boundary-ish, case-insensitive.
217
+ const firstName = me.displayName.split(/\s+/)[0];
218
+ if (!firstName || firstName.length < 3) return false;
219
+ return new RegExp(`(^|[^\\p{L}])${escapeRegExp(firstName)}($|[^\\p{L}])`, "iu").test(text);
220
+ }
221
+
222
+ function escapeRegExp(value: string): string {
223
+ return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
224
+ }
@@ -0,0 +1,304 @@
1
+ /**
2
+ * Listen mode — the loop.
3
+ *
4
+ * Polls the signed-in user's chats and reports the ones that should wake pi.
5
+ * Deliberately knows nothing about pi: it takes a callback, so the decision
6
+ * rules (./index.ts) and the polling can each be tested on their own, and the
7
+ * extension stays the only place that touches the session.
8
+ *
9
+ * Why polling and not a webhook: Microsoft Graph change notifications for chats
10
+ * need a publicly reachable HTTPS endpoint plus subscription renewal, which a
11
+ * laptop on a home network cannot offer. Polling costs one `/me/chats` call per
12
+ * interval and works everywhere.
13
+ */
14
+
15
+ import type { ChatSummary, MessageSummary, SignedInUser } from "../types.ts";
16
+ import type { ResolvedWatchConfig, TeamsConnection } from "../config/index.ts";
17
+ import { listChats } from "../graph/chats.ts";
18
+ import { listChatMessages } from "../graph/messages.ts";
19
+ import { getMe } from "../graph/me.ts";
20
+ import { chatCandidates } from "../graph/mappers.ts";
21
+ import { hasAccess } from "../safety/index.ts";
22
+ import { formatGraphError } from "../utils/errors.ts";
23
+ import {
24
+ activityMarker,
25
+ chatsToExamine,
26
+ createWatchState,
27
+ noteWake,
28
+ pruneState,
29
+ shouldWake,
30
+ wakesThisHour,
31
+ type WatchState,
32
+ } from "./index.ts";
33
+
34
+ // ---------------------------------------------------------------------------
35
+ // Events and status
36
+ // ---------------------------------------------------------------------------
37
+
38
+ /** One message that deserves a model turn. */
39
+ export interface WatchEvent {
40
+ chat: ChatSummary;
41
+ message: MessageSummary;
42
+ /** Epoch ms the wake was decided */
43
+ wokeAt: number;
44
+ /** Who pi is acting as, carried along for the prompt */
45
+ me?: SignedInUser;
46
+ }
47
+
48
+ /** What the footer and `teams_watch` report about a running watcher. */
49
+ export interface WatchRuntimeStatus {
50
+ running: boolean;
51
+ enabled: boolean;
52
+ intervalSeconds: number;
53
+ lastTickAt?: number;
54
+ lastWakeAt?: number;
55
+ /** Chat label of the last wake, for the status line */
56
+ lastWakeChat?: string;
57
+ lastError?: string;
58
+ wakesThisHour: number;
59
+ /** Chats seen so far — a quick sanity signal that the loop is alive */
60
+ trackedChats: number;
61
+ }
62
+
63
+ // ---------------------------------------------------------------------------
64
+ // One tick
65
+ // ---------------------------------------------------------------------------
66
+
67
+ /**
68
+ * Bound on how many chats one tick examines.
69
+ *
70
+ * A chat list can move a lot at once — a Monday morning, a release — and each
71
+ * examination is a Graph call. The cap keeps a burst from turning into a
72
+ * thundering herd; whatever is left over is simply picked up next tick.
73
+ */
74
+ export const MAX_EXAMINATIONS_PER_TICK = 5;
75
+
76
+ export interface WatchTickDeps {
77
+ /** Newest chats, most recent activity first */
78
+ listChats: () => Promise<ChatSummary[]>;
79
+ /** The most recent messages of one chat, newest first */
80
+ listMessages: (chatId: string) => Promise<MessageSummary[]>;
81
+ now: () => number;
82
+ /**
83
+ * Read guard, so the watcher respects the same allow/deny rules as every
84
+ * other read. Defaults to allowing everything.
85
+ */
86
+ allowed?: (chat: ChatSummary) => boolean;
87
+ }
88
+
89
+ export interface WatchTickResult {
90
+ /** Chats looked at in this tick */
91
+ examined: number;
92
+ wakes: WatchEvent[];
93
+ }
94
+
95
+ /**
96
+ * Advance the watcher by one tick.
97
+ *
98
+ * The first tick is a **baseline**: it records what is currently there and
99
+ * wakes nobody. Switching listen mode on must not answer the backlog — it means
100
+ * "from now on", and anything older than the freshness window would be a
101
+ * surprise to wake for.
102
+ */
103
+ export async function runWatchTick(
104
+ deps: WatchTickDeps,
105
+ state: WatchState,
106
+ watch: ResolvedWatchConfig,
107
+ me: SignedInUser | undefined,
108
+ options: { baseline: boolean },
109
+ ): Promise<WatchTickResult> {
110
+ const now = deps.now();
111
+ pruneState(state, now);
112
+
113
+ const chats = (await deps.listChats()).filter((chat) => deps.allowed?.(chat) ?? true);
114
+
115
+ if (options.baseline) {
116
+ for (const chat of chats) state.seen.set(chat.id, activityMarker(chat));
117
+ return { examined: 0, wakes: [] };
118
+ }
119
+
120
+ const freshnessMs = Math.max(5 * 60_000, watch.intervalSeconds * 2000);
121
+ const candidates = chatsToExamine(state, chats, watch, now, freshnessMs).slice(0, MAX_EXAMINATIONS_PER_TICK);
122
+
123
+ const wakes: WatchEvent[] = [];
124
+
125
+ for (const chat of candidates) {
126
+ // Mark first: a chat that fails to load must not be retried forever, and
127
+ // the marker is the chat list's own, so the next tick sees it as moved
128
+ // only if something new actually arrives.
129
+ state.seen.set(chat.id, activityMarker(chat));
130
+
131
+ let messages: MessageSummary[];
132
+ try {
133
+ messages = await deps.listMessages(chat.id);
134
+ } catch {
135
+ // A single unreadable chat must not end the tick — the rest of the
136
+ // list is still worth examining.
137
+ continue;
138
+ }
139
+
140
+ const message = messages.find((entry) => !entry.deletedDateTime) ?? messages[0];
141
+ if (!message) continue;
142
+
143
+ const decision = shouldWake(chat, message, me, watch, state, now);
144
+ if (!decision.wake) continue;
145
+
146
+ noteWake(state, chat.id, now);
147
+ wakes.push({ chat, message, wokeAt: now, me });
148
+ }
149
+
150
+ return { examined: candidates.length, wakes };
151
+ }
152
+
153
+ // ---------------------------------------------------------------------------
154
+ // The loop
155
+ // ---------------------------------------------------------------------------
156
+
157
+ export interface WatchLoopOptions {
158
+ connection: TeamsConnection;
159
+ /** Called once per wake, in arrival order */
160
+ onWake: (event: WatchEvent) => void;
161
+ /** Called when a tick fails; the loop keeps running */
162
+ onError?: (message: string) => void;
163
+ /** Called after every tick, for the footer */
164
+ onTick?: (status: WatchRuntimeStatus) => void;
165
+ }
166
+
167
+ export interface WatchLoop {
168
+ /** Stop polling. Safe to call twice. */
169
+ stop(): void;
170
+ /** A snapshot for the status line and the tool */
171
+ status(): WatchRuntimeStatus;
172
+ /** Force a tick now, instead of waiting out the interval */
173
+ poll(): Promise<void>;
174
+ }
175
+
176
+ /**
177
+ * The loop this process is running, if any.
178
+ *
179
+ * One session runs one watcher, and the tools that report on listen mode need
180
+ * to see it. Keeping the slot here — rather than in the extension — means
181
+ * `teams_watch` can answer with live runtime facts without importing the
182
+ * extension, which imports the tools.
183
+ */
184
+ let activeLoop: WatchLoop | undefined;
185
+
186
+ export function setActiveWatchLoop(loop: WatchLoop | undefined): void {
187
+ activeLoop = loop;
188
+ }
189
+
190
+ export function getActiveWatchLoop(): WatchLoop | undefined {
191
+ return activeLoop;
192
+ }
193
+
194
+ /** Stop the running watcher, if there is one. */
195
+ export function stopActiveWatchLoop(): void {
196
+ activeLoop?.stop();
197
+ activeLoop = undefined;
198
+ }
199
+
200
+ /**
201
+ * Start polling for the given connection.
202
+ *
203
+ * Each tick is scheduled after the previous one finished rather than on a fixed
204
+ * timer, so a slow Graph call cannot stack ticks on top of each other.
205
+ */
206
+ export function startWatchLoop(options: WatchLoopOptions): WatchLoop {
207
+ const { connection } = options;
208
+ const watch = connection.watch;
209
+ const state = createWatchState();
210
+
211
+ let stopped = false;
212
+ let baselineDone = false;
213
+ let timer: ReturnType<typeof setTimeout> | undefined;
214
+ let me: SignedInUser | undefined;
215
+ let meResolved = false;
216
+ let failureCount = 0;
217
+
218
+ const status: WatchRuntimeStatus = {
219
+ running: true,
220
+ enabled: watch.enabled,
221
+ intervalSeconds: watch.intervalSeconds,
222
+ wakesThisHour: 0,
223
+ trackedChats: 0,
224
+ };
225
+
226
+ const schedule = (delayMs: number) => {
227
+ if (stopped) return;
228
+ timer = setTimeout(() => void tick(), delayMs);
229
+ // Do not hold the process open for a poll that can wait.
230
+ timer.unref?.();
231
+ };
232
+
233
+ async function tick(): Promise<void> {
234
+ if (stopped) return;
235
+
236
+ try {
237
+ if (!meResolved) {
238
+ me = await getMe(connection).catch(() => undefined);
239
+ meResolved = true;
240
+ }
241
+
242
+ const result = await runWatchTick(
243
+ {
244
+ listChats: () => listChats(connection, { max: 50, meId: me?.id }),
245
+ listMessages: (chatId) => listChatMessages(connection, chatId, { max: 1 }),
246
+ now: () => Date.now(),
247
+ allowed: (chat) => hasAccess(connection, "read", "chats", chatCandidates(chat)),
248
+ },
249
+ state,
250
+ watch,
251
+ me,
252
+ { baseline: !baselineDone },
253
+ );
254
+
255
+ baselineDone = true;
256
+ failureCount = 0;
257
+ status.lastTickAt = Date.now();
258
+ status.lastError = undefined;
259
+ status.wakesThisHour = wakesThisHour(state, status.lastTickAt);
260
+ status.trackedChats = state.seen.size;
261
+
262
+ for (const event of result.wakes) {
263
+ status.lastWakeAt = event.wokeAt;
264
+ status.lastWakeChat = event.chat.label;
265
+ status.wakesThisHour = wakesThisHour(state, event.wokeAt);
266
+ try {
267
+ options.onWake(event);
268
+ } catch (err) {
269
+ options.onError?.(formatGraphError(err));
270
+ }
271
+ }
272
+ } catch (err) {
273
+ // Transient failures are normal — a sleeping laptop, an expired token,
274
+ // a throttled tenant. Back off instead of giving up, and report it so
275
+ // the user is not left wondering why nothing happens.
276
+ failureCount += 1;
277
+ status.lastTickAt = Date.now();
278
+ status.lastError = formatGraphError(err);
279
+ options.onError?.(status.lastError);
280
+ }
281
+
282
+ options.onTick?.(status);
283
+ schedule(watch.intervalSeconds * 1000 * Math.min(2 ** failureCount, 10));
284
+ }
285
+
286
+ // The first tick is immediate: it establishes the baseline, and a user who
287
+ // just switched listen mode should not wait a full interval to see it alive.
288
+ void tick();
289
+
290
+ return {
291
+ stop() {
292
+ stopped = true;
293
+ status.running = false;
294
+ if (timer) clearTimeout(timer);
295
+ timer = undefined;
296
+ },
297
+ status: () => ({ ...status, wakesThisHour: wakesThisHour(state, Date.now()) }),
298
+ async poll() {
299
+ if (timer) clearTimeout(timer);
300
+ timer = undefined;
301
+ await tick();
302
+ },
303
+ };
304
+ }
@@ -0,0 +1,59 @@
1
+ /**
2
+ * Listen mode — the prompt.
3
+ *
4
+ * An incoming Teams message is turned into a prompt, and this is the whole of
5
+ * that translation. It is a separate module because the wording is the feature:
6
+ * what pi does with a message it was woken for is decided here, and nothing in
7
+ * the polling loop should be able to change it.
8
+ *
9
+ * Two things the prompt must always carry: the chat ID — so the reply cannot
10
+ * land in the wrong conversation, since pi posts as a person and a name is not
11
+ * an identifier — and the instruction that doing nothing is a valid answer.
12
+ */
13
+
14
+ import type { MessageSummary, SignedInUser } from "../types.ts";
15
+ import type { WatchEvent } from "./loop.ts";
16
+
17
+ function senderName(message: MessageSummary): string {
18
+ return message.from?.displayName ?? message.from?.upn ?? "someone";
19
+ }
20
+
21
+ /**
22
+ * The prompt for one wake.
23
+ *
24
+ * Written to be read by the model, not by the user: it states the facts, names
25
+ * the tool to use, and then gets out of the way.
26
+ */
27
+ export function composeWatchPrompt(event: WatchEvent, me?: SignedInUser): string {
28
+ const { chat, message } = event;
29
+ const text = message.text?.trim() ? message.text.trim() : "(no text content — open the chat to see it)";
30
+ const at = message.createdDateTime ? new Date(message.createdDateTime).toLocaleString() : "just now";
31
+
32
+ return [
33
+ "An incoming Microsoft Teams message arrived while you were idle.",
34
+ "",
35
+ `- Chat: ${chat.label} (${chat.chatType})`,
36
+ `- From: ${senderName(message)}`,
37
+ `- At: ${at}`,
38
+ `- Chat ID: ${chat.id}`,
39
+ "",
40
+ "Message:",
41
+ text
42
+ .split("\n")
43
+ .map((line) => `> ${line}`)
44
+ .join("\n"),
45
+ "",
46
+ "Decide whether this needs an answer.",
47
+ "",
48
+ `- If it does: reply with teams_send_chat_message, passing chat: "${chat.id}". pi posts as ` +
49
+ `${me?.displayName ?? "me"}, so write in my voice, in the language of the conversation, as briefly as ` +
50
+ "the message allows. No greeting boilerplate, no signature.",
51
+ "- If it does not: say so in one line and stop.",
52
+ "",
53
+ "Format it for a chat bubble, not a document: the answer first, three short paragraphs at most, " +
54
+ "bullets for anything enumerable, no tables. The recipient is not expecting this message, so it has " +
55
+ "to be readable at a glance.",
56
+ "",
57
+ "Do not react, do not touch other chats, and do not send anything to a channel.",
58
+ ].join("\n");
59
+ }
@@ -1,72 +0,0 @@
1
- /**
2
- * Client credentials grant — app-only access, no user involved.
3
- *
4
- * Useful for unattended jobs (a nightly digest, a scheduled report) against
5
- * directory and presence data. It is deliberately *not* a way to post as
6
- * somebody: Microsoft Graph only accepts app-only writes to chats and channels
7
- * for migration scenarios (`Teamwork.Migrate.All`), so this extension refuses
8
- * message sends on an app-only token instead of letting them fail deep inside
9
- * a Graph call. See ../safety/index.ts.
10
- */
11
-
12
- import type { TeamsConnection } from "../config/index.ts";
13
- import { AuthError, tokenUrl } from "./device-code.ts";
14
- import type { CachedToken } from "./token-store.ts";
15
-
16
- interface TokenResponse {
17
- access_token: string;
18
- expires_in: number;
19
- scope?: string;
20
- token_type: string;
21
- error?: string;
22
- error_description?: string;
23
- }
24
-
25
- /**
26
- * Acquire an app-only token.
27
- *
28
- * @throws {AuthError} when the secret is missing, expired, or the app has no
29
- * admin-consented application permissions.
30
- */
31
- export async function acquireAppToken(
32
- conn: TeamsConnection,
33
- signal?: AbortSignal,
34
- ): Promise<CachedToken> {
35
- if (!conn.clientSecret) {
36
- throw new AuthError(
37
- "missing_client_secret",
38
- `Account "${conn.account}" uses client-credentials but no clientSecret is configured.`,
39
- );
40
- }
41
-
42
- const body = new URLSearchParams({
43
- grant_type: "client_credentials",
44
- client_id: conn.clientId,
45
- client_secret: conn.clientSecret,
46
- scope: conn.scopes.length === 1 ? conn.scopes[0]! : "https://graph.microsoft.com/.default",
47
- });
48
-
49
- const response = await fetch(tokenUrl(conn.authorityHost, conn.tenantId), {
50
- method: "POST",
51
- headers: { "Content-Type": "application/x-www-form-urlencoded" },
52
- body: body.toString(),
53
- signal,
54
- });
55
-
56
- const json = (await response.json()) as TokenResponse;
57
-
58
- if (!response.ok || !json.access_token) {
59
- throw new AuthError(
60
- json.error ?? `http_${response.status}`,
61
- json.error_description ?? `Client credentials request failed with HTTP ${response.status}`,
62
- );
63
- }
64
-
65
- return {
66
- accessToken: json.access_token,
67
- expiresAt: Date.now() + Math.max(0, json.expires_in - 60) * 1000,
68
- scopes: json.scope ? json.scope.split(" ") : [],
69
- authMode: "client-credentials",
70
- updatedAt: new Date().toISOString(),
71
- };
72
- }