@patimweb/pi-teams 1.10.0 → 1.11.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.
- package/docs/tools.md +35 -6
- package/package.json +1 -1
- package/skills/teams-collaboration/SKILL.md +21 -3
- package/src/graph/messages.ts +25 -0
- package/src/safety/index.ts +15 -3
- package/src/tools/teams-send-channel-message.ts +38 -8
- package/src/tools/teams-send-chat-message.ts +21 -3
- package/src/utils/hosted-content.ts +105 -0
package/docs/tools.md
CHANGED
|
@@ -41,10 +41,10 @@ the default account. See [configuration.md](configuration.md).
|
|
|
41
41
|
|
|
42
42
|
| Tool | Description |
|
|
43
43
|
|------|-------------|
|
|
44
|
-
| `teams_send_chat_message` | Send a chat message as you |
|
|
44
|
+
| `teams_send_chat_message` | Send a chat message as you, optionally with images |
|
|
45
45
|
| `teams_create_chat` | Start a 1:1 or group chat, optionally with the first message |
|
|
46
|
-
| `teams_send_channel_message` | Post in a channel as you |
|
|
47
|
-
| `teams_reply_channel_message` | Reply inside an existing thread |
|
|
46
|
+
| `teams_send_channel_message` | Post in a channel as you, optionally with images |
|
|
47
|
+
| `teams_reply_channel_message` | Reply inside an existing thread, optionally with images |
|
|
48
48
|
| `teams_react` | Add or remove an emoji reaction |
|
|
49
49
|
| `teams_mark_read` | Mark a chat as read (or unread again) — the natural end of a catch-up |
|
|
50
50
|
| `teams_update_message` | Edit a chat message you already sent |
|
|
@@ -137,9 +137,11 @@ subset Teams renders in a chat bubble:
|
|
|
137
137
|
| `[label](https://…)` | link |
|
|
138
138
|
| `# Heading` | a bold line — Teams has no headings in a chat |
|
|
139
139
|
|
|
140
|
-
|
|
141
|
-
a chat,
|
|
142
|
-
|
|
140
|
+
A markdown image and a table are deliberately **not** translated: Teams renders
|
|
141
|
+
neither in a chat bubble, and an image URL would only work if the recipient's
|
|
142
|
+
client could reach it. A picture that is already on disk goes out through the
|
|
143
|
+
`images` parameter instead — see below. For a table, put it in a file and link
|
|
144
|
+
to it.
|
|
143
145
|
|
|
144
146
|
Pass `html: true` on a send tool to bypass the conversion and supply raw HTML
|
|
145
147
|
instead. Everything else is escaped, so a message body cannot inject markup.
|
|
@@ -148,3 +150,30 @@ This is also why pi writes the way it does — short, answer-first, bullets for
|
|
|
148
150
|
anything enumerable. The medium is a chat bubble, not a document. The rules the
|
|
149
151
|
model follows are in `skills/teams-collaboration/SKILL.md`, under *How to format
|
|
150
152
|
a Teams message*.
|
|
153
|
+
|
|
154
|
+
## Sending images
|
|
155
|
+
|
|
156
|
+
`teams_send_chat_message`, `teams_send_channel_message` and
|
|
157
|
+
`teams_reply_channel_message` take `images: ["/pfad/bild.png"]` — local paths of
|
|
158
|
+
pictures to attach. They appear under the text, in the order given, and a
|
|
159
|
+
message may consist of images alone.
|
|
160
|
+
|
|
161
|
+
Teams keeps an inline picture inside the message itself: the bytes travel in the
|
|
162
|
+
same request as the text, and the body points at them (`hostedContents`).
|
|
163
|
+
Nothing is uploaded to SharePoint first, which is why this works with the
|
|
164
|
+
permissions the package already asks for and costs no admin consent.
|
|
165
|
+
|
|
166
|
+
- Accepted: png, jpeg, gif, webp, bmp. Anything else is refused before the
|
|
167
|
+
message is built, because Teams would show it as a broken picture.
|
|
168
|
+
- **4 MB per image**, the limit Graph enforces on hosted content.
|
|
169
|
+
- The path must be readable from the process that sends. For a pi running in a
|
|
170
|
+
container that is a path *inside* the container.
|
|
171
|
+
|
|
172
|
+
This is attachments-for-images only. A PDF, a DOCX or a ZIP cannot be sent: a
|
|
173
|
+
real file attachment needs the file in SharePoint or OneDrive and a `contentUrl`
|
|
174
|
+
pointing at it, which means asking for a write scope on the user's drive. The
|
|
175
|
+
package deliberately does not ask for `Files.ReadWrite`, so a non-image is
|
|
176
|
+
refused rather than half-supported — link to the file instead.
|
|
177
|
+
|
|
178
|
+
What was sent, pictures included, is recorded in the audit entry and shown in
|
|
179
|
+
the confirmation prompt at `safetyLevel: confirm`.
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@patimweb/pi-teams",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.11.0",
|
|
4
4
|
"description": "Microsoft Teams integration for pi coding agent — acts fully as the signed-in user across multiple accounts and tenants: chats, channels, meetings, presence, with configurable guardrails",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"pi-package",
|
|
@@ -97,10 +97,10 @@ instead of trying anyway.
|
|
|
97
97
|
|
|
98
98
|
| Tool | Use when |
|
|
99
99
|
|------|----------|
|
|
100
|
-
| `teams_send_chat_message` | Sending a chat message |
|
|
100
|
+
| `teams_send_chat_message` | Sending a chat message, with or without images |
|
|
101
101
|
| `teams_create_chat` | Starting a new conversation |
|
|
102
|
-
| `teams_send_channel_message` | Posting a new channel message |
|
|
103
|
-
| `teams_reply_channel_message` | Answering inside an existing thread |
|
|
102
|
+
| `teams_send_channel_message` | Posting a new channel message, with or without images |
|
|
103
|
+
| `teams_reply_channel_message` | Answering inside an existing thread, with or without images |
|
|
104
104
|
| `teams_react` | Adding or removing an emoji reaction |
|
|
105
105
|
| `teams_update_message` | Fixing a message the user already sent |
|
|
106
106
|
| `teams_chat_members` | Bringing someone into a group chat, or taking them out |
|
|
@@ -240,6 +240,24 @@ deliberate and must not be worked around:
|
|
|
240
240
|
|
|
241
241
|
Ordinary SharePoint and OneDrive attachments download normally.
|
|
242
242
|
|
|
243
|
+
### Sending images
|
|
244
|
+
|
|
245
|
+
The three send tools take `images: ["/path/bild.png"]`: local pictures to attach
|
|
246
|
+
to the message. They are shown under the text, a message may be images only, and
|
|
247
|
+
nothing is uploaded anywhere — Teams keeps them inside the message.
|
|
248
|
+
|
|
249
|
+
- png, jpeg, gif, webp, bmp, **at most 4 MB each**. Anything else is refused
|
|
250
|
+
before the message is built; report that instead of retrying with the same file.
|
|
251
|
+
- The path is read by the process that sends. Inside a container that is a path
|
|
252
|
+
*inside* the container, not on the user's machine.
|
|
253
|
+
- Only images. A PDF, DOCX or ZIP cannot be attached — give the user a link to
|
|
254
|
+
the file instead. Do not promise an attachment you cannot send.
|
|
255
|
+
- The confirmation prompt and the audit entry name the images that go out, so
|
|
256
|
+
what the user approves is what is sent.
|
|
257
|
+
|
|
258
|
+
For a table, the same applies as for any other non-image: put it in a file and
|
|
259
|
+
link it.
|
|
260
|
+
|
|
243
261
|
## Channel addressing
|
|
244
262
|
|
|
245
263
|
Channels are addressed as `Team/Channel` or via separate `team` and `channel`
|
package/src/graph/messages.ts
CHANGED
|
@@ -12,6 +12,7 @@ import type { ChannelSummary, MessageLocation, MessageSummary, PersonRef } from
|
|
|
12
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
|
+
import type { HostedImage } from "../utils/hosted-content.ts";
|
|
15
16
|
|
|
16
17
|
/* eslint-disable @typescript-eslint/no-explicit-any */
|
|
17
18
|
type Raw = Record<string, any>;
|
|
@@ -37,6 +38,13 @@ export interface MessageInput {
|
|
|
37
38
|
importance?: string;
|
|
38
39
|
/** People to @-mention; each must appear in the body as @DisplayName */
|
|
39
40
|
mentions?: PersonRef[];
|
|
41
|
+
/**
|
|
42
|
+
* Inline images, already read from disk and base64-encoded.
|
|
43
|
+
*
|
|
44
|
+
* They travel in the same POST as the body — Teams keeps the bytes inside the
|
|
45
|
+
* message — so there is no second request and no file upload anywhere.
|
|
46
|
+
*/
|
|
47
|
+
images?: HostedImage[];
|
|
40
48
|
}
|
|
41
49
|
|
|
42
50
|
/**
|
|
@@ -77,12 +85,29 @@ export function buildMessageBody(input: MessageInput): Record<string, unknown> {
|
|
|
77
85
|
});
|
|
78
86
|
});
|
|
79
87
|
|
|
88
|
+
// The pictures follow the text rather than being placed inside it: the caller
|
|
89
|
+
// writes the message, and a model that also had to pick a position in the HTML
|
|
90
|
+
// would be choosing something nobody asked it to choose. The id is ours to
|
|
91
|
+
// pick — it only has to be the same in the body and in hostedContents, which is
|
|
92
|
+
// the part that quietly goes wrong.
|
|
93
|
+
const hostedContents: Record<string, unknown>[] = [];
|
|
94
|
+
(input.images ?? []).forEach((image, index) => {
|
|
95
|
+
const id = String(index + 1);
|
|
96
|
+
content += `<br><img src="../hostedContents/${id}/$value" alt="${escapeHtml(image.name)}">`;
|
|
97
|
+
hostedContents.push({
|
|
98
|
+
"@microsoft.graph.temporaryId": id,
|
|
99
|
+
contentBytes: image.contentBytes,
|
|
100
|
+
contentType: image.contentType,
|
|
101
|
+
});
|
|
102
|
+
});
|
|
103
|
+
|
|
80
104
|
const body: Record<string, unknown> = {
|
|
81
105
|
body: { contentType: "html", content },
|
|
82
106
|
};
|
|
83
107
|
if (input.subject) body.subject = input.subject;
|
|
84
108
|
if (input.importance) body.importance = input.importance;
|
|
85
109
|
if (mentionEntries.length > 0) body.mentions = mentionEntries;
|
|
110
|
+
if (hostedContents.length > 0) body.hostedContents = hostedContents;
|
|
86
111
|
|
|
87
112
|
return body;
|
|
88
113
|
}
|
package/src/safety/index.ts
CHANGED
|
@@ -119,17 +119,29 @@ export function formatMutationSummary(
|
|
|
119
119
|
return str.length > max ? `${str.slice(0, max - 1)}…` : str;
|
|
120
120
|
};
|
|
121
121
|
const disclosed = (value: unknown): string => applyAiFooter(text(value, 400), footer);
|
|
122
|
+
/**
|
|
123
|
+
* The images that travel with the message, by file name.
|
|
124
|
+
*
|
|
125
|
+
* Someone approving a send has to see that a picture is part of it: a
|
|
126
|
+
* confirmation that shows only the text would be asking about half of it.
|
|
127
|
+
*/
|
|
128
|
+
const withImages = (value: unknown): string => {
|
|
129
|
+
const paths = Array.isArray(value) ? value.filter((p): p is string => typeof p === "string") : [];
|
|
130
|
+
if (paths.length === 0) return "";
|
|
131
|
+
const names = paths.map((p) => p.split(/[\\/]/).pop() ?? p).join(", ");
|
|
132
|
+
return `\n\n(with ${paths.length === 1 ? "1 image" : `${paths.length} images`}: ${text(names, 120)})`;
|
|
133
|
+
};
|
|
122
134
|
|
|
123
135
|
switch (toolName) {
|
|
124
136
|
case "teams_send_chat_message":
|
|
125
|
-
return `Send a chat message to "${text(params.chat, 60)}":\n\n${disclosed(params.body)}`;
|
|
137
|
+
return `Send a chat message to "${text(params.chat, 60)}":\n\n${disclosed(params.body)}${withImages(params.images)}`;
|
|
126
138
|
case "teams_send_channel_message": {
|
|
127
139
|
const where = params.team ? `${text(params.team, 40)}/${text(params.channel, 40)}` : text(params.channel, 60);
|
|
128
|
-
return `Post in channel ${where}:\n\n${disclosed(params.body)}`;
|
|
140
|
+
return `Post in channel ${where}:\n\n${disclosed(params.body)}${withImages(params.images)}`;
|
|
129
141
|
}
|
|
130
142
|
case "teams_reply_channel_message": {
|
|
131
143
|
const where = params.team ? `${text(params.team, 40)}/${text(params.channel, 40)}` : text(params.channel, 60);
|
|
132
|
-
return `Reply in thread ${text(params.messageId, 40)} (${where}):\n\n${disclosed(params.body)}`;
|
|
144
|
+
return `Reply in thread ${text(params.messageId, 40)} (${where}):\n\n${disclosed(params.body)}${withImages(params.images)}`;
|
|
133
145
|
}
|
|
134
146
|
case "teams_create_chat": {
|
|
135
147
|
const people = Array.isArray(params.participants) ? params.participants.join(", ") : text(params.participants);
|
|
@@ -12,6 +12,7 @@ import { resolveUserId } from "../graph/me.ts";
|
|
|
12
12
|
import { auditWrite } from "../safety/audit.ts";
|
|
13
13
|
import { CHAT_MESSAGE_BODY_DESCRIPTION, CHAT_SHAPE_GUIDELINE } from "../utils/chat-style.ts";
|
|
14
14
|
import { applyAiFooter } from "../utils/disclosure.ts";
|
|
15
|
+
import { imageNote, readHostedImage } from "../utils/hosted-content.ts";
|
|
15
16
|
import { assertAccess } from "../safety/index.ts";
|
|
16
17
|
import { truncate } from "../utils/formatting.ts";
|
|
17
18
|
import { requireChannel } from "./resolve.ts";
|
|
@@ -67,6 +68,13 @@ const sharedParams = {
|
|
|
67
68
|
),
|
|
68
69
|
importance: Type.Optional(Type.String({ description: "'normal' (default), 'high' or 'urgent'" })),
|
|
69
70
|
html: Type.Optional(Type.Boolean({ description: "Send 'body' as raw HTML instead of converting markdown" })),
|
|
71
|
+
images: Type.Optional(
|
|
72
|
+
Type.Array(Type.String(), {
|
|
73
|
+
description:
|
|
74
|
+
"Local paths of images to attach (png, jpg, gif, webp, bmp; 4 MB each at most). " +
|
|
75
|
+
"They are shown inline, under the text.",
|
|
76
|
+
}),
|
|
77
|
+
),
|
|
70
78
|
};
|
|
71
79
|
|
|
72
80
|
export const teamsSendChannelMessageTool = {
|
|
@@ -74,7 +82,8 @@ export const teamsSendChannelMessageTool = {
|
|
|
74
82
|
description:
|
|
75
83
|
"Post a new message in a Microsoft Teams channel as the signed-in user. It appears exactly as if the " +
|
|
76
84
|
"user posted it. Give the channel as a 'Team/Channel' path or pass 'team' separately. " +
|
|
77
|
-
"'subject' starts a titled post; use teams_reply_channel_message to answer inside an existing thread."
|
|
85
|
+
"'subject' starts a titled post; use teams_reply_channel_message to answer inside an existing thread. " +
|
|
86
|
+
"'images' attaches local image files to the post itself; pi cannot attach other kinds of files.",
|
|
78
87
|
parameters: Type.Object({
|
|
79
88
|
...sharedParams,
|
|
80
89
|
subject: Type.Optional(Type.String({ description: "Optional post title" })),
|
|
@@ -99,6 +108,7 @@ export const teamsSendChannelMessageTool = {
|
|
|
99
108
|
mentions?: string[];
|
|
100
109
|
importance?: string;
|
|
101
110
|
html?: boolean;
|
|
111
|
+
images?: string[];
|
|
102
112
|
},
|
|
103
113
|
signal: AbortSignal | undefined,
|
|
104
114
|
_onUpdate: undefined,
|
|
@@ -106,7 +116,15 @@ export const teamsSendChannelMessageTool = {
|
|
|
106
116
|
): Promise<ToolResult> {
|
|
107
117
|
return run(async () => {
|
|
108
118
|
const conn = connectionFor(ctx, params.account, params.tenant);
|
|
109
|
-
|
|
119
|
+
|
|
120
|
+
// Read before anything is posted: an image that cannot be read has to stop
|
|
121
|
+
// the post, not arrive as text promising one.
|
|
122
|
+
const images = [];
|
|
123
|
+
for (const path of params.images ?? []) images.push(await readHostedImage(path));
|
|
124
|
+
|
|
125
|
+
if (!params.body.trim() && images.length === 0) {
|
|
126
|
+
return errorResult("Refusing to post an empty message.");
|
|
127
|
+
}
|
|
110
128
|
|
|
111
129
|
const { team, channel } = await requireChannel(conn, params.team, params.channel, "write", signal);
|
|
112
130
|
const { mentions, error } = await resolveMentions(conn, params.mentions, signal);
|
|
@@ -128,6 +146,7 @@ export const teamsSendChannelMessageTool = {
|
|
|
128
146
|
subject: params.subject,
|
|
129
147
|
importance: params.importance,
|
|
130
148
|
mentions,
|
|
149
|
+
images,
|
|
131
150
|
},
|
|
132
151
|
signal,
|
|
133
152
|
);
|
|
@@ -138,7 +157,7 @@ export const teamsSendChannelMessageTool = {
|
|
|
138
157
|
tenant: conn.tenant,
|
|
139
158
|
actor: me?.upn,
|
|
140
159
|
target: `channel:${target}`,
|
|
141
|
-
summary: truncate(body, 200),
|
|
160
|
+
summary: truncate(body, 200) + imageNote(images),
|
|
142
161
|
});
|
|
143
162
|
|
|
144
163
|
return textResult(
|
|
@@ -158,7 +177,7 @@ export const teamsSendChannelMessageTool = {
|
|
|
158
177
|
tenant: conn.tenant,
|
|
159
178
|
actor: me?.upn,
|
|
160
179
|
target: `channel:${target}`,
|
|
161
|
-
summary: truncate(body, 200),
|
|
180
|
+
summary: truncate(body, 200) + imageNote(images),
|
|
162
181
|
error: err instanceof Error ? err.message : String(err),
|
|
163
182
|
});
|
|
164
183
|
throw err;
|
|
@@ -172,7 +191,8 @@ export const teamsReplyChannelMessageTool = {
|
|
|
172
191
|
description:
|
|
173
192
|
"Reply inside an existing Microsoft Teams channel thread as the signed-in user. " +
|
|
174
193
|
"Get the messageId from teams_read_channel, teams_read_thread or teams_search_messages. " +
|
|
175
|
-
"Replying keeps the conversation in one thread instead of starting a new post."
|
|
194
|
+
"Replying keeps the conversation in one thread instead of starting a new post. " +
|
|
195
|
+
"'images' attaches local image files to the reply itself.",
|
|
176
196
|
parameters: Type.Object({
|
|
177
197
|
...sharedParams,
|
|
178
198
|
messageId: Type.String({ description: "ID of the thread's opening message" }),
|
|
@@ -192,6 +212,7 @@ export const teamsReplyChannelMessageTool = {
|
|
|
192
212
|
mentions?: string[];
|
|
193
213
|
importance?: string;
|
|
194
214
|
html?: boolean;
|
|
215
|
+
images?: string[];
|
|
195
216
|
},
|
|
196
217
|
signal: AbortSignal | undefined,
|
|
197
218
|
_onUpdate: undefined,
|
|
@@ -199,7 +220,15 @@ export const teamsReplyChannelMessageTool = {
|
|
|
199
220
|
): Promise<ToolResult> {
|
|
200
221
|
return run(async () => {
|
|
201
222
|
const conn = connectionFor(ctx, params.account, params.tenant);
|
|
202
|
-
|
|
223
|
+
|
|
224
|
+
// Read before anything is posted: an image that cannot be read has to stop
|
|
225
|
+
// the reply, not arrive as text promising one.
|
|
226
|
+
const images = [];
|
|
227
|
+
for (const path of params.images ?? []) images.push(await readHostedImage(path));
|
|
228
|
+
|
|
229
|
+
if (!params.body.trim() && images.length === 0) {
|
|
230
|
+
return errorResult("Refusing to post an empty reply.");
|
|
231
|
+
}
|
|
203
232
|
|
|
204
233
|
const { team, channel } = await requireChannel(conn, params.team, params.channel, "write", signal);
|
|
205
234
|
const { mentions, error } = await resolveMentions(conn, params.mentions, signal);
|
|
@@ -221,6 +250,7 @@ export const teamsReplyChannelMessageTool = {
|
|
|
221
250
|
html: params.html,
|
|
222
251
|
importance: params.importance,
|
|
223
252
|
mentions,
|
|
253
|
+
images,
|
|
224
254
|
},
|
|
225
255
|
signal,
|
|
226
256
|
);
|
|
@@ -231,7 +261,7 @@ export const teamsReplyChannelMessageTool = {
|
|
|
231
261
|
tenant: conn.tenant,
|
|
232
262
|
actor: me?.upn,
|
|
233
263
|
target: `channel:${target}#${params.messageId}`,
|
|
234
|
-
summary: truncate(body, 200),
|
|
264
|
+
summary: truncate(body, 200) + imageNote(images),
|
|
235
265
|
});
|
|
236
266
|
|
|
237
267
|
return textResult(
|
|
@@ -251,7 +281,7 @@ export const teamsReplyChannelMessageTool = {
|
|
|
251
281
|
tenant: conn.tenant,
|
|
252
282
|
actor: me?.upn,
|
|
253
283
|
target: `channel:${target}#${params.messageId}`,
|
|
254
|
-
summary: truncate(body, 200),
|
|
284
|
+
summary: truncate(body, 200) + imageNote(images),
|
|
255
285
|
error: err instanceof Error ? err.message : String(err),
|
|
256
286
|
});
|
|
257
287
|
throw err;
|
|
@@ -23,6 +23,7 @@ import { auditWrite } from "../safety/audit.ts";
|
|
|
23
23
|
import { assertAccess } from "../safety/index.ts";
|
|
24
24
|
import { CHAT_MESSAGE_BODY_DESCRIPTION, CHAT_SHAPE_GUIDELINE } from "../utils/chat-style.ts";
|
|
25
25
|
import { applyAiFooter } from "../utils/disclosure.ts";
|
|
26
|
+
import { imageNote, readHostedImage } from "../utils/hosted-content.ts";
|
|
26
27
|
import { truncate } from "../utils/formatting.ts";
|
|
27
28
|
import { requireChat } from "./resolve.ts";
|
|
28
29
|
import {
|
|
@@ -46,6 +47,7 @@ interface SendChatParams {
|
|
|
46
47
|
mentions?: string[];
|
|
47
48
|
importance?: string;
|
|
48
49
|
html?: boolean;
|
|
50
|
+
images?: string[];
|
|
49
51
|
}
|
|
50
52
|
|
|
51
53
|
export const teamsSendChatMessageTool = {
|
|
@@ -54,7 +56,8 @@ export const teamsSendChatMessageTool = {
|
|
|
54
56
|
"Send a message in a Microsoft Teams chat as the signed-in user. The message appears exactly as if the " +
|
|
55
57
|
"user typed it. Accepts a chat ID, group chat topic, or participant name/e-mail. " +
|
|
56
58
|
"Use 'mentions' with names or e-mail addresses to @-mention people — write '@Display Name' in the body " +
|
|
57
|
-
"where the mention should appear."
|
|
59
|
+
"where the mention should appear. " +
|
|
60
|
+
"'images' attaches local image files to the message itself; pi cannot attach other kinds of files.",
|
|
58
61
|
parameters: Type.Object({
|
|
59
62
|
chat: Type.String({ description: "Chat ID, group chat topic, or a participant's name/e-mail" }),
|
|
60
63
|
body: Type.String({ description: CHAT_MESSAGE_BODY_DESCRIPTION }),
|
|
@@ -71,6 +74,13 @@ export const teamsSendChatMessageTool = {
|
|
|
71
74
|
html: Type.Optional(
|
|
72
75
|
Type.Boolean({ description: "Send 'body' as raw HTML instead of converting markdown" }),
|
|
73
76
|
),
|
|
77
|
+
images: Type.Optional(
|
|
78
|
+
Type.Array(Type.String(), {
|
|
79
|
+
description:
|
|
80
|
+
"Local paths of images to attach to the message (png, jpg, gif, webp, bmp; 4 MB each at most). " +
|
|
81
|
+
"They are shown inline, under the text.",
|
|
82
|
+
}),
|
|
83
|
+
),
|
|
74
84
|
}),
|
|
75
85
|
promptSnippet: "Send a message in a Teams chat as the user",
|
|
76
86
|
promptGuidelines: [
|
|
@@ -91,7 +101,14 @@ export const teamsSendChatMessageTool = {
|
|
|
91
101
|
return run(async () => {
|
|
92
102
|
const conn = connectionFor(ctx, params.account, params.tenant);
|
|
93
103
|
|
|
94
|
-
|
|
104
|
+
// Read before anything is sent: a picture that cannot be read has to stop
|
|
105
|
+
// the message, not arrive as text promising one.
|
|
106
|
+
const images = [];
|
|
107
|
+
for (const path of params.images ?? []) images.push(await readHostedImage(path));
|
|
108
|
+
|
|
109
|
+
if (!params.body.trim() && images.length === 0) {
|
|
110
|
+
return errorResult("Refusing to send an empty message.");
|
|
111
|
+
}
|
|
95
112
|
|
|
96
113
|
const chat = await requireChat(conn, params.chat, "write", signal);
|
|
97
114
|
|
|
@@ -129,6 +146,7 @@ export const teamsSendChatMessageTool = {
|
|
|
129
146
|
html: params.html,
|
|
130
147
|
importance: params.importance,
|
|
131
148
|
mentions,
|
|
149
|
+
images,
|
|
132
150
|
},
|
|
133
151
|
signal,
|
|
134
152
|
);
|
|
@@ -139,7 +157,7 @@ export const teamsSendChatMessageTool = {
|
|
|
139
157
|
tenant: conn.tenant,
|
|
140
158
|
actor: me?.upn,
|
|
141
159
|
target: `chat:${chat.label}`,
|
|
142
|
-
summary: truncate(body, 200),
|
|
160
|
+
summary: truncate(body, 200) + imageNote(images),
|
|
143
161
|
});
|
|
144
162
|
|
|
145
163
|
// Replying is reading: clear the unread marker the answer was written for.
|
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Attaching a local image to a message.
|
|
3
|
+
*
|
|
4
|
+
* Teams keeps an inline image inside the message itself as a `hostedContent`:
|
|
5
|
+
* the body points at `../hostedContents/{id}/$value` and the bytes travel in the
|
|
6
|
+
* same POST. That is what makes this cheap — nothing has to be uploaded to
|
|
7
|
+
* SharePoint first, which is what a real file attachment needs, and which would
|
|
8
|
+
* mean asking for a write scope on the user's drive.
|
|
9
|
+
*
|
|
10
|
+
* Graph caps a hosted content at 4 MB, and an image of a type Teams does not
|
|
11
|
+
* render would reach the reader as a broken picture, so both are checked here,
|
|
12
|
+
* before the message that carries it is built.
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
import { readFile, stat } from "node:fs/promises";
|
|
16
|
+
import { basename, extname } from "node:path";
|
|
17
|
+
|
|
18
|
+
/** Graph rejects a hosted content larger than this. */
|
|
19
|
+
export const MAX_HOSTED_CONTENT_BYTES = 4 * 1024 * 1024;
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* The image types Teams shows inline, by file extension.
|
|
23
|
+
*
|
|
24
|
+
* A list rather than "anything starting with image/": a type Teams does not
|
|
25
|
+
* render arrives as a broken image, and refusing it up front is friendlier than
|
|
26
|
+
* sending it and having the reader find out.
|
|
27
|
+
*/
|
|
28
|
+
export const HOSTED_CONTENT_TYPES: Record<string, string> = {
|
|
29
|
+
".png": "image/png",
|
|
30
|
+
".jpg": "image/jpeg",
|
|
31
|
+
".jpeg": "image/jpeg",
|
|
32
|
+
".gif": "image/gif",
|
|
33
|
+
".webp": "image/webp",
|
|
34
|
+
".bmp": "image/bmp",
|
|
35
|
+
};
|
|
36
|
+
|
|
37
|
+
/** The content type for a path, or undefined when Teams does not render it inline. */
|
|
38
|
+
export function hostedContentType(path: string): string | undefined {
|
|
39
|
+
return HOSTED_CONTENT_TYPES[extname(path).toLowerCase()];
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* One image, ready to send.
|
|
44
|
+
*
|
|
45
|
+
* `contentBytes` is base64 because that is what Graph asks for: the bytes are
|
|
46
|
+
* JSON string data, not a multipart upload.
|
|
47
|
+
*/
|
|
48
|
+
export interface HostedImage {
|
|
49
|
+
/** The file name, as the attachment is described in audit entries */
|
|
50
|
+
name: string;
|
|
51
|
+
contentType: string;
|
|
52
|
+
contentBytes: string;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/** A byte count as a human reads it — the limits here are in megabytes. */
|
|
56
|
+
export function formatBytes(bytes: number): string {
|
|
57
|
+
return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* Read one local image for sending.
|
|
62
|
+
*
|
|
63
|
+
* Throws with the reason instead of sending something partial: a message that
|
|
64
|
+
* quietly goes out without the picture the user asked for is worse than a tool
|
|
65
|
+
* call that says why it did not.
|
|
66
|
+
*/
|
|
67
|
+
export async function readHostedImage(path: string): Promise<HostedImage> {
|
|
68
|
+
const name = basename(path);
|
|
69
|
+
|
|
70
|
+
const contentType = hostedContentType(path);
|
|
71
|
+
if (!contentType) {
|
|
72
|
+
const supported = [...new Set(Object.values(HOSTED_CONTENT_TYPES))].join(", ");
|
|
73
|
+
throw new Error(`"${name}" is not an image Teams shows inline. Supported types: ${supported}.`);
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
let info;
|
|
77
|
+
try {
|
|
78
|
+
info = await stat(path);
|
|
79
|
+
} catch {
|
|
80
|
+
throw new Error(`"${path}" could not be read. Give a path to a file that exists locally.`);
|
|
81
|
+
}
|
|
82
|
+
if (info.isDirectory()) throw new Error(`"${path}" is a directory, not an image.`);
|
|
83
|
+
if (info.size === 0) throw new Error(`"${name}" is empty.`);
|
|
84
|
+
if (info.size > MAX_HOSTED_CONTENT_BYTES) {
|
|
85
|
+
throw new Error(
|
|
86
|
+
`"${name}" is ${formatBytes(info.size)}; Teams accepts at most ` +
|
|
87
|
+
`${formatBytes(MAX_HOSTED_CONTENT_BYTES)} per image.`,
|
|
88
|
+
);
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
const bytes = await readFile(path);
|
|
92
|
+
return { name, contentType, contentBytes: bytes.toString("base64") };
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/**
|
|
96
|
+
* The images named in one phrase, for the audit entry.
|
|
97
|
+
*
|
|
98
|
+
* What went out is the thing an audit entry has to answer, and a message with a
|
|
99
|
+
* picture in it is not described by its text alone.
|
|
100
|
+
*/
|
|
101
|
+
export function imageNote(images: readonly HostedImage[]): string {
|
|
102
|
+
if (images.length === 0) return "";
|
|
103
|
+
const label = images.length === 1 ? "image" : "images";
|
|
104
|
+
return ` [${label}: ${images.map((image) => image.name).join(", ")}]`;
|
|
105
|
+
}
|