@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.
- package/docs/listen-mode.md +13 -0
- 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/graph/presence.ts +46 -4
- 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/chat-style.ts +5 -2
- package/src/utils/hosted-content.ts +105 -0
- package/src/utils/richtext.ts +9 -1
- package/src/watch/loop.ts +14 -0
- package/src/watch/presence-keeper.ts +100 -0
package/docs/listen-mode.md
CHANGED
|
@@ -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
|
-
|
|
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.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`
|
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/graph/presence.ts
CHANGED
|
@@ -1,10 +1,14 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Presence — reading who is available and setting the user's own status.
|
|
3
3
|
*
|
|
4
|
-
*
|
|
5
|
-
*
|
|
6
|
-
*
|
|
7
|
-
*
|
|
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,
|
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.
|
package/src/utils/chat-style.ts
CHANGED
|
@@ -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
|
+
}
|
package/src/utils/richtext.ts
CHANGED
|
@@ -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> </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
|
+
}
|