@patimweb/pi-teams 1.3.1 → 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/README.md +45 -5
- package/package.json +1 -1
- package/skills/teams-collaboration/SKILL.md +16 -3
- package/src/config/index.ts +61 -1
- package/src/extension/index.ts +10 -5
- package/src/graph/chats.ts +41 -0
- package/src/graph/mappers.ts +4 -4
- package/src/safety/index.ts +16 -6
- package/src/status.ts +6 -0
- package/src/tools/teams-create-chat.ts +7 -3
- package/src/tools/teams-send-channel-message.ts +15 -8
- package/src/tools/teams-send-chat-message.ts +9 -4
- package/src/tools/teams-update-message.ts +7 -2
- package/src/tools/teams-watch.ts +43 -9
- package/src/utils/disclosure.ts +55 -0
- package/src/watch/cursor.ts +112 -0
- package/src/watch/index.ts +107 -19
- package/src/watch/loop.ts +85 -24
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
|
-
*
|
|
99
|
-
*
|
|
100
|
-
*
|
|
101
|
-
*
|
|
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
|
-
|
|
116
|
-
|
|
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
|
|
126
|
-
// Mark first
|
|
127
|
-
//
|
|
128
|
-
//
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
287
|
-
//
|
|
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 {
|