@patimweb/pi-teams 1.11.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/package.json +1 -1
- package/src/graph/presence.ts +46 -4
- package/src/utils/chat-style.ts +5 -2
- 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/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",
|
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/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.
|
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
|
+
}
|