@patimweb/pi-teams 1.2.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.
@@ -9,7 +9,7 @@
9
9
 
10
10
  import type { TeamsConnection } from "../config/index.ts";
11
11
  import type { ChannelSummary, MessageLocation, MessageSummary, PersonRef } from "../types.ts";
12
- import { graphGetOptional, graphList, graphPost } from "./client.ts";
12
+ import { graphGetOptional, graphList, graphPatch, graphPost } from "./client.ts";
13
13
  import { mapMessage } from "./mappers.ts";
14
14
  import { markdownToTeamsHtml } from "../utils/richtext.ts";
15
15
 
@@ -292,62 +292,76 @@ export async function replyToChannelMessage(
292
292
  }
293
293
 
294
294
  /**
295
- * Path for the softDelete / undoSoftDelete actions.
295
+ * Path to one message in a chat — the base for editing and deleting.
296
296
  *
297
- * The chat form takes an explicit user id — Graph does not accept `/me` here —
298
- * which is why every caller has to resolve the signed-in user first.
297
+ * The user id is explicit: Graph does not accept `/me` in this position, which
298
+ * is why every caller resolves the signed-in user first.
299
+ *
300
+ * There is no channel form. Editing or deleting a channel post needs the
301
+ * `ChannelMessage.ReadWrite` scope, and this package deliberately does not ask
302
+ * for a permission it cannot rely on being granted.
299
303
  */
300
- export function deletePath(
301
- location: MessageLocation,
302
- messageId: string,
303
- userId: string,
304
- replyId?: string,
305
- ): string {
306
- if (location.kind === "chat") {
307
- return (
308
- `/users/${encodeURIComponent(userId)}` +
309
- `/chats/${encodeURIComponent(location.chatId!)}` +
310
- `/messages/${encodeURIComponent(messageId)}`
311
- );
312
- }
313
- const base = `${channelPath(location.teamId!, location.channelId!)}/${encodeURIComponent(messageId)}`;
314
- return replyId ? `${base}/replies/${encodeURIComponent(replyId)}` : base;
304
+ export function messagePath(chatId: string, messageId: string, userId: string): string {
305
+ return (
306
+ `/users/${encodeURIComponent(userId)}` +
307
+ `/chats/${encodeURIComponent(chatId)}` +
308
+ `/messages/${encodeURIComponent(messageId)}`
309
+ );
315
310
  }
316
311
 
317
312
  /**
318
- * Delete one of the signed-in user's own messages (soft delete, as in the app).
313
+ * Delete one of the signed-in user's own chat messages (soft delete, as in the app).
319
314
  *
320
315
  * Graph exposes this as an action, not an HTTP DELETE, and only for the sender.
321
316
  */
322
317
  export async function softDeleteMessage(
323
318
  conn: TeamsConnection,
324
- location: MessageLocation,
319
+ chatId: string,
325
320
  messageId: string,
326
321
  userId: string,
327
- options: { replyId?: string; signal?: AbortSignal } = {},
322
+ options: { signal?: AbortSignal } = {},
328
323
  ): Promise<void> {
329
- await graphPost(
330
- conn,
331
- `${deletePath(location, messageId, userId, options.replyId)}/softDelete`,
332
- undefined,
333
- { signal: options.signal },
334
- );
324
+ await graphPost(conn, `${messagePath(chatId, messageId, userId)}/softDelete`, undefined, {
325
+ signal: options.signal,
326
+ });
335
327
  }
336
328
 
337
329
  /** Restore a message that was soft-deleted. */
338
330
  export async function undoSoftDeleteMessage(
339
331
  conn: TeamsConnection,
340
- location: MessageLocation,
332
+ chatId: string,
341
333
  messageId: string,
342
334
  userId: string,
343
- options: { replyId?: string; signal?: AbortSignal } = {},
335
+ options: { signal?: AbortSignal } = {},
344
336
  ): Promise<void> {
345
- await graphPost(
346
- conn,
347
- `${deletePath(location, messageId, userId, options.replyId)}/undoSoftDelete`,
348
- undefined,
349
- { signal: options.signal },
350
- );
337
+ await graphPost(conn, `${messagePath(chatId, messageId, userId)}/undoSoftDelete`, undefined, {
338
+ signal: options.signal,
339
+ });
340
+ }
341
+
342
+ // ---------------------------------------------------------------------------
343
+ // Editing
344
+ // ---------------------------------------------------------------------------
345
+
346
+ /**
347
+ * Rewrite one of the signed-in user's own chat messages.
348
+ *
349
+ * Only the body is patched: an edit replaces the text as it stands, and sending
350
+ * a fresh set of mention entities would silently re-notify people who were
351
+ * already notified by the original message.
352
+ */
353
+ export async function updateMessage(
354
+ conn: TeamsConnection,
355
+ chatId: string,
356
+ messageId: string,
357
+ userId: string,
358
+ input: { body: string; html?: boolean },
359
+ options: { signal?: AbortSignal } = {},
360
+ ): Promise<void> {
361
+ const { body } = buildMessageBody({ body: input.body, html: input.html });
362
+ await graphPatch(conn, messagePath(chatId, messageId, userId), { body }, {
363
+ signal: options.signal,
364
+ });
351
365
  }
352
366
 
353
367
  // ---------------------------------------------------------------------------
@@ -82,6 +82,14 @@ const APP_ONLY_FORBIDDEN = new Set<string>([
82
82
  "teams_create_chat",
83
83
  "teams_delete_message",
84
84
  "teams_react",
85
+ // The read-cursor actions are delegated-only in Graph ("Application: not
86
+ // supported"), so an app-only token gets a 403 with no explanation.
87
+ "teams_mark_read",
88
+ // An app-only token may only touch messages for migration and DLP, and a
89
+ // membership change with no person behind it is exactly the kind of write
90
+ // this gate exists to stop.
91
+ "teams_update_message",
92
+ "teams_chat_members",
85
93
  "teams_set_presence",
86
94
  "teams_set_status_message",
87
95
  ]);
@@ -120,12 +128,18 @@ export function formatMutationSummary(
120
128
  const people = Array.isArray(params.participants) ? params.participants.join(", ") : text(params.participants);
121
129
  return `Create a new chat with: ${text(people, 200)}`;
122
130
  }
123
- case "teams_create_channel":
124
- return `Create channel "${text(params.name, 60)}" in team "${text(params.team, 60)}"`;
125
131
  case "teams_delete_message":
126
132
  return `Delete your message ${text(params.messageId, 60)}`;
127
133
  case "teams_react":
128
134
  return `${params.remove ? "Remove" : "Add"} reaction ${text(params.reaction, 10)} on message ${text(params.messageId, 50)}`;
135
+ case "teams_mark_read":
136
+ return `${params.read === false ? "Mark as unread" : "Mark as read"}: chat "${text(params.chat, 60)}"`;
137
+ case "teams_update_message":
138
+ return `Edit your message ${text(params.messageId, 50)} in ${text(params.chat ?? params.channel, 60)}:\n\n${text(params.body, 400)}`;
139
+ case "teams_chat_members":
140
+ return `${params.action === "remove" ? "Remove" : "Add"} ${text(params.person, 80)} ${params.action === "remove" ? "from" : "to"} the chat "${text(params.chat, 60)}"`;
141
+ case "teams_respond_invite":
142
+ return `${text(params.response, 20)} the invitation ${text(params.eventId, 50)}${params.comment ? `: "${text(params.comment, 160)}"` : ""}`;
129
143
  case "teams_set_presence":
130
144
  return `Set your Teams presence to ${text(params.availability, 30)}${params.expiresIn ? ` for ${text(params.expiresIn, 20)}` : ""}`;
131
145
  case "teams_set_status_message":
@@ -88,6 +88,30 @@ export function connectionFor(
88
88
  return resolveConnection(account, tenant);
89
89
  }
90
90
 
91
+ // ---------------------------------------------------------------------------
92
+ // Consent gates
93
+ // ---------------------------------------------------------------------------
94
+
95
+ /**
96
+ * Whether this account asks for a delegated scope.
97
+ *
98
+ * A delegated scope cannot be granted unless it was requested, so the
99
+ * configuration is a reliable gate: if it is missing here, the Graph call
100
+ * cannot succeed, and its failure would be a consent error that says nothing
101
+ * about the one-line fix.
102
+ */
103
+ export function hasScope(conn: TeamsConnection, scope: string): boolean {
104
+ return conn.scopes.some((entry) => entry.toLowerCase() === scope.toLowerCase());
105
+ }
106
+
107
+ /** The message to return when a tool needs a scope the account does not request. */
108
+ export function missingScopeError(scope: string, feature: string): string {
109
+ return (
110
+ `${feature} needs the Microsoft Graph scope "${scope}", which this account does not request. ` +
111
+ `Add it to "scopes" in pi-teams.json (or have an admin grant it in Entra ID) and run teams_login again.`
112
+ );
113
+ }
114
+
91
115
  // ---------------------------------------------------------------------------
92
116
  // Identity cache
93
117
  // ---------------------------------------------------------------------------
@@ -0,0 +1,224 @@
1
+ /**
2
+ * teams_availability — when everybody is free.
3
+ *
4
+ * Reads *free/busy*, not calendars: `getSchedule` is what a delegated token may
5
+ * see about a colleague's day, and it is also the reason this works with the
6
+ * scopes already granted — `findMeetingTimes` demands `Calendars.Read.Shared`,
7
+ * a scope nobody has consented to just to ask "Tuesday at two?".
8
+ *
9
+ * Scheduling is then a second step on purpose. This tool answers the question;
10
+ * `teams_create_meeting` acts on it once the user has agreed to a slot.
11
+ */
12
+
13
+ import { Type } from "typebox";
14
+ import { getSchedule } from "../graph/calendar.ts";
15
+ import { resolveUserId } from "../graph/me.ts";
16
+ import { assertAccess } from "../safety/index.ts";
17
+ import {
18
+ formatNaiveDay,
19
+ formatSlotRange,
20
+ truncate,
21
+ } from "../utils/formatting.ts";
22
+ import {
23
+ describeBusy,
24
+ findCommonFreeSlots,
25
+ localDayStart,
26
+ localNow,
27
+ localTimeZone,
28
+ type BusyView,
29
+ } from "../utils/slots.ts";
30
+ import {
31
+ AccountParam,
32
+ LimitParam,
33
+ TenantParam,
34
+ connectionFor,
35
+ currentUser,
36
+ run,
37
+ textResult,
38
+ type ToolContext,
39
+ type ToolResult,
40
+ } from "./shared.ts";
41
+
42
+ /** How far ahead the default search looks. */
43
+ const DEFAULT_SEARCH_DAYS = 7;
44
+
45
+ export const teamsAvailabilityTool = {
46
+ name: "teams_availability",
47
+ description:
48
+ "Find when the signed-in user and other people are all free, using Microsoft Graph free/busy. " +
49
+ "Only availability is read — never the appointments themselves. " +
50
+ "By default it searches the next 7 days for slots on weekdays between 09:00 and 17:00; pass 'start' and " +
51
+ "'end' (ISO 8601 local date-times) to look at an exact window instead. " +
52
+ "Use it before teams_create_meeting when the goal is to find a time rather than to book one.",
53
+ parameters: Type.Object({
54
+ people: Type.Optional(
55
+ Type.Array(Type.String(), {
56
+ description:
57
+ "Names, e-mail addresses or UPNs of everyone who has to be there; the signed-in user is always included",
58
+ }),
59
+ ),
60
+ start: Type.Optional(
61
+ Type.String({
62
+ description:
63
+ "ISO 8601 local date-time, e.g. '2026-09-15T09:00:00'. Default: today, in 'timeZone'",
64
+ }),
65
+ ),
66
+ end: Type.Optional(
67
+ Type.String({
68
+ description:
69
+ "ISO 8601 local date-time. Default: 7 days after 'start'",
70
+ }),
71
+ ),
72
+ durationMinutes: Type.Optional(
73
+ Type.Number({ description: "Length of the meeting to look for, in minutes (default 30)" }),
74
+ ),
75
+ timeZone: Type.Optional(
76
+ Type.String({
77
+ description:
78
+ "IANA or Windows time zone the times are expressed in. Defaults to this machine's time zone",
79
+ }),
80
+ ),
81
+ account: AccountParam,
82
+ tenant: TenantParam,
83
+ limit: LimitParam,
84
+ }),
85
+ promptSnippet: "Find a free slot for several people",
86
+ promptGuidelines: [
87
+ "Use teams_availability to find a time before creating a meeting; propose the slots it returns and let the user pick.",
88
+ "Availability is other people's data: pass it on as times, not as an account of how busy someone is.",
89
+ ],
90
+
91
+ async execute(
92
+ _toolCallId: string,
93
+ params: {
94
+ people?: string[];
95
+ start?: string;
96
+ end?: string;
97
+ durationMinutes?: number;
98
+ timeZone?: string;
99
+ account?: string;
100
+ tenant?: string;
101
+ limit?: number;
102
+ },
103
+ signal: AbortSignal | undefined,
104
+ _onUpdate: undefined,
105
+ ctx: ToolContext,
106
+ ): Promise<ToolResult> {
107
+ return run(async () => {
108
+ const conn = connectionFor(ctx, params.account, params.tenant);
109
+ const me = await currentUser(conn, signal);
110
+
111
+ const timeZone = params.timeZone ?? localTimeZone();
112
+ const duration = Math.min(Math.max(params.durationMinutes ?? 30, 5), 1440);
113
+
114
+ // Default: the coming week, searched inside working hours. An explicit
115
+ // window is honoured as-is — asking "are we free at 20:00?" is a
116
+ // legitimate question that a 09:00–17:00 filter would answer wrongly.
117
+ const explicit = !!params.start || !!params.end;
118
+ const start = params.start ?? localDayStart(0);
119
+ const end = params.end ?? localDayStart(DEFAULT_SEARCH_DAYS);
120
+
121
+ /** scheduleId (lower case) → what to call that person */
122
+ const names = new Map<string, string>();
123
+ const schedules: string[] = [];
124
+
125
+ const selfAddress = me.mail ?? me.upn;
126
+ schedules.push(selfAddress);
127
+ names.set(selfAddress.toLowerCase(), `${me.displayName} (you)`);
128
+
129
+ const unresolved: string[] = [];
130
+ for (const reference of params.people ?? []) {
131
+ const person = await resolveUserId(conn, reference, signal);
132
+ if (!person) {
133
+ unresolved.push(reference);
134
+ continue;
135
+ }
136
+
137
+ // Free/busy is other people's data, so the people rules apply.
138
+ assertAccess(conn, "read", "people", person.displayName, [
139
+ person.displayName,
140
+ person.upn,
141
+ person.mail,
142
+ person.id,
143
+ ]);
144
+
145
+ const address = person.mail ?? person.upn;
146
+ if (!address) {
147
+ unresolved.push(reference);
148
+ continue;
149
+ }
150
+ if (!schedules.some((entry) => entry.toLowerCase() === address.toLowerCase())) {
151
+ schedules.push(address);
152
+ names.set(address.toLowerCase(), person.displayName);
153
+ }
154
+ }
155
+
156
+ const windows = await getSchedule(conn, {
157
+ schedules,
158
+ start,
159
+ end,
160
+ timeZone,
161
+ intervalMinutes: duration,
162
+ signal,
163
+ });
164
+
165
+ const views: BusyView[] = windows.map((window) => ({
166
+ name: names.get(window.scheduleId.toLowerCase()) ?? window.scheduleId,
167
+ view: window.availabilityView,
168
+ }));
169
+
170
+ const slots = findCommonFreeSlots(
171
+ views,
172
+ {
173
+ start,
174
+ intervalMinutes: duration,
175
+ notBefore: explicit ? undefined : localNow(),
176
+ workingHoursOnly: !explicit,
177
+ },
178
+ params.limit ?? 5,
179
+ );
180
+
181
+ const who = [...names.values()].map((name) => truncate(name, 60)).join(", ");
182
+ const windowLabel = explicit
183
+ ? `${formatSlotRange(start, end)}`
184
+ : `${formatNaiveDay(start)} – ${formatNaiveDay(end)} on weekdays, 09:00–17:00`;
185
+
186
+ const lines = [
187
+ `## Free/busy — ${windowLabel} (${timeZone}), ${duration} min`,
188
+ "",
189
+ `Checked: ${who}`,
190
+ "",
191
+ ];
192
+
193
+ if (slots.length === 0) {
194
+ lines.push("**No slot where everybody is free.**", "");
195
+ if (windows.length > 0) {
196
+ lines.push("Busy blocks:", ...describeBusy(windows, names), "");
197
+ }
198
+ } else {
199
+ lines.push(`**${slots.length} slot(s) with everybody free:**`, "");
200
+ for (const slot of slots) {
201
+ lines.push(`- ${formatSlotRange(slot.start, slot.end)}`);
202
+ }
203
+ lines.push("");
204
+ }
205
+
206
+ if (unresolved.length > 0) {
207
+ lines.push(`_Could not resolve: ${unresolved.join(", ")} — use teams_find_user._`, "");
208
+ }
209
+
210
+ // The individual blocks are only useful when a slot was proposed and the
211
+ // reader wants to sanity-check it, or when nothing was found at all.
212
+ if (slots.length > 0 && windows.some((window) => window.busy.length > 0)) {
213
+ lines.push("Busy blocks in the window:", ...describeBusy(windows, names), "");
214
+ }
215
+
216
+ return textResult(lines.join("\n").trimEnd(), {
217
+ slots: slots.length,
218
+ people: schedules.length,
219
+ unresolved: unresolved.length,
220
+ timeZone,
221
+ });
222
+ });
223
+ },
224
+ };
@@ -0,0 +1,148 @@
1
+ /**
2
+ * teams_chat_members — bring someone into a group chat, or take them out.
3
+ *
4
+ * Teams has no "invite" for a chat: either the person is a member of the
5
+ * conversation or they cannot see it. That makes adding a real change to who
6
+ * can read the history, which is why it goes through the safety gates and the
7
+ * person is checked against the member rules before Graph is called.
8
+ */
9
+
10
+ import { Type } from "typebox";
11
+ import { addChatMember, listChatMembers, matchChatMember, removeChatMember } from "../graph/chats.ts";
12
+ import { resolveUserId } from "../graph/me.ts";
13
+ import { auditWrite } from "../safety/audit.ts";
14
+ import { assertAccess } from "../safety/index.ts";
15
+ import { requireChat } from "./resolve.ts";
16
+ import {
17
+ AccountParam,
18
+ TenantParam,
19
+ connectionFor,
20
+ currentUser,
21
+ errorResult,
22
+ hasScope,
23
+ missingScopeError,
24
+ run,
25
+ textResult,
26
+ type ToolContext,
27
+ type ToolResult,
28
+ } from "./shared.ts";
29
+
30
+ export const teamsChatMembersTool = {
31
+ name: "teams_chat_members",
32
+ description:
33
+ "Add a person to an existing Microsoft Teams group chat, or remove one. " +
34
+ "Takes a chat ID, the group chat topic, or a participant's name/e-mail, plus who to add or remove. " +
35
+ "A 1:1 chat cannot be extended — use teams_create_chat with everyone in it to make it a group chat. " +
36
+ "Removing someone additionally needs the ChatMember.ReadWrite scope.",
37
+ parameters: Type.Object({
38
+ chat: Type.String({ description: "Chat ID, group chat topic, or a participant's name/e-mail" }),
39
+ action: Type.Union([Type.Literal("add"), Type.Literal("remove")], {
40
+ description: "'add' to bring someone in, 'remove' to take them out",
41
+ }),
42
+ person: Type.String({
43
+ description: "Who to add or remove, by name, e-mail address or user principal name",
44
+ }),
45
+ account: AccountParam,
46
+ tenant: TenantParam,
47
+ }),
48
+ promptSnippet: "Add or remove a member of a Teams chat",
49
+ promptGuidelines: [
50
+ "Adding a member gives them the chat's history from that point on, so confirm the person with the user when the name is ambiguous.",
51
+ ],
52
+
53
+ async execute(
54
+ _toolCallId: string,
55
+ params: {
56
+ chat: string;
57
+ action: "add" | "remove";
58
+ person: string;
59
+ account?: string;
60
+ tenant?: string;
61
+ },
62
+ signal: AbortSignal | undefined,
63
+ _onUpdate: undefined,
64
+ ctx: ToolContext,
65
+ ): Promise<ToolResult> {
66
+ return run(async () => {
67
+ const conn = connectionFor(ctx, params.account, params.tenant);
68
+ const me = await currentUser(conn, signal);
69
+ const chat = await requireChat(conn, params.chat, "write", signal);
70
+
71
+ if (params.action === "add") {
72
+ if (chat.chatType === "oneOnOne") {
73
+ return errorResult(
74
+ `"${chat.label}" is a 1:1 chat and cannot be extended. Use teams_create_chat with ` +
75
+ `everyone who should be in it to start a group chat.`,
76
+ );
77
+ }
78
+
79
+ const person = await resolveUserId(conn, params.person, signal);
80
+ if (!person) {
81
+ return errorResult(
82
+ `No person matching "${params.person}". Use teams_find_user to find the exact name or e-mail.`,
83
+ );
84
+ }
85
+
86
+ // The people already in the chat were checked by requireChat; the one
87
+ // about to join has to be checked on its own.
88
+ assertAccess(conn, "write", "people", person.displayName, [
89
+ person.displayName,
90
+ person.upn,
91
+ person.mail,
92
+ person.id,
93
+ ]);
94
+
95
+ await addChatMember(conn, chat.id, person, { signal });
96
+
97
+ auditWrite(conn.audit, {
98
+ tool: "teams_chat_members",
99
+ account: conn.account,
100
+ tenant: conn.tenant,
101
+ actor: me.upn,
102
+ target: `chat:${chat.label}`,
103
+ summary: `added ${person.displayName} to the chat`,
104
+ });
105
+
106
+ return textResult(`✅ ${person.displayName} added to "${chat.label}".`, {
107
+ chatId: chat.id,
108
+ action: "add",
109
+ });
110
+ }
111
+
112
+ // Removal takes the membership id, not the user id — a scope the default
113
+ // configuration leaves out, so the answer is the fix rather than a 403.
114
+ if (!hasScope(conn, "ChatMember.ReadWrite")) {
115
+ return errorResult(
116
+ missingScopeError("ChatMember.ReadWrite", "Removing a member from a chat"),
117
+ );
118
+ }
119
+
120
+ const person = await resolveUserId(conn, params.person, signal).catch(() => undefined);
121
+ const members = await listChatMembers(conn, chat.id, { signal });
122
+ const reference = person?.id ?? person?.upn ?? person?.mail ?? params.person;
123
+ const member = matchChatMember(members, reference) ?? matchChatMember(members, params.person);
124
+
125
+ if (!member) {
126
+ const known = members.map((entry) => entry.displayName).join(", ") || "(none visible)";
127
+ return errorResult(`"${params.person}" is not a member of "${chat.label}". Members: ${known}.`);
128
+ }
129
+
130
+ await removeChatMember(conn, chat.id, member.membershipId, { signal });
131
+
132
+ auditWrite(conn.audit, {
133
+ tool: "teams_chat_members",
134
+ account: conn.account,
135
+ tenant: conn.tenant,
136
+ actor: me.upn,
137
+ target: `chat:${chat.label}`,
138
+ summary: `removed ${member.displayName} from the chat`,
139
+ });
140
+
141
+ return textResult(`✅ ${member.displayName} removed from "${chat.label}".`, {
142
+ chatId: chat.id,
143
+ action: "remove",
144
+ membershipId: member.membershipId,
145
+ });
146
+ });
147
+ },
148
+ };
@@ -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
  },