@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.
@@ -12,6 +12,7 @@ import { Box, Text } from "@earendil-works/pi-tui";
12
12
  import {
13
13
  ensureConfigTemplate,
14
14
  getConfigPath,
15
+ setWatchConfig,
15
16
  tryResolveConnection,
16
17
  type TeamsConnection,
17
18
  } from "../config/index.ts";
@@ -22,6 +23,13 @@ import {
22
23
  isMutationTool,
23
24
  LOCAL_CONFIG_TOOLS,
24
25
  } from "../safety/index.ts";
26
+ import {
27
+ setActiveWatchLoop,
28
+ startWatchLoop,
29
+ stopActiveWatchLoop,
30
+ type WatchLoop,
31
+ } from "../watch/loop.ts";
32
+ import { composeWatchPrompt } from "../watch/prompt.ts";
25
33
 
26
34
  import { teamsSetupTool } from "../tools/teams-setup.ts";
27
35
  import { teamsLoginTool, teamsLogoutTool } from "../tools/teams-login.ts";
@@ -43,12 +51,16 @@ import {
43
51
  teamsReplyChannelMessageTool,
44
52
  teamsSendChannelMessageTool,
45
53
  } from "../tools/teams-send-channel-message.ts";
46
- import { teamsCreateChannelTool } from "../tools/teams-create-channel.ts";
47
54
  import { teamsListFilesTool } from "../tools/teams-list-files.ts";
48
55
  import { teamsSearchMessagesTool } from "../tools/teams-search-messages.ts";
49
56
  import { teamsReactTool } from "../tools/teams-react.ts";
57
+ import { teamsMarkReadTool } from "../tools/teams-mark-read.ts";
58
+ import { teamsUpdateMessageTool } from "../tools/teams-update-message.ts";
59
+ import { teamsChatMembersTool } from "../tools/teams-chat-members.ts";
60
+ import { teamsAvailabilityTool } from "../tools/teams-availability.ts";
50
61
  import { teamsDeleteMessageTool } from "../tools/teams-delete-message.ts";
51
62
  import { teamsInboxTool } from "../tools/teams-inbox.ts";
63
+ import { teamsWatchTool } from "../tools/teams-watch.ts";
52
64
  import {
53
65
  teamsGetPresenceTool,
54
66
  teamsSetPresenceTool,
@@ -59,6 +71,7 @@ import {
59
71
  teamsCreateMeetingTool,
60
72
  teamsGetMeetingTool,
61
73
  teamsListMeetingsTool,
74
+ teamsRespondInviteTool,
62
75
  teamsUpdateMeetingTool,
63
76
  } from "../tools/teams-meetings.ts";
64
77
 
@@ -73,6 +86,7 @@ const tools = [
73
86
  teamsAccountsTool,
74
87
  teamsStatusTool,
75
88
  teamsPermissionsTool,
89
+ teamsWatchTool,
76
90
  teamsDoctorTool,
77
91
  // Discovery
78
92
  teamsListTeamsTool,
@@ -84,16 +98,18 @@ const tools = [
84
98
  teamsReadChatTool,
85
99
  teamsSendChatMessageTool,
86
100
  teamsCreateChatTool,
101
+ teamsMarkReadTool,
102
+ teamsChatMembersTool,
87
103
  // Channels
88
104
  teamsReadChannelTool,
89
105
  teamsReadThreadTool,
90
106
  teamsSendChannelMessageTool,
91
107
  teamsReplyChannelMessageTool,
92
- teamsCreateChannelTool,
93
108
  teamsListFilesTool,
94
109
  // Cross-cutting
95
110
  teamsSearchMessagesTool,
96
111
  teamsReactTool,
112
+ teamsUpdateMessageTool,
97
113
  teamsDeleteMessageTool,
98
114
  teamsInboxTool,
99
115
  // Presence
@@ -103,8 +119,10 @@ const tools = [
103
119
  // Calendar
104
120
  teamsListMeetingsTool,
105
121
  teamsGetMeetingTool,
122
+ teamsAvailabilityTool,
106
123
  teamsCreateMeetingTool,
107
124
  teamsUpdateMeetingTool,
125
+ teamsRespondInviteTool,
108
126
  teamsCancelMeetingTool,
109
127
  ];
110
128
 
@@ -118,6 +136,74 @@ export default function (pi: ExtensionAPI) {
118
136
  return connection;
119
137
  };
120
138
 
139
+ // -----------------------------------------------------------------------
140
+ // Listen mode
141
+ // -----------------------------------------------------------------------
142
+
143
+ /** The watcher running in this session, when listen mode is on. */
144
+ let watchLoop: WatchLoop | undefined;
145
+
146
+ /** The last polling error the user was told about, so it is said once. */
147
+ let reportedWatchError: string | undefined;
148
+
149
+ const stopWatch = () => {
150
+ watchLoop?.stop();
151
+ watchLoop = undefined;
152
+ setActiveWatchLoop(undefined);
153
+ };
154
+
155
+ /**
156
+ * Start the watcher for the session's account, if the config asks for it.
157
+ *
158
+ * Restarting is the same call: the old loop is stopped first, so a config
159
+ * change cannot leave two pollers running.
160
+ */
161
+ const startWatch = (ctx: any) => {
162
+ stopWatch();
163
+
164
+ const conn = refresh();
165
+ if (!conn?.watch.enabled) return;
166
+
167
+ reportedWatchError = undefined;
168
+ const loop = startWatchLoop({
169
+ connection: conn,
170
+ onWake: (event) => {
171
+ pi.sendUserMessage(composeWatchPrompt(event, event.me), { deliverAs: "followUp" });
172
+ },
173
+ onTick: (status) => {
174
+ if (!status.lastError) reportedWatchError = undefined;
175
+ },
176
+ onError: (message) => {
177
+ // A watcher that fails every tick would otherwise say so every minute.
178
+ if (message === reportedWatchError) return;
179
+ reportedWatchError = message;
180
+ ctx.ui.notify(`Teams listen mode: ${message}`, "warning");
181
+ },
182
+ });
183
+
184
+ watchLoop = loop;
185
+ setActiveWatchLoop(loop);
186
+ };
187
+
188
+ /**
189
+ * Paint the footer status line from the current connection.
190
+ *
191
+ * The only place the status line is written, so the three surfaces —
192
+ * session start, `/teams-status`, and a tool result — cannot drift apart.
193
+ */
194
+ const paintStatus = (ctx: any) => {
195
+ const card = buildConnectionCard(refresh());
196
+ if (!card) return;
197
+ const watching = !!watchLoop?.status().running;
198
+ ctx.ui.setStatus(
199
+ "teams",
200
+ ctx.ui.theme.fg(
201
+ card.signedIn ? "success" : "warning",
202
+ buildConnectionLabel({ ...card, watching }),
203
+ ),
204
+ );
205
+ };
206
+
121
207
  // -----------------------------------------------------------------------
122
208
  // Status card — rendered in the transcript, never sent to the model
123
209
  // -----------------------------------------------------------------------
@@ -162,7 +248,7 @@ export default function (pi: ExtensionAPI) {
162
248
  return;
163
249
  }
164
250
  pi.appendEntry<ConnectionCard>("teams-connection", card);
165
- ctx.ui.setStatus("teams", ctx.ui.theme.fg("success", buildConnectionLabel(card)));
251
+ paintStatus(ctx);
166
252
  ctx.ui.notify(buildConnectionLabel(card), "info");
167
253
  },
168
254
  });
@@ -188,6 +274,56 @@ export default function (pi: ExtensionAPI) {
188
274
  },
189
275
  });
190
276
 
277
+ pi.registerCommand("teams-listen", {
278
+ description: "Let pi listen to Teams: on, off, or status (default)",
279
+ handler: async (args, ctx) => {
280
+ const conn = refresh();
281
+ if (!conn) {
282
+ ctx.ui.notify(
283
+ `Teams: no account configured yet. Use the teams_setup tool, or edit ${getConfigPath()}.`,
284
+ "warning",
285
+ );
286
+ return;
287
+ }
288
+
289
+ const action = args.trim().toLowerCase() || "status";
290
+
291
+ if (action === "on" || action === "off") {
292
+ // Written to the account, not globally: the watcher listens as one
293
+ // identity, and "which account" must not be a guess.
294
+ const watch = setWatchConfig({ enabled: action === "on" }, conn.account);
295
+ startWatch(ctx);
296
+ paintStatus(ctx);
297
+ ctx.ui.notify(
298
+ watch.enabled
299
+ ? `Teams listen mode on — polling every ${watch.intervalSeconds} s, max ${watch.maxTriggersPerHour} wakes/hour.`
300
+ : "Teams listen mode off.",
301
+ "info",
302
+ );
303
+ return;
304
+ }
305
+
306
+ if (action !== "status") {
307
+ ctx.ui.notify(`Unknown argument "${action}". Use on, off, or status.`, "warning");
308
+ return;
309
+ }
310
+
311
+ const runtime = watchLoop?.status();
312
+ pi.appendEntry<ConnectionCard>("teams-connection", { ...buildConnectionCard(conn)!, watching: !!runtime?.running });
313
+ ctx.ui.notify(
314
+ [
315
+ `Listen mode: ${conn.watch.enabled ? "on" : "off"}`,
316
+ `every ${conn.watch.intervalSeconds} s`,
317
+ conn.watch.chats.length > 0 ? `chats: ${conn.watch.chats.join(", ")}` : "all recent chats",
318
+ conn.watch.from.length > 0 ? `people: ${conn.watch.from.join(", ")}` : "any sender",
319
+ conn.watch.mentionOnly ? "mentions only" : "every message",
320
+ runtime ? `${runtime.wakesThisHour} wake(s) this hour` : "not running in this session",
321
+ ].join(" · "),
322
+ "info",
323
+ );
324
+ },
325
+ });
326
+
191
327
  pi.registerCommand("teams-inbox", {
192
328
  description: "Show what needs your attention in Microsoft Teams",
193
329
  handler: async (_args, ctx) => {
@@ -246,7 +382,8 @@ export default function (pi: ExtensionAPI) {
246
382
  const card = buildConnectionCard(connection);
247
383
  if (!card) return;
248
384
 
249
- ctx.ui.setStatus("teams", ctx.ui.theme.fg(card.signedIn ? "success" : "warning", buildConnectionLabel(card)));
385
+ paintStatus(ctx);
386
+ startWatch(ctx);
250
387
 
251
388
  if (!card.signedIn) {
252
389
  ctx.ui.notify(
@@ -294,6 +431,16 @@ export default function (pi: ExtensionAPI) {
294
431
  });
295
432
  }
296
433
 
434
+ // -----------------------------------------------------------------------
435
+ // Listen mode lifecycle
436
+ // -----------------------------------------------------------------------
437
+
438
+ pi.on("session_shutdown", () => {
439
+ // The timer would be unref'd anyway, but a poll in flight during shutdown
440
+ // would still be a Graph call for a session that is gone.
441
+ stopWatch();
442
+ });
443
+
297
444
  // -----------------------------------------------------------------------
298
445
  // Safety interceptor
299
446
  // -----------------------------------------------------------------------
@@ -333,4 +480,39 @@ export default function (pi: ExtensionAPI) {
333
480
  if (!approved) return { block: true, reason: `User declined: ${summary}` };
334
481
  }
335
482
  });
483
+
484
+ // -----------------------------------------------------------------------
485
+ // Status after a tool ran
486
+ // -----------------------------------------------------------------------
487
+
488
+ /**
489
+ * The status line is a snapshot of session start — but every change to the
490
+ * connection happens afterwards, in a tool, and tools have no UI context of
491
+ * their own. Without this the footer would still read "not signed in" after a
492
+ * successful login, and "listening" after listen mode was switched off.
493
+ */
494
+ const STATUS_TOOLS = new Set([
495
+ "teams_login",
496
+ "teams_logout",
497
+ "teams_setup",
498
+ "teams_accounts",
499
+ "teams_watch",
500
+ ]);
501
+
502
+ /** Tools after which the watcher has to be re-read from the config. */
503
+ const WATCH_CONFIG_TOOLS = new Set(["teams_watch", "teams_setup", "teams_accounts"]);
504
+
505
+ pi.on("tool_result", async (event, ctx) => {
506
+ if (!STATUS_TOOLS.has(event.toolName) || event.isError) return;
507
+
508
+ if (event.toolName === "teams_logout") {
509
+ // A watcher polling a session that was just removed only produces
510
+ // errors; leaving it running would be noise, not service.
511
+ stopWatch();
512
+ } else if (WATCH_CONFIG_TOOLS.has(event.toolName)) {
513
+ startWatch(ctx);
514
+ }
515
+
516
+ paintStatus(ctx);
517
+ });
336
518
  }
@@ -143,6 +143,93 @@ export async function cancelEvent(
143
143
  }
144
144
  }
145
145
 
146
+ /** RSVP responses Graph accepts on an invitation. */
147
+ export type EventResponse = "accept" | "decline" | "tentativelyAccept";
148
+
149
+ /**
150
+ * Answer a meeting invitation.
151
+ *
152
+ * This is the calendar's equivalent of saying yes out loud: with
153
+ * `sendResponse` the organizer is notified, which is why the tool asks for
154
+ * confirmation rather than doing it quietly.
155
+ */
156
+ export async function respondToEvent(
157
+ conn: TeamsConnection,
158
+ eventId: string,
159
+ response: EventResponse,
160
+ options: { comment?: string; sendResponse?: boolean; signal?: AbortSignal } = {},
161
+ ): Promise<void> {
162
+ await graphPost(
163
+ conn,
164
+ `/me/events/${encodeURIComponent(eventId)}/${response}`,
165
+ { comment: options.comment, sendResponse: options.sendResponse !== false },
166
+ { signal: options.signal },
167
+ );
168
+ }
169
+
170
+ // ---------------------------------------------------------------------------
171
+ // Free / busy
172
+ // ---------------------------------------------------------------------------
173
+
174
+ /** One person's availability over the requested window. */
175
+ export interface FreeBusyWindow {
176
+ /** The address Graph matched the schedule to */
177
+ scheduleId: string;
178
+ /**
179
+ * One character per slot, starting at the window start:
180
+ * `0` free, `1` tentative, `2` busy, `3` out of office, `4` working elsewhere.
181
+ */
182
+ availabilityView: string;
183
+ /** Everything that was not free, with subject omitted on purpose */
184
+ busy: Array<{ status: string; start: string; end: string }>;
185
+ }
186
+
187
+ /**
188
+ * Free/busy for several people in one call.
189
+ *
190
+ * `getSchedule` is what a delegated token may read about another person's
191
+ * calendar — free/busy, not the appointments — and it is the reason
192
+ * availability works without the `.Shared` scopes that `findMeetingTimes`
193
+ * insists on. Subjects are reported by Graph but dropped here: knowing that
194
+ * somebody is busy is the point, knowing why is their business.
195
+ */
196
+ export async function getSchedule(
197
+ conn: TeamsConnection,
198
+ input: {
199
+ schedules: string[];
200
+ start: string;
201
+ end: string;
202
+ timeZone: string;
203
+ /** Slot width in minutes; Graph allows 5–1440 */
204
+ intervalMinutes?: number;
205
+ signal?: AbortSignal;
206
+ },
207
+ ): Promise<FreeBusyWindow[]> {
208
+ const raw = await graphPost<Raw>(conn, "/me/calendar/getSchedule", {
209
+ schedules: input.schedules,
210
+ startTime: { dateTime: input.start, timeZone: input.timeZone },
211
+ endTime: { dateTime: input.end, timeZone: input.timeZone },
212
+ availabilityViewInterval: Math.min(Math.max(input.intervalMinutes ?? 30, 5), 1440),
213
+ }, {
214
+ signal: input.signal,
215
+ // Without this the busy blocks come back in UTC while the view is indexed
216
+ // in the requested zone, and the two would not line up.
217
+ headers: { Prefer: `outlook.timezone="${input.timeZone}"` },
218
+ });
219
+
220
+ return (raw?.value ?? []).map((entry: Raw) => ({
221
+ scheduleId: entry.scheduleId ?? "",
222
+ availabilityView: entry.availabilityView ?? "",
223
+ busy: (entry.scheduleItems ?? [])
224
+ .filter((item: Raw) => item.status && item.status !== "free")
225
+ .map((item: Raw) => ({
226
+ status: item.status,
227
+ start: item.start?.dateTime ?? "",
228
+ end: item.end?.dateTime ?? "",
229
+ })),
230
+ }));
231
+ }
232
+
146
233
  /** Ad-hoc meeting link with no calendar entry. */
147
234
  export async function createOnlineMeeting(
148
235
  conn: TeamsConnection,
@@ -3,10 +3,10 @@
3
3
  */
4
4
 
5
5
  import type { TeamsConnection } from "../config/index.ts";
6
- import type { ChatSummary, PersonRef } from "../types.ts";
7
- import { graphGetOptional, graphList, graphPost } from "./client.ts";
6
+ import type { ChatMemberSummary, ChatSummary, PersonRef } from "../types.ts";
7
+ import { graphDelete, graphGetOptional, graphList, graphPost } from "./client.ts";
8
8
  import { GraphError } from "../utils/errors.ts";
9
- import { mapChat } from "./mappers.ts";
9
+ import { mapChat, mapChatMember } from "./mappers.ts";
10
10
  import { resolveUserId } from "./me.ts";
11
11
 
12
12
  /**
@@ -140,6 +140,137 @@ export async function createChat(
140
140
  return mapChat((raw ?? {}) as Record<string, never>);
141
141
  }
142
142
 
143
+ // ---------------------------------------------------------------------------
144
+ // Read state
145
+ // ---------------------------------------------------------------------------
146
+
147
+ /**
148
+ * Endpoint and body for the two read-cursor actions.
149
+ *
150
+ * Graph takes the user in the **body**, not in the URL: the delegated token
151
+ * says who is allowed to act, the body says whose read state changes. Kept as
152
+ * a pure function (like `deletePath`) because this is the part worth testing.
153
+ */
154
+ export function readStateRequest(
155
+ chatId: string,
156
+ user: { id: string; tenantId?: string },
157
+ read: boolean,
158
+ ): { path: string; body: Record<string, unknown> } {
159
+ return {
160
+ path: `/chats/${encodeURIComponent(chatId)}/${
161
+ read ? "markChatReadForUser" : "markChatUnreadForUser"
162
+ }`,
163
+ // `tenantId` may be undefined; JSON.stringify drops it, and Graph accepts
164
+ // the id alone.
165
+ body: { user: { id: user.id, tenantId: user.tenantId } },
166
+ };
167
+ }
168
+
169
+ /**
170
+ * Mark a chat as read — or unread again — for the signed-in user.
171
+ *
172
+ * This is the only read-state a delegated token may move, and it moves the
173
+ * same cursor the app moves when you open a chat: it does not touch what
174
+ * anyone else sees, but read receipts (where the tenant has them on) do make
175
+ * the chat look seen. Channel messages have no equivalent action.
176
+ */
177
+ export async function setChatReadState(
178
+ conn: TeamsConnection,
179
+ chatId: string,
180
+ user: { id: string; tenantId?: string },
181
+ read: boolean,
182
+ options: { signal?: AbortSignal } = {},
183
+ ): Promise<void> {
184
+ const { path, body } = readStateRequest(chatId, user, read);
185
+ await graphPost(conn, path, body, { signal: options.signal });
186
+ }
187
+
188
+ // ---------------------------------------------------------------------------
189
+ // Membership
190
+ // ---------------------------------------------------------------------------
191
+
192
+ /**
193
+ * The members of a chat, with the membership id each one is removed by.
194
+ *
195
+ * Read raw rather than through `mapChat`, because a `ChatSummary` keeps only
196
+ * the people (what a label and the scope rules need) and drops the membership
197
+ * id that removing someone requires.
198
+ */
199
+ export async function listChatMembers(
200
+ conn: TeamsConnection,
201
+ chatId: string,
202
+ options: { signal?: AbortSignal } = {},
203
+ ): Promise<ChatMemberSummary[]> {
204
+ const raw = await graphList<Record<string, never>>(
205
+ conn,
206
+ `/chats/${encodeURIComponent(chatId)}/members`,
207
+ { max: 100, signal: options.signal },
208
+ );
209
+ return raw.map((entry) => mapChatMember(entry));
210
+ }
211
+
212
+ /**
213
+ * Add a person to an existing chat.
214
+ *
215
+ * Graph binds the member by user id or UPN; a 1:1 chat cannot be extended (the
216
+ * Teams client converts it to a group chat, Graph refuses), so callers should
217
+ * check the chat type first.
218
+ */
219
+ export async function addChatMember(
220
+ conn: TeamsConnection,
221
+ chatId: string,
222
+ person: PersonRef,
223
+ options: { signal?: AbortSignal } = {},
224
+ ): Promise<void> {
225
+ const bind = person.id ?? person.upn ?? person.mail;
226
+ if (!bind) throw new Error(`Cannot add ${person.displayName}: no user id, UPN or e-mail address.`);
227
+
228
+ await graphPost(
229
+ conn,
230
+ `/chats/${encodeURIComponent(chatId)}/members`,
231
+ {
232
+ "@odata.type": "#microsoft.graph.aadUserConversationMember",
233
+ "user@odata.bind": `https://graph.microsoft.com/v1.0/users/${encodeURIComponent(bind)}`,
234
+ roles: [],
235
+ },
236
+ { signal: options.signal },
237
+ );
238
+ }
239
+
240
+ /** Remove one membership. Graph takes the membership id, not the user id. */
241
+ export async function removeChatMember(
242
+ conn: TeamsConnection,
243
+ chatId: string,
244
+ membershipId: string,
245
+ options: { signal?: AbortSignal } = {},
246
+ ): Promise<void> {
247
+ await graphDelete(
248
+ conn,
249
+ `/chats/${encodeURIComponent(chatId)}/members/${encodeURIComponent(membershipId)}`,
250
+ { signal: options.signal },
251
+ );
252
+ }
253
+
254
+ /**
255
+ * Find the membership of one person in a chat.
256
+ *
257
+ * Matching goes through the identifiers a caller can actually have: object id,
258
+ * UPN, e-mail, display name.
259
+ */
260
+ export function matchChatMember(
261
+ members: readonly ChatMemberSummary[],
262
+ reference: string,
263
+ ): ChatMemberSummary | undefined {
264
+ const needle = reference.trim().toLowerCase();
265
+ if (!needle) return undefined;
266
+
267
+ return members.find((member) =>
268
+ [member.userId, member.upn, member.mail, member.displayName]
269
+ .filter((value): value is string => !!value)
270
+ .some((value) => value.toLowerCase() === needle),
271
+ );
272
+ }
273
+
143
274
  /** Resolve people references (UPN, e-mail, name) to PersonRefs. */
144
275
  export async function resolveParticipants(
145
276
  conn: TeamsConnection,
@@ -9,6 +9,7 @@
9
9
 
10
10
  import type {
11
11
  ChannelSummary,
12
+ ChatMemberSummary,
12
13
  ChatSummary,
13
14
  DriveItemSummary,
14
15
  EventSummary,
@@ -67,6 +68,25 @@ export function mapMember(raw: Raw): MemberSummary {
67
68
  };
68
69
  }
69
70
 
71
+ /**
72
+ * A chat member.
73
+ *
74
+ * Graph exposes two identifiers per member and they are not interchangeable:
75
+ * the membership id is what removes someone, the user id is what says who they
76
+ * are. Keeping both here is cheaper than looking them up again later.
77
+ */
78
+ export function mapChatMember(raw: Raw): ChatMemberSummary {
79
+ const user = raw.user ?? raw;
80
+ return {
81
+ membershipId: raw.id ?? "",
82
+ userId: user?.id ?? undefined,
83
+ displayName: raw.displayName ?? user?.displayName ?? "(unknown)",
84
+ upn: user?.userPrincipalName ?? raw.userPrincipalName ?? undefined,
85
+ mail: user?.email ?? raw.email ?? undefined,
86
+ roles: Array.isArray(raw.roles) ? raw.roles : [],
87
+ };
88
+ }
89
+
70
90
  /** Identifiers a person can be matched against by the scope rules. */
71
91
  export function personCandidates(person: PersonRef | MemberSummary | undefined): (string | undefined)[] {
72
92
  if (!person) return [];