@patimweb/pi-teams 1.3.0 → 1.4.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/src/watch/loop.ts CHANGED
@@ -6,6 +6,11 @@
6
6
  * rules (./index.ts) and the polling can each be tested on their own, and the
7
7
  * extension stays the only place that touches the session.
8
8
  *
9
+ * The watcher's memory of what it has already looked at does not live in this
10
+ * process — it is read at startup and written back after every tick, so a
11
+ * restart picks up exactly the messages that arrived while pi was not running.
12
+ * See ./cursor.ts for why, and ./index.ts for what counts as "worth a look".
13
+ *
9
14
  * Why polling and not a webhook: Microsoft Graph change notifications for chats
10
15
  * need a publicly reachable HTTPS endpoint plus subscription renewal, which a
11
16
  * laptop on a home network cannot offer. Polling costs one `/me/chats` call per
@@ -14,7 +19,7 @@
14
19
 
15
20
  import type { ChatSummary, MessageSummary, SignedInUser } from "../types.ts";
16
21
  import type { ResolvedWatchConfig, TeamsConnection } from "../config/index.ts";
17
- import { listChats } from "../graph/chats.ts";
22
+ import { getChatViewpoint, listChats } from "../graph/chats.ts";
18
23
  import { listChatMessages } from "../graph/messages.ts";
19
24
  import { getMe } from "../graph/me.ts";
20
25
  import { chatCandidates } from "../graph/mappers.ts";
@@ -24,6 +29,9 @@ import {
24
29
  activityMarker,
25
30
  chatsToExamine,
26
31
  createWatchState,
32
+ isUnread,
33
+ noteFailure,
34
+ noteSuccess,
27
35
  noteWake,
28
36
  pruneState,
29
37
  shouldWake,
@@ -58,6 +66,10 @@ export interface WatchRuntimeStatus {
58
66
  wakesThisHour: number;
59
67
  /** Chats seen so far — a quick sanity signal that the loop is alive */
60
68
  trackedChats: number;
69
+ /** How many chats had moved when the watcher came up, before the per-tick cap */
70
+ catchUp?: number;
71
+ /** Of those, how many the user had already read in Teams */
72
+ catchUpRead?: number;
61
73
  }
62
74
 
63
75
  // ---------------------------------------------------------------------------
@@ -78,65 +90,95 @@ export interface WatchTickDeps {
78
90
  listChats: () => Promise<ChatSummary[]>;
79
91
  /** The most recent messages of one chat, newest first */
80
92
  listMessages: (chatId: string) => Promise<MessageSummary[]>;
93
+ /**
94
+ * The user's read cursor for one chat (`viewpoint.lastMessageReadDateTime`).
95
+ * `undefined` means Graph did not say, which is treated as unread rather than
96
+ * as settled.
97
+ */
98
+ readState: (chatId: string) => Promise<string | undefined>;
81
99
  now: () => number;
82
100
  /**
83
101
  * Read guard, so the watcher respects the same allow/deny rules as every
84
102
  * other read. Defaults to allowing everything.
85
103
  */
86
104
  allowed?: (chat: ChatSummary) => boolean;
105
+ /**
106
+ * Persist the cursor after the tick. The durable half of "pi has looked at
107
+ * this chat": without it a restart replays what was already answered.
108
+ */
109
+ persistCursor?: (seen: ReadonlyMap<string, string>) => void;
87
110
  }
88
111
 
89
112
  export interface WatchTickResult {
90
113
  /** Chats looked at in this tick */
91
114
  examined: number;
115
+ /** Chats that had moved, before the per-tick cap trimmed the batch */
116
+ pending: number;
117
+ /** Of the examined ones, how many the user had already read */
118
+ alreadyRead: number;
92
119
  wakes: WatchEvent[];
93
120
  }
94
121
 
95
122
  /**
96
123
  * Advance the watcher by one tick.
97
124
  *
98
- * The first tick is a **baseline**: it records what is currently there and
99
- * wakes nobody. Switching listen mode on must not answer the backlog — it means
100
- * "from now on", and anything older than the freshness window would be a
101
- * surprise to wake for.
125
+ * Nothing here is a baseline: with a cursor restored, "moved since pi last
126
+ * looked" is the backlog the user switched listen mode on to catch up with, and
127
+ * without one every chat is compared against the user's own read state on the
128
+ * very first tick. What keeps that from becoming a flood is the per-tick cap
129
+ * and the hourly wake limit, not a window that quietly drops messages.
102
130
  */
103
131
  export async function runWatchTick(
104
132
  deps: WatchTickDeps,
105
133
  state: WatchState,
106
134
  watch: ResolvedWatchConfig,
107
135
  me: SignedInUser | undefined,
108
- options: { baseline: boolean },
109
136
  ): Promise<WatchTickResult> {
110
137
  const now = deps.now();
111
138
  pruneState(state, now);
112
139
 
113
140
  const chats = (await deps.listChats()).filter((chat) => deps.allowed?.(chat) ?? true);
114
141
 
115
- if (options.baseline) {
116
- for (const chat of chats) state.seen.set(chat.id, activityMarker(chat));
117
- return { examined: 0, wakes: [] };
118
- }
119
-
120
- const freshnessMs = Math.max(5 * 60_000, watch.intervalSeconds * 2000);
121
- const candidates = chatsToExamine(state, chats, watch, now, freshnessMs).slice(0, MAX_EXAMINATIONS_PER_TICK);
142
+ const candidates = chatsToExamine(state, chats, watch);
143
+ const batch = candidates.slice(0, MAX_EXAMINATIONS_PER_TICK);
122
144
 
123
145
  const wakes: WatchEvent[] = [];
146
+ let alreadyRead = 0;
124
147
 
125
- for (const chat of candidates) {
126
- // Mark first: a chat that fails to load must not be retried forever, and
127
- // the marker is the chat list's own, so the next tick sees it as moved
128
- // only if something new actually arrives.
148
+ for (const chat of batch) {
149
+ // Mark first, then undo the mark if this chat cannot be examined: a chat
150
+ // that fails must not be retried forever, but a token renewal must not turn
151
+ // into a message nobody ever answers.
129
152
  state.seen.set(chat.id, activityMarker(chat));
130
153
 
154
+ // The read cursor first: a chat the user has already opened in Teams ends
155
+ // here, one Graph call in, before the message is even fetched.
156
+ let readAt: string | undefined;
157
+ try {
158
+ readAt = await deps.readState(chat.id);
159
+ } catch {
160
+ if (noteFailure(state, chat.id)) state.seen.delete(chat.id);
161
+ continue;
162
+ }
163
+
164
+ if (!isUnread(chat, readAt)) {
165
+ noteSuccess(state, chat.id);
166
+ alreadyRead += 1;
167
+ continue;
168
+ }
169
+
131
170
  let messages: MessageSummary[];
132
171
  try {
133
172
  messages = await deps.listMessages(chat.id);
134
173
  } catch {
135
174
  // A single unreadable chat must not end the tick — the rest of the
136
175
  // list is still worth examining.
176
+ if (noteFailure(state, chat.id)) state.seen.delete(chat.id);
137
177
  continue;
138
178
  }
139
179
 
180
+ noteSuccess(state, chat.id);
181
+
140
182
  const message = messages.find((entry) => !entry.deletedDateTime) ?? messages[0];
141
183
  if (!message) continue;
142
184
 
@@ -147,7 +189,11 @@ export async function runWatchTick(
147
189
  wakes.push({ chat, message, wokeAt: now, me });
148
190
  }
149
191
 
150
- return { examined: candidates.length, wakes };
192
+ // Written even when nothing woke: marking a chat examined is the progress
193
+ // that must survive a restart.
194
+ if (batch.length > 0) deps.persistCursor?.(state.seen);
195
+
196
+ return { examined: batch.length, pending: candidates.length, alreadyRead, wakes };
151
197
  }
152
198
 
153
199
  // ---------------------------------------------------------------------------
@@ -156,6 +202,14 @@ export async function runWatchTick(
156
202
 
157
203
  export interface WatchLoopOptions {
158
204
  connection: TeamsConnection;
205
+ /**
206
+ * The persisted cursor read at startup. `undefined` means listen mode has
207
+ * never run for this account, which is the one case where the read state of
208
+ * every chat is asked instead — the backlog the user switched it on for.
209
+ */
210
+ cursor?: Map<string, string>;
211
+ /** Called after a tick that moved the cursor, to make it durable */
212
+ persistCursor?: (seen: ReadonlyMap<string, string>) => void;
159
213
  /** Called once per wake, in arrival order */
160
214
  onWake: (event: WatchEvent) => void;
161
215
  /** Called when a tick fails; the loop keeps running */
@@ -206,10 +260,9 @@ export function stopActiveWatchLoop(): void {
206
260
  export function startWatchLoop(options: WatchLoopOptions): WatchLoop {
207
261
  const { connection } = options;
208
262
  const watch = connection.watch;
209
- const state = createWatchState();
263
+ const state = createWatchState(options.cursor);
210
264
 
211
265
  let stopped = false;
212
- let baselineDone = false;
213
266
  let timer: ReturnType<typeof setTimeout> | undefined;
214
267
  let me: SignedInUser | undefined;
215
268
  let meResolved = false;
@@ -243,16 +296,23 @@ export function startWatchLoop(options: WatchLoopOptions): WatchLoop {
243
296
  {
244
297
  listChats: () => listChats(connection, { max: 50, meId: me?.id }),
245
298
  listMessages: (chatId) => listChatMessages(connection, chatId, { max: 1 }),
299
+ readState: async (chatId) => (await getChatViewpoint(connection, chatId))?.lastMessageReadAt,
246
300
  now: () => Date.now(),
247
301
  allowed: (chat) => hasAccess(connection, "read", "chats", chatCandidates(chat)),
302
+ persistCursor: options.persistCursor,
248
303
  },
249
304
  state,
250
305
  watch,
251
306
  me,
252
- { baseline: !baselineDone },
253
307
  );
254
308
 
255
- baselineDone = true;
309
+ // Reported once, from the first tick: the number the user wants after
310
+ // switching listen mode on is "how much did I miss", and every later
311
+ // tick would only show the steady-state traffic.
312
+ if (status.catchUp === undefined) {
313
+ status.catchUp = result.pending;
314
+ status.catchUpRead = result.alreadyRead;
315
+ }
256
316
  failureCount = 0;
257
317
  status.lastTickAt = Date.now();
258
318
  status.lastError = undefined;
@@ -283,8 +343,9 @@ export function startWatchLoop(options: WatchLoopOptions): WatchLoop {
283
343
  schedule(watch.intervalSeconds * 1000 * Math.min(2 ** failureCount, 10));
284
344
  }
285
345
 
286
- // The first tick is immediate: it establishes the baseline, and a user who
287
- // just switched listen mode should not wait a full interval to see it alive.
346
+ // The first tick is immediate: it answers what is still open since the last
347
+ // run, and a user who switched listen mode on should not wait a full interval
348
+ // to see it alive.
288
349
  void tick();
289
350
 
290
351
  return {