@patimweb/pi-teams 1.2.0 → 1.3.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,15 +1,19 @@
1
1
  /**
2
- * teams_delete_message — soft-delete one of the user's own messages.
2
+ * teams_delete_message — take back a message the user sent in a chat.
3
3
  *
4
4
  * Graph only permits this for the sender, which is also the only case that
5
5
  * makes sense here: pi may take back something it said in the user's name, not
6
6
  * edit anyone else's history.
7
+ *
8
+ * Chats only. Deleting a channel post needs the `ChannelMessage.ReadWrite`
9
+ * scope, which this package does not request — a channel post is deleted in
10
+ * Teams, or edited into what it should have said.
7
11
  */
8
12
 
9
13
  import { Type } from "typebox";
10
14
  import { softDeleteMessage, undoSoftDeleteMessage } from "../graph/messages.ts";
11
15
  import { auditWrite } from "../safety/audit.ts";
12
- import { requireChannel, requireChat } from "./resolve.ts";
16
+ import { requireChat } from "./resolve.ts";
13
17
  import {
14
18
  AccountParam,
15
19
  TenantParam,
@@ -21,38 +25,31 @@ import {
21
25
  type ToolContext,
22
26
  type ToolResult,
23
27
  } from "./shared.ts";
24
- import type { MessageLocation } from "../types.ts";
25
28
 
26
29
  export const teamsDeleteMessageTool = {
27
30
  name: "teams_delete_message",
28
31
  description:
29
- "Delete a message the signed-in user sent in Microsoft Teams (soft delete, exactly like deleting it in " +
30
- "the app). Specify 'chat' or 'channel' (plus 'team') to say where it lives. " +
31
- "Set undo: true to restore a message that was soft-deleted. Only the sender's own messages can be deleted.",
32
+ "Delete a message the signed-in user sent in a Microsoft Teams chat (soft delete, exactly like deleting " +
33
+ "it in the app). Set undo: true to restore a message that was soft-deleted. " +
34
+ "Only the sender's own messages can be deleted, and only in chats — a channel post cannot be deleted " +
35
+ "through Graph with the permissions pi asks for.",
32
36
  parameters: Type.Object({
33
37
  messageId: Type.String({ description: "ID of the message to delete" }),
34
- chat: Type.Optional(Type.String({ description: "Chat ID or name, when the message is in a chat" })),
35
- channel: Type.Optional(
36
- Type.String({ description: "Channel name/ID or 'Team/Channel' path, when it is in a channel" }),
37
- ),
38
- team: Type.Optional(Type.String({ description: "Team name or ID" })),
39
- replyId: Type.Optional(
40
- Type.String({ description: "Reply ID, when deleting a reply inside a channel thread" }),
41
- ),
38
+ chat: Type.String({ description: "Chat ID, group chat topic, or a participant's name/e-mail" }),
42
39
  undo: Type.Optional(Type.Boolean({ description: "Restore a previously deleted message" })),
43
40
  account: AccountParam,
44
41
  tenant: TenantParam,
45
42
  }),
46
43
  promptSnippet: "Delete a message the user sent in Teams",
44
+ promptGuidelines: [
45
+ "Prefer teams_update_message over deleting and resending: an edit keeps the message in place, a delete leaves a gap and the replacement arrives as a new notification.",
46
+ ],
47
47
 
48
48
  async execute(
49
49
  _toolCallId: string,
50
50
  params: {
51
51
  messageId: string;
52
- chat?: string;
53
- channel?: string;
54
- team?: string;
55
- replyId?: string;
52
+ chat: string;
56
53
  undo?: boolean;
57
54
  account?: string;
58
55
  tenant?: string;
@@ -63,37 +60,13 @@ export const teamsDeleteMessageTool = {
63
60
  ): Promise<ToolResult> {
64
61
  return run(async () => {
65
62
  const conn = connectionFor(ctx, params.account, params.tenant);
66
-
67
- let location: MessageLocation;
68
- let target: string;
69
-
70
- if (params.chat) {
71
- const chat = await requireChat(conn, params.chat, "write", signal);
72
- location = { kind: "chat", chatId: chat.id };
73
- target = `chat:${chat.label}`;
74
- } else if (params.channel) {
75
- const { team, channel } = await requireChannel(conn, params.team, params.channel, "write", signal);
76
- location = {
77
- kind: "channel",
78
- teamId: team.id,
79
- teamName: team.displayName,
80
- channelId: channel.id,
81
- channelName: channel.displayName,
82
- };
83
- target = `channel:${team.displayName}/${channel.displayName}`;
84
- } else {
85
- return errorResult("Specify either 'chat' or 'channel' so pi knows where the message lives.");
86
- }
87
-
88
- // Graph needs an explicit user id for the chat form of this action, and
89
- // only the sender may delete — so failing here is better than a 403.
90
63
  const me = await currentUser(conn, signal);
64
+ const chat = await requireChat(conn, params.chat, "write", signal);
91
65
 
92
- const options = { replyId: params.replyId, signal };
93
66
  if (params.undo) {
94
- await undoSoftDeleteMessage(conn, location, params.messageId, me.id, options);
67
+ await undoSoftDeleteMessage(conn, chat.id, params.messageId, me.id, { signal });
95
68
  } else {
96
- await softDeleteMessage(conn, location, params.messageId, me.id, options);
69
+ await softDeleteMessage(conn, chat.id, params.messageId, me.id, { signal });
97
70
  }
98
71
 
99
72
  auditWrite(conn.audit, {
@@ -101,13 +74,13 @@ export const teamsDeleteMessageTool = {
101
74
  account: conn.account,
102
75
  tenant: conn.tenant,
103
76
  actor: me.upn,
104
- target,
77
+ target: `chat:${chat.label}`,
105
78
  summary: `${params.undo ? "restored" : "deleted"} message ${params.messageId}`,
106
79
  });
107
80
 
108
81
  return textResult(
109
- `✅ Message ${params.messageId} ${params.undo ? "restored" : "deleted"} in ${target}.`,
110
- { messageId: params.messageId, undone: !!params.undo },
82
+ `✅ Message ${params.messageId} ${params.undo ? "restored" : "deleted"} in "${chat.label}".`,
83
+ { messageId: params.messageId, chatId: chat.id, undone: !!params.undo },
111
84
  );
112
85
  });
113
86
  },
@@ -47,6 +47,7 @@ export const teamsInboxTool = {
47
47
  promptGuidelines: [
48
48
  "Use teams_inbox for open-ended catch-up questions about Teams.",
49
49
  "Summarize what is waiting and who is waiting on it; do not reply to anything unless the user asks.",
50
+ "After a catch-up, offer to clear the list with teams_mark_read — never mark a chat read unasked.",
50
51
  ],
51
52
 
52
53
  async execute(
@@ -0,0 +1,84 @@
1
+ /**
2
+ * teams_mark_read — move the read cursor of a chat.
3
+ *
4
+ * "Mark as read" is the one piece of chat state a delegated token may change
5
+ * on the user's behalf, and it is the natural end of a catch-up: pi summarizes
6
+ * the inbox, then clears it. It is a mutation all the same — the chat stops
7
+ * being bold, and where the tenant shows read receipts, the sender sees that
8
+ * it was opened.
9
+ */
10
+
11
+ import { Type } from "typebox";
12
+ import { setChatReadState } from "../graph/chats.ts";
13
+ import { auditWrite } from "../safety/audit.ts";
14
+ import { requireChat } from "./resolve.ts";
15
+ import {
16
+ AccountParam,
17
+ TenantParam,
18
+ connectionFor,
19
+ currentUser,
20
+ run,
21
+ textResult,
22
+ type ToolContext,
23
+ type ToolResult,
24
+ } from "./shared.ts";
25
+
26
+ export const teamsMarkReadTool = {
27
+ name: "teams_mark_read",
28
+ description:
29
+ "Mark a Microsoft Teams chat as read — or unread again — as the signed-in user, exactly like opening " +
30
+ "or re-marking it in the app. Accepts a chat ID, a group chat topic, or a participant's name/e-mail. " +
31
+ "Set read: false to make the chat unread again. Channels have no equivalent: channel posts cannot be " +
32
+ "marked read or unread.",
33
+ parameters: Type.Object({
34
+ chat: Type.String({
35
+ description: "Chat ID, group chat topic, or a participant's name/e-mail",
36
+ }),
37
+ read: Type.Optional(
38
+ Type.Boolean({
39
+ description: "true (default) marks the chat read, false marks it unread again",
40
+ }),
41
+ ),
42
+ account: AccountParam,
43
+ tenant: TenantParam,
44
+ }),
45
+ promptSnippet: "Mark a Teams chat read or unread",
46
+ promptGuidelines: [
47
+ "Use teams_mark_read for 'mark that as read', 'clear my inbox', 'mark it unread again'.",
48
+ "Marking a chat read is visible to the sender where the tenant shows read receipts, so do it when asked, not on your own initiative.",
49
+ ],
50
+
51
+ async execute(
52
+ _toolCallId: string,
53
+ params: { chat: string; read?: boolean; account?: string; tenant?: string },
54
+ signal: AbortSignal | undefined,
55
+ _onUpdate: undefined,
56
+ ctx: ToolContext,
57
+ ): Promise<ToolResult> {
58
+ return run(async () => {
59
+ const conn = connectionFor(ctx, params.account, params.tenant);
60
+ const me = await currentUser(conn, signal);
61
+
62
+ // "write": this changes state in Teams, so the write rules apply —
63
+ // a chat pi may read but not write is one whose unread marker stays.
64
+ const chat = await requireChat(conn, params.chat, "write", signal);
65
+ const read = params.read !== false;
66
+
67
+ await setChatReadState(conn, chat.id, me, read, { signal });
68
+
69
+ auditWrite(conn.audit, {
70
+ tool: "teams_mark_read",
71
+ account: conn.account,
72
+ tenant: conn.tenant,
73
+ actor: me.upn,
74
+ target: `chat:${chat.label}`,
75
+ summary: `marked chat as ${read ? "read" : "unread"}`,
76
+ });
77
+
78
+ return textResult(
79
+ `✅ "${chat.label}" marked as ${read ? "read" : "unread"}.`,
80
+ { chatId: chat.id, label: chat.label, read },
81
+ );
82
+ });
83
+ },
84
+ };
@@ -13,7 +13,9 @@ import {
13
13
  createOnlineMeeting,
14
14
  getEvent,
15
15
  listEvents,
16
+ respondToEvent,
16
17
  updateEvent,
18
+ type EventResponse,
17
19
  } from "../graph/calendar.ts";
18
20
  import { auditWrite } from "../safety/audit.ts";
19
21
  import { assertAccess } from "../safety/index.ts";
@@ -302,6 +304,102 @@ export const teamsUpdateMeetingTool = {
302
304
  },
303
305
  };
304
306
 
307
+ export const teamsRespondInviteTool = {
308
+ name: "teams_respond_invite",
309
+ description:
310
+ "Answer a Microsoft Teams meeting invitation as the signed-in user: accept, decline, or mark as " +
311
+ "tentative. The organizer is notified unless sendResponse is false. Get the eventId from " +
312
+ "teams_list_meetings. Only invitations can be answered this way — a meeting the user organizes is " +
313
+ "changed with teams_update_meeting or cancelled with teams_cancel_meeting.",
314
+ parameters: Type.Object({
315
+ eventId: Type.String({ description: "Calendar event ID of the invitation" }),
316
+ response: Type.Union(
317
+ [Type.Literal("accept"), Type.Literal("decline"), Type.Literal("tentative")],
318
+ { description: "'accept', 'decline', or 'tentative'" },
319
+ ),
320
+ comment: Type.Optional(
321
+ Type.String({ description: "Note sent to the organizer with the answer" }),
322
+ ),
323
+ sendResponse: Type.Optional(
324
+ Type.Boolean({
325
+ description: "Notify the organizer (default true). Set false to answer silently",
326
+ }),
327
+ ),
328
+ account: AccountParam,
329
+ tenant: TenantParam,
330
+ }),
331
+ promptSnippet: "Accept or decline a Teams meeting invitation",
332
+ promptGuidelines: [
333
+ "Answering an invitation notifies the organizer, so only respond to an invitation the user actually decided on.",
334
+ ],
335
+
336
+ async execute(
337
+ _toolCallId: string,
338
+ params: {
339
+ eventId: string;
340
+ response: "accept" | "decline" | "tentative";
341
+ comment?: string;
342
+ sendResponse?: boolean;
343
+ account?: string;
344
+ tenant?: string;
345
+ },
346
+ signal: AbortSignal | undefined,
347
+ _onUpdate: undefined,
348
+ ctx: ToolContext,
349
+ ): Promise<ToolResult> {
350
+ return run(async () => {
351
+ const conn = connectionFor(ctx, params.account, params.tenant);
352
+ const me = await currentUser(conn, signal);
353
+
354
+ const event = await getEvent(conn, params.eventId, signal);
355
+ if (!event) return errorResult(`Event ${params.eventId} not found. Check the ID from teams_list_meetings.`);
356
+
357
+ // Graph would answer with a bare error; the real problem is that the user
358
+ // is the organizer, and answering their own invitation is a no-op.
359
+ const organizer = [event.organizer?.mail, event.organizer?.upn].filter(Boolean) as string[];
360
+ const mine = [me.mail, me.upn].filter(Boolean) as string[];
361
+ if (
362
+ organizer.length > 0 &&
363
+ mine.some((address) => organizer.some((other) => other.toLowerCase() === address.toLowerCase()))
364
+ ) {
365
+ return errorResult(
366
+ `You organize "${event.subject}" — there is no invitation to answer. ` +
367
+ `Use teams_update_meeting to change it, or teams_cancel_meeting to call it off.`,
368
+ );
369
+ }
370
+
371
+ const response: EventResponse = params.response === "tentative" ? "tentativelyAccept" : params.response;
372
+ await respondToEvent(conn, params.eventId, response, {
373
+ comment: params.comment,
374
+ sendResponse: params.sendResponse,
375
+ signal,
376
+ });
377
+
378
+ auditWrite(conn.audit, {
379
+ tool: "teams_respond_invite",
380
+ account: conn.account,
381
+ tenant: conn.tenant,
382
+ actor: me.upn,
383
+ target: `event:${event.subject}`,
384
+ summary: `answered the invitation with "${params.response}"`,
385
+ });
386
+
387
+ const verb =
388
+ params.response === "accept"
389
+ ? "accepted"
390
+ : params.response === "decline"
391
+ ? "declined"
392
+ : "marked as tentative";
393
+ const notified = params.sendResponse === false ? " (organizer not notified)" : "";
394
+
395
+ return textResult(`✅ "${event.subject}" ${verb}${notified}.`, {
396
+ eventId: params.eventId,
397
+ response: params.response,
398
+ });
399
+ });
400
+ },
401
+ };
402
+
305
403
  export const teamsCancelMeetingTool = {
306
404
  name: "teams_cancel_meeting",
307
405
  description:
@@ -0,0 +1,88 @@
1
+ /**
2
+ * teams_update_message — rewrite a message the user already sent.
3
+ *
4
+ * Editing beats delete-and-resend: the message keeps its ID and its place in
5
+ * the conversation, and Teams shows it as edited rather than as a gap followed
6
+ * by a near-duplicate. Only the sender's own messages can be edited, which is
7
+ * the only case that makes sense for something pi wrote in the user's name.
8
+ *
9
+ * Chats only. Editing a channel post needs the `ChannelMessage.ReadWrite`
10
+ * scope, which this package does not request and does not assume will be
11
+ * granted — so the tool does not offer a path that could not run.
12
+ */
13
+
14
+ import { Type } from "typebox";
15
+ import { updateMessage } from "../graph/messages.ts";
16
+ import { auditWrite } from "../safety/audit.ts";
17
+ import { requireChat } from "./resolve.ts";
18
+ import {
19
+ AccountParam,
20
+ TenantParam,
21
+ connectionFor,
22
+ currentUser,
23
+ errorResult,
24
+ run,
25
+ textResult,
26
+ type ToolContext,
27
+ type ToolResult,
28
+ } from "./shared.ts";
29
+
30
+ export const teamsUpdateMessageTool = {
31
+ name: "teams_update_message",
32
+ description:
33
+ "Edit a message the signed-in user already sent in a Microsoft Teams chat: replaces the text and keeps " +
34
+ "the message in place, so Teams shows it as edited instead of as a new message. " +
35
+ "Use this instead of deleting a message and sending a corrected copy.",
36
+ parameters: Type.Object({
37
+ messageId: Type.String({ description: "ID of the message to edit" }),
38
+ body: Type.String({ description: "The new message text (lightweight markdown by default)" }),
39
+ chat: Type.String({ description: "Chat ID, group chat topic, or a participant's name/e-mail" }),
40
+ html: Type.Optional(Type.Boolean({ description: "Treat 'body' as raw HTML instead of markdown" })),
41
+ account: AccountParam,
42
+ tenant: TenantParam,
43
+ }),
44
+ promptSnippet: "Edit a message the user sent in Teams",
45
+ promptGuidelines: [
46
+ "Use teams_update_message to correct a message the user already sent, rather than deleting it and posting a replacement.",
47
+ ],
48
+
49
+ async execute(
50
+ _toolCallId: string,
51
+ params: {
52
+ messageId: string;
53
+ body: string;
54
+ chat: string;
55
+ html?: boolean;
56
+ account?: string;
57
+ tenant?: string;
58
+ },
59
+ signal: AbortSignal | undefined,
60
+ _onUpdate: undefined,
61
+ ctx: ToolContext,
62
+ ): Promise<ToolResult> {
63
+ return run(async () => {
64
+ const conn = connectionFor(ctx, params.account, params.tenant);
65
+ const me = await currentUser(conn, signal);
66
+ const chat = await requireChat(conn, params.chat, "write", signal);
67
+
68
+ await updateMessage(conn, chat.id, params.messageId, me.id, {
69
+ body: params.body,
70
+ html: params.html,
71
+ }, { signal });
72
+
73
+ auditWrite(conn.audit, {
74
+ tool: "teams_update_message",
75
+ account: conn.account,
76
+ tenant: conn.tenant,
77
+ actor: me.upn,
78
+ target: `chat:${chat.label}`,
79
+ summary: `edited message ${params.messageId}: ${params.body.slice(0, 120)}`,
80
+ });
81
+
82
+ return textResult(`✅ Message ${params.messageId} edited in "${chat.label}".`, {
83
+ messageId: params.messageId,
84
+ chatId: chat.id,
85
+ });
86
+ });
87
+ },
88
+ };
@@ -15,14 +15,16 @@ 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",
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",
@@ -114,6 +114,68 @@ export function formatRelative(iso: string | undefined): string {
114
114
  return formatDate(iso);
115
115
  }
116
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
+
117
179
  /** Shorten a string for previews, without cutting mid-escape. */
118
180
  export function truncate(text: string, max: number): string {
119
181
  const oneLine = text.replace(/\s*\n\s*/g, " ").trim();