@patimweb/pi-teams 1.13.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 |
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@patimweb/pi-teams",
3
- "version": "1.13.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",
@@ -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,