@patimweb/pi-teams 1.12.0 → 1.14.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.
@@ -46,7 +46,7 @@ never sees.
46
46
  | `autoStart` | `false` | whether a session with a UI may start the watcher without being asked |
47
47
  | `intervalSeconds` | `60` | seconds between polls (minimum 15) |
48
48
  | `chats` | `[]` | **where** pi listens: glob patterns matched against topic, label, chat ID and participant names. Empty means every recent chat. |
49
- | `from` | `[]` | **who** pi listens to: glob patterns matched against display name, UPN and e-mail. Empty means any sender. |
49
+ | `from` | `[]` | **who** pi listens to: glob patterns matched against display name, UPN and e-mail — so `*@contoso.com` covers a whole domain. Graph sends a sender without address; pi fills it in from the chat's member list or the directory. Empty means any sender. |
50
50
  | `mentionOnly` | `false` | whether a message has to address you: `true`/`false`, or `{ default, chats, people }` for overrides. See [below](#who-has-to-address-pi). |
51
51
  | `cooldownSeconds` | `300` | stay quiet in a chat after waking pi for it |
52
52
  | `maxTriggersPerHour` | `10` | hard cap on wakes per hour |
@@ -186,3 +186,11 @@ and renews it every 5 minutes for 15 minutes at a time. You show as
186
186
  **Available**, or as whatever you picked with `teams_set_presence`. When the
187
187
  watcher stops, pi clears the session and you drop back to Offline straight away
188
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.12.0",
3
+ "version": "1.14.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) => {
@@ -52,7 +52,8 @@ export function mapPerson(raw: Raw | undefined): PersonRef | undefined {
52
52
  const name = identity.displayName ?? identity.userPrincipalName ?? identity.email;
53
53
  if (!name && !identity.id) return undefined;
54
54
  return {
55
- id: identity.id ?? undefined,
55
+ // A chat member's `id` is the *membership* id; the person is `userId`.
56
+ id: identity.userId ?? identity.id ?? undefined,
56
57
  displayName: name ?? "(unknown)",
57
58
  upn: identity.userPrincipalName ?? undefined,
58
59
  mail: identity.email ?? identity.mail ?? undefined,
package/src/graph/me.ts CHANGED
@@ -20,6 +20,26 @@ export async function getMe(conn: TeamsConnection, signal?: AbortSignal): Promis
20
20
  return { ...user, tenantId: conn.tenantId };
21
21
  }
22
22
 
23
+ /**
24
+ * UPN and mail of one user, by object id.
25
+ *
26
+ * A chat message's sender carries only id and display name, so a listen rule
27
+ * like `*@contoso.com` has nothing to match against without this lookup.
28
+ */
29
+ export async function getUserAddresses(
30
+ conn: TeamsConnection,
31
+ userId: string,
32
+ signal?: AbortSignal,
33
+ ): Promise<{ upn?: string; mail?: string }> {
34
+ const raw = await graphRequest<Record<string, string | null | undefined>>(
35
+ conn,
36
+ "GET",
37
+ `/users/${encodeURIComponent(userId)}`,
38
+ { query: { $select: "userPrincipalName,mail" }, signal },
39
+ );
40
+ return { upn: raw?.userPrincipalName ?? undefined, mail: raw?.mail ?? undefined };
41
+ }
42
+
23
43
  /**
24
44
  * Find people by name, UPN or e-mail.
25
45
  *
@@ -237,6 +237,34 @@ export function isWatchedChat(chat: ChatSummary, watch: ResolvedWatchConfig): bo
237
237
  // Who pi listens to
238
238
  // ---------------------------------------------------------------------------
239
239
 
240
+ /**
241
+ * The sender of a message, with the addresses Graph left out filled in.
242
+ *
243
+ * `chatMessage.from.user` carries only id and display name — no UPN, no mail —
244
+ * so a listen rule like `*@contoso.com` could never match a sender. The chat's
245
+ * member list does carry the mail address, so the sender is looked up there
246
+ * first (by user id, else by display name).
247
+ */
248
+ export function withSenderAddresses(message: MessageSummary, chat: ChatSummary): MessageSummary {
249
+ const sender = message.from;
250
+ if (!sender || (sender.upn && sender.mail)) return message;
251
+
252
+ const member =
253
+ (sender.id ? chat.members.find((m) => m.id === sender.id) : undefined) ??
254
+ chat.members.find((m) => m.displayName === sender.displayName);
255
+ if (!member || (!member.upn && !member.mail)) return message;
256
+
257
+ return {
258
+ ...message,
259
+ from: { ...sender, upn: sender.upn ?? member.upn, mail: sender.mail ?? member.mail },
260
+ };
261
+ }
262
+
263
+ /** Does the sender carry an address a rule could match on? */
264
+ export function senderHasAddress(message: MessageSummary): boolean {
265
+ return !!(message.from?.upn || message.from?.mail);
266
+ }
267
+
240
268
  /**
241
269
  * Does the sender fall inside the configured listen list?
242
270
  *
package/src/watch/loop.ts CHANGED
@@ -37,13 +37,16 @@ import {
37
37
  noteWake,
38
38
  openMessages,
39
39
  pruneState,
40
+ senderHasAddress,
40
41
  settleChat,
41
42
  shouldWake,
42
43
  waitingCount,
43
44
  wakesThisHour,
44
45
  WATCH_MESSAGE_WINDOW,
46
+ withSenderAddresses,
45
47
  type WatchState,
46
48
  } from "./index.ts";
49
+ import { getUserAddresses } from "../graph/me.ts";
47
50
 
48
51
  // ---------------------------------------------------------------------------
49
52
  // Events and status
@@ -145,6 +148,12 @@ export interface WatchTickDeps {
145
148
  * this chat": without it a restart replays what was already answered.
146
149
  */
147
150
  persistCursor?: (seen: ReadonlyMap<string, string>) => void;
151
+ /**
152
+ * UPN and mail of a user by id, for senders the chat's member list could not
153
+ * fill in. Without an address, a listen rule like `*@contoso.com` cannot
154
+ * match. Optional; a failure leaves the sender as it was.
155
+ */
156
+ lookupAddresses?: (userId: string) => Promise<{ upn?: string; mail?: string }>;
148
157
  }
149
158
 
150
159
  export interface WatchTickResult {
@@ -159,6 +168,13 @@ export interface WatchTickResult {
159
168
  wakes: WatchEvent[];
160
169
  }
161
170
 
171
+ function definedOnly(value: { upn?: string; mail?: string }): { upn?: string; mail?: string } {
172
+ const out: { upn?: string; mail?: string } = {};
173
+ if (value.upn) out.upn = value.upn;
174
+ if (value.mail) out.mail = value.mail;
175
+ return out;
176
+ }
177
+
162
178
  /**
163
179
  * Advance the watcher by one tick.
164
180
  *
@@ -237,12 +253,24 @@ export async function runWatchTick(
237
253
 
238
254
  noteSuccess(state, chat.id);
239
255
 
240
- const message = messages.find((entry) => !entry.deletedDateTime) ?? messages[0];
241
- if (!message) {
256
+ const newest = messages.find((entry) => !entry.deletedDateTime) ?? messages[0];
257
+ if (!newest) {
242
258
  settleChat(state, chat.id, marker);
243
259
  continue;
244
260
  }
245
261
 
262
+ // Graph sends the sender without UPN or mail; fill them in so address
263
+ // rules (`*@contoso.com`) in `from` and `mentionOnly.people` can match.
264
+ let message = withSenderAddresses(newest, chat);
265
+ if (!senderHasAddress(message) && message.from?.id && deps.lookupAddresses) {
266
+ try {
267
+ const found = await deps.lookupAddresses(message.from.id);
268
+ message = { ...message, from: { ...message.from, ...definedOnly(found) } };
269
+ } catch {
270
+ /* no address: the rule is matched on name and id alone, as before */
271
+ }
272
+ }
273
+
246
274
  const decision = shouldWake(chat, message, me, watch, state, now);
247
275
  if (!decision.wake) {
248
276
  if (decision.retryAfterMs !== undefined) {
@@ -371,6 +399,18 @@ export function startWatchLoop(options: WatchLoopOptions): WatchLoop {
371
399
  */
372
400
  let me: SignedInUser | undefined;
373
401
  let failureCount = 0;
402
+ /** Sender addresses by user id — people rarely change their UPN mid-session. */
403
+ const addressCache = new Map<string, Promise<{ upn?: string; mail?: string }>>();
404
+ const lookupAddresses = (userId: string) => {
405
+ let hit = addressCache.get(userId);
406
+ if (!hit) {
407
+ hit = getUserAddresses(connection, userId);
408
+ // A failed lookup is retried next time instead of being cached.
409
+ hit.catch(() => addressCache.delete(userId));
410
+ addressCache.set(userId, hit);
411
+ }
412
+ return hit;
413
+ };
374
414
  const presence =
375
415
  options.presence === undefined
376
416
  ? presenceKeeperFor(connection, options.onError)
@@ -416,6 +456,7 @@ export function startWatchLoop(options: WatchLoopOptions): WatchLoop {
416
456
  now: () => Date.now(),
417
457
  allowed: (chat) => hasAccess(connection, "read", "chats", chatCandidates(chat)),
418
458
  persistCursor: options.persistCursor,
459
+ lookupAddresses,
419
460
  },
420
461
  state,
421
462
  watch,