@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 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
- Tables and images are deliberately **not** translated: Teams renders neither in
141
- a chat, so passing them through would look worse than plain text. Put a table in
142
- a file and link it instead.
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.10.0",
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`
@@ -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
  }
@@ -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
- if (!params.body.trim()) return errorResult("Refusing to post an empty message.");
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
- if (!params.body.trim()) return errorResult("Refusing to post an empty reply.");
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
- if (!params.body.trim()) return errorResult("Refusing to send an empty message.");
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
+ }