@patimweb/pi-teams 1.10.0 → 1.12.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.
@@ -173,3 +173,16 @@ Polling works anywhere.
173
173
  whether listen mode is enabled and actually running, how many chats it tracks,
174
174
  how many are waiting out a cooldown, when the last poll was, and what the last
175
175
  error said. See [troubleshooting.md](troubleshooting.md) for the specific cases.
176
+
177
+ ## Presence while listening
178
+
179
+ Teams shows a user as **Offline** unless at least one presence session exists
180
+ for them — a running Teams client is one, pi calling Graph is not. Even a
181
+ status set with `teams_set_presence` stays invisible without a session.
182
+
183
+ While listen mode runs, pi therefore holds its own application presence
184
+ session (`setPresence`, session ID = the app's client ID, `Presence.ReadWrite`)
185
+ and renews it every 5 minutes for 15 minutes at a time. You show as
186
+ **Available**, or as whatever you picked with `teams_set_presence`. When the
187
+ watcher stops, pi clears the session and you drop back to Offline straight away
188
+ (at the latest 15 minutes after pi was killed).
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.12.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
  }
@@ -1,10 +1,14 @@
1
1
  /**
2
2
  * Presence — reading who is available and setting the user's own status.
3
3
  *
4
- * Setting presence uses `setUserPreferredPresence`, not `setPresence`: the
5
- * latter is tied to an application session and gets overwritten by the Teams
6
- * client within seconds. Preferred presence is what the user themselves sets
7
- * in the app, so it behaves the way they expect — including the expiry.
4
+ * What the user picks (`teams_set_presence`) is the *preferred* presence
5
+ * (`setUserPreferredPresence`) — the same thing as choosing a status in the
6
+ * Teams app, including the expiry.
7
+ *
8
+ * Preferred presence only shows while at least one presence *session* exists
9
+ * for the user; without one, Teams shows Offline no matter what was picked.
10
+ * pi opens its own application session (`setPresence`, sessionId = client ID)
11
+ * while listen mode runs — see ../watch/presence-keeper.ts.
8
12
  */
9
13
 
10
14
  import type { TeamsConnection } from "../config/index.ts";
@@ -114,6 +118,44 @@ export async function setPreferredPresence(
114
118
  await graphPost(conn, "/me/presence/setUserPreferredPresence", body, { signal: options.signal });
115
119
  }
116
120
 
121
+ /**
122
+ * Create or refresh pi's own application presence session for a user.
123
+ *
124
+ * `sessionId` must be the app's client ID. The session lasts
125
+ * `expirationDuration` (PT5M … PT4H) and has to be renewed to stay alive.
126
+ * Uses `/users/{id}` so it works for delegated and app-only tokens alike.
127
+ */
128
+ export async function setSessionPresence(
129
+ conn: TeamsConnection,
130
+ userId: string,
131
+ availability: "Available" | "Busy" | "Away",
132
+ options: { expirationDuration?: string; signal?: AbortSignal } = {},
133
+ ): Promise<void> {
134
+ const body: Record<string, unknown> = {
135
+ sessionId: conn.clientId,
136
+ availability,
137
+ activity: availability,
138
+ };
139
+ if (options.expirationDuration) body.expirationDuration = options.expirationDuration;
140
+ await graphPost(conn, `/users/${encodeURIComponent(userId)}/presence/setPresence`, body, {
141
+ signal: options.signal,
142
+ });
143
+ }
144
+
145
+ /** End pi's application presence session right away. */
146
+ export async function clearSessionPresence(
147
+ conn: TeamsConnection,
148
+ userId: string,
149
+ signal?: AbortSignal,
150
+ ): Promise<void> {
151
+ await graphPost(
152
+ conn,
153
+ `/users/${encodeURIComponent(userId)}/presence/clearPresence`,
154
+ { sessionId: conn.clientId },
155
+ { signal },
156
+ );
157
+ }
158
+
117
159
  /** Hand presence back to Teams' automatic calculation. */
118
160
  export async function clearPreferredPresence(
119
161
  conn: TeamsConnection,
@@ -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.
@@ -37,7 +37,9 @@ export const CHAT_MESSAGE_BODY_DESCRIPTION =
37
37
  "Message text as lightweight markdown, in chat shape: the answer on the first line, then at most 5 short " +
38
38
  "lines, `- ` bullets for anything enumerable, one idea per line. No greeting, no closing, no summary. " +
39
39
  "Rendered: **bold**, *italic*, `code`, ~~struck~~, [label](url), `- ` bullets, `1. ` numbered, ``` fences. " +
40
- "A `#` heading arrives as a bold line; tables are not rendered at all.";
40
+ "A `#` heading arrives as a bold line; tables are not rendered at all. Longer posts the user asked for " +
41
+ "(digest, summary, list of news): an intro line, a blank line, then one `- ` bullet per item, a blank line " +
42
+ "before any closing note.";
41
43
 
42
44
  /**
43
45
  * The same shape as one guideline bullet.
@@ -50,7 +52,8 @@ export const CHAT_MESSAGE_BODY_DESCRIPTION =
50
52
  export const CHAT_SHAPE_GUIDELINE =
51
53
  "teams_send_chat_message, teams_send_channel_message, teams_reply_channel_message and teams_update_message " +
52
54
  "write chat shape, not a document: the answer on the first line, at most 5 short lines, `- ` bullets for " +
53
- "enumerable items, no greeting, no closing summary, no tables.";
55
+ "enumerable items, no greeting, no closing summary, no tables; a longer post (digest) is one `- ` bullet per " +
56
+ "item with blank lines between intro, list and closing note.";
54
57
 
55
58
  /**
56
59
  * The shape as prose, for the listen-mode prompt.
@@ -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
+ }
@@ -63,6 +63,11 @@ const HEADING = /^\s{0,3}#{1,6}\s+(.*)$/;
63
63
  * Blank lines separate paragraphs, single newlines become `<br>` inside one —
64
64
  * which is what a person typing into the chat box gets, and the safest
65
65
  * assumption for text that was generated, not composed.
66
+ *
67
+ * Teams renders `<p>` (and lists) in a chat bubble with no margin at all, so
68
+ * `<p>a</p><p>b</p>` looks exactly like `a<br>b` — every blank line the model
69
+ * wrote disappears and a digest arrives as one block. The Teams client itself
70
+ * writes an empty paragraph for a blank line, so blocks are joined with one.
66
71
  */
67
72
  export function markdownToTeamsHtml(text: string): string {
68
73
  const lines = text.replace(/\r\n?/g, "\n").split("\n");
@@ -146,5 +151,8 @@ export function markdownToTeamsHtml(text: string): string {
146
151
  if (code) blocks.push(`<pre>${escapeHtml(code.join("\n"))}</pre>`);
147
152
  flushBlock();
148
153
 
149
- return blocks.join("");
154
+ return blocks.join(BLOCK_SPACER);
150
155
  }
156
+
157
+ /** What the Teams client writes for an empty line between two blocks. */
158
+ export const BLOCK_SPACER = "<p>&nbsp;</p>";
package/src/watch/loop.ts CHANGED
@@ -25,6 +25,7 @@ import { getMe } from "../graph/me.ts";
25
25
  import { chatCandidates } from "../graph/mappers.ts";
26
26
  import { hasAccess } from "../safety/index.ts";
27
27
  import { formatGraphError } from "../utils/errors.ts";
28
+ import { presenceKeeperFor, type PresenceKeeper } from "./presence-keeper.ts";
28
29
  import {
29
30
  activityMarker,
30
31
  chatsToExamine,
@@ -298,6 +299,11 @@ export interface WatchLoopOptions {
298
299
  onError?: (message: string) => void;
299
300
  /** Called after every tick, for the footer */
300
301
  onTick?: (status: WatchRuntimeStatus) => void;
302
+ /**
303
+ * Holds the user's Teams presence online while the watcher runs.
304
+ * Defaults to a Graph-backed keeper; pass `null` to leave presence alone.
305
+ */
306
+ presence?: PresenceKeeper | null;
301
307
  }
302
308
 
303
309
  export interface WatchLoop {
@@ -365,6 +371,10 @@ export function startWatchLoop(options: WatchLoopOptions): WatchLoop {
365
371
  */
366
372
  let me: SignedInUser | undefined;
367
373
  let failureCount = 0;
374
+ const presence =
375
+ options.presence === undefined
376
+ ? presenceKeeperFor(connection, options.onError)
377
+ : options.presence ?? undefined;
368
378
 
369
379
  const status: WatchRuntimeStatus = {
370
380
  running: true,
@@ -393,6 +403,9 @@ export function startWatchLoop(options: WatchLoopOptions): WatchLoop {
393
403
  throw new WatchIdentityError(err);
394
404
  }
395
405
  }
406
+ // Teams shows Offline without a presence session; pi holds one while
407
+ // it listens (see ./presence-keeper.ts).
408
+ if (me.id && !stopped) presence?.start(me.id);
396
409
 
397
410
  const result = await runWatchTick(
398
411
  {
@@ -475,6 +488,7 @@ export function startWatchLoop(options: WatchLoopOptions): WatchLoop {
475
488
  status.running = false;
476
489
  if (timer) clearTimeout(timer);
477
490
  timer = undefined;
491
+ void presence?.stop();
478
492
  },
479
493
  status: () => ({ ...status, wakesThisHour: wakesThisHour(state, Date.now()) }),
480
494
  async poll() {
@@ -0,0 +1,100 @@
1
+ /**
2
+ * Listen mode — keeping the user online while pi listens.
3
+ *
4
+ * Teams shows a user as Offline unless at least one *presence session* exists
5
+ * for them. A running Teams client is such a session; pi talking to Graph is
6
+ * not. Even a preferred presence set with `setUserPreferredPresence` only takes
7
+ * effect "when at least one presence session exists for the user. Otherwise, the
8
+ * user's presence shows as Offline" (Microsoft Graph docs). A user who only
9
+ * works through pi therefore always looked Offline, including while pi was
10
+ * answering their chats.
11
+ *
12
+ * While listen mode runs, pi now holds its own application presence session
13
+ * (`setPresence`, sessionId = the app's client ID) and renews it before it
14
+ * expires. When the watcher stops, the session is cleared so the user drops to
15
+ * Offline right away instead of lingering as Available. A preferred presence
16
+ * the user picked (`teams_set_presence`, e.g. DoNotDisturb) still wins over the
17
+ * session — which is the point: it now actually shows.
18
+ */
19
+
20
+ import type { TeamsConnection } from "../config/index.ts";
21
+ import { clearSessionPresence, setSessionPresence } from "../graph/presence.ts";
22
+ import { formatGraphError } from "../utils/errors.ts";
23
+
24
+ /** How long each session write lasts. Graph accepts PT5M … PT4H. */
25
+ export const PRESENCE_SESSION_MINUTES = 15;
26
+ /** Renew well before expiry so one slow or failed call does not drop the user. */
27
+ export const PRESENCE_RENEW_MINUTES = 5;
28
+
29
+ export interface PresenceKeeperDeps {
30
+ set: (userId: string, expirationDuration: string) => Promise<void>;
31
+ clear: (userId: string) => Promise<void>;
32
+ onError?: (message: string) => void;
33
+ }
34
+
35
+ export interface PresenceKeeper {
36
+ /** Start (or keep) holding the session for this user. Idempotent. */
37
+ start(userId: string): void;
38
+ /** Stop renewing and clear the session. Safe to call twice. */
39
+ stop(): Promise<void>;
40
+ /** Whether a renewal timer is running */
41
+ readonly active: boolean;
42
+ }
43
+
44
+ export function createPresenceKeeper(deps: PresenceKeeperDeps): PresenceKeeper {
45
+ let userId: string | undefined;
46
+ let timer: ReturnType<typeof setInterval> | undefined;
47
+ /** Report a failure once, not every renewal, until a renewal succeeds again. */
48
+ let failing = false;
49
+ const duration = `PT${PRESENCE_SESSION_MINUTES}M`;
50
+
51
+ const renew = async () => {
52
+ if (!userId) return;
53
+ try {
54
+ await deps.set(userId, duration);
55
+ failing = false;
56
+ } catch (err) {
57
+ if (!failing) deps.onError?.(`Could not keep Teams presence online: ${formatGraphError(err)}`);
58
+ failing = true;
59
+ }
60
+ };
61
+
62
+ return {
63
+ start(id: string) {
64
+ if (timer && userId === id) return;
65
+ if (timer) clearInterval(timer);
66
+ userId = id;
67
+ void renew();
68
+ timer = setInterval(() => void renew(), PRESENCE_RENEW_MINUTES * 60_000);
69
+ timer.unref?.();
70
+ },
71
+ async stop() {
72
+ if (timer) clearInterval(timer);
73
+ timer = undefined;
74
+ const id = userId;
75
+ userId = undefined;
76
+ if (!id) return;
77
+ try {
78
+ await deps.clear(id);
79
+ } catch {
80
+ /* the session expires on its own within PRESENCE_SESSION_MINUTES */
81
+ }
82
+ },
83
+ get active() {
84
+ return timer !== undefined;
85
+ },
86
+ };
87
+ }
88
+
89
+ /** The keeper wired to Graph for one connection. */
90
+ export function presenceKeeperFor(
91
+ conn: TeamsConnection,
92
+ onError?: (message: string) => void,
93
+ ): PresenceKeeper {
94
+ return createPresenceKeeper({
95
+ set: (userId, expirationDuration) =>
96
+ setSessionPresence(conn, userId, "Available", { expirationDuration }),
97
+ clear: (userId) => clearSessionPresence(conn, userId),
98
+ onError,
99
+ });
100
+ }