@patimweb/pi-teams 1.11.0 → 1.13.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,24 @@ 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).
189
+
190
+ ## "Seen" as soon as pi picks a message up
191
+
192
+ When a message wakes pi, the chat is marked read right away — before the answer
193
+ is written. With read receipts on in the tenant, the sender sees the "seen" eye
194
+ under their message and knows a reply is coming. A real typing indicator
195
+ ("…") is not possible: Microsoft Graph has no typing API for user accounts,
196
+ only the Bot Framework has one for bots.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@patimweb/pi-teams",
3
- "version": "1.11.0",
3
+ "version": "1.13.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",
@@ -37,6 +37,7 @@ import {
37
37
  type WatchLoop,
38
38
  } from "../watch/loop.ts";
39
39
  import { composeWatchPrompt } from "../watch/prompt.ts";
40
+ import { setChatReadState } from "../graph/chats.ts";
40
41
  import { clearWakeTarget, pinViolation, pinWakeTarget } from "../watch/pin.ts";
41
42
  import { readWatchCursor, writeWatchCursor } from "../watch/cursor.ts";
42
43
 
@@ -225,6 +226,13 @@ export default function (pi: ExtensionAPI) {
225
226
  // answer the chat that woke pi, and the message it is about to read
226
227
  // was written by somebody else.
227
228
  pinWakeTarget(event.chat.id, event.chat.label);
229
+ // Mark the chat read the moment pi picks it up: with read receipts on,
230
+ // the sender sees the "seen" eye right away — the closest thing to a
231
+ // typing indicator Graph offers (there is no typing API for users).
232
+ // Fire-and-forget: a failed receipt must not hold up the answer.
233
+ if (event.me?.id) {
234
+ void setChatReadState(conn, event.chat.id, event.me, true).catch(() => undefined);
235
+ }
228
236
  pi.sendUserMessage(composeWatchPrompt(event, event.me), { deliverAs: "followUp" });
229
237
  },
230
238
  onTick: (status) => {
@@ -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,
@@ -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.
@@ -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
+ }