@patimweb/pi-teams 1.13.0 → 1.15.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/docs/listen-mode.md +83 -1
- package/docs/tools.md +2 -0
- package/package.json +1 -1
- package/src/config/index.ts +114 -0
- package/src/extension/index.ts +118 -7
- package/src/graph/mappers.ts +2 -1
- package/src/graph/me.ts +20 -0
- package/src/tools/teams-history.ts +152 -0
- package/src/tools/teams-watch.ts +5 -0
- package/src/watch/control.ts +60 -0
- package/src/watch/dispatch.ts +577 -0
- package/src/watch/index.ts +28 -0
- package/src/watch/loop.ts +43 -2
- package/src/watch/pin.ts +62 -0
- package/src/watch/worker.ts +78 -0
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
|
|
241
|
-
if (!
|
|
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,
|
package/src/watch/pin.ts
CHANGED
|
@@ -17,6 +17,8 @@
|
|
|
17
17
|
* turn, and one session runs one watcher.
|
|
18
18
|
*/
|
|
19
19
|
|
|
20
|
+
import { workerIdentity, type WorkerIdentity } from "./worker.ts";
|
|
21
|
+
|
|
20
22
|
/**
|
|
21
23
|
* How long a pin can survive without the turn ending.
|
|
22
24
|
*
|
|
@@ -35,6 +37,57 @@ interface Pin {
|
|
|
35
37
|
|
|
36
38
|
let pin: Pin | undefined;
|
|
37
39
|
|
|
40
|
+
/**
|
|
41
|
+
* Wakes whose prompt is queued but whose turn has not started yet.
|
|
42
|
+
*
|
|
43
|
+
* A wake is delivered as a follow-up: when pi is busy it waits in the queue.
|
|
44
|
+
* Pinning at wake time would therefore move the pin away from the answer that
|
|
45
|
+
* is still being written — the reply to chat A would be refused because chat B
|
|
46
|
+
* had just arrived. The pin is taken instead when the queued prompt actually
|
|
47
|
+
* enters the transcript (see `claimWakePin`), keyed by its exact text.
|
|
48
|
+
*/
|
|
49
|
+
const pending = new Map<string, Pin>();
|
|
50
|
+
|
|
51
|
+
/** The worker identity of this process, read once. Overridable for tests. */
|
|
52
|
+
let worker: WorkerIdentity | undefined = workerIdentity();
|
|
53
|
+
|
|
54
|
+
/** Tests only: pretend this process is (or is not) a chat worker. */
|
|
55
|
+
export function setWorkerIdentityForTests(identity: WorkerIdentity | undefined): void {
|
|
56
|
+
worker = identity;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* Remember a queued wake, so its pin can be taken when its turn starts.
|
|
61
|
+
*
|
|
62
|
+
* Entries older than the pin TTL are dropped on the way: a prompt that never
|
|
63
|
+
* made it into a turn (cancelled queue, restart) must not pin a later one.
|
|
64
|
+
*/
|
|
65
|
+
export function queueWakePin(prompt: string, chatId: string, label: string, now = Date.now()): void {
|
|
66
|
+
for (const [text, entry] of pending) {
|
|
67
|
+
if (now - entry.at > PIN_TTL_MS) pending.delete(text);
|
|
68
|
+
}
|
|
69
|
+
pending.set(prompt, { chatId, label, at: now });
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/**
|
|
73
|
+
* A user message entered the transcript: if it is a queued wake, pin it now.
|
|
74
|
+
*
|
|
75
|
+
* Returns whether a pin was taken. Anything else — typed input, other
|
|
76
|
+
* extensions' prompts — leaves the pin alone.
|
|
77
|
+
*/
|
|
78
|
+
export function claimWakePin(text: string, now = Date.now()): boolean {
|
|
79
|
+
const entry = pending.get(text);
|
|
80
|
+
if (!entry) return false;
|
|
81
|
+
pending.delete(text);
|
|
82
|
+
pin = { chatId: entry.chatId, label: entry.label, at: now };
|
|
83
|
+
return true;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/** How many wakes are queued but not yet pinned. */
|
|
87
|
+
export function pendingWakePins(): number {
|
|
88
|
+
return pending.size;
|
|
89
|
+
}
|
|
90
|
+
|
|
38
91
|
/** Pin the answer of the turn that is about to start to one chat. */
|
|
39
92
|
export function pinWakeTarget(chatId: string, label: string, now = Date.now()): void {
|
|
40
93
|
pin = { chatId, label, at: now };
|
|
@@ -45,8 +98,17 @@ export function clearWakeTarget(): void {
|
|
|
45
98
|
pin = undefined;
|
|
46
99
|
}
|
|
47
100
|
|
|
101
|
+
/** Tests only: forget every queued wake as well. */
|
|
102
|
+
export function resetWakePinsForTests(): void {
|
|
103
|
+
pin = undefined;
|
|
104
|
+
pending.clear();
|
|
105
|
+
}
|
|
106
|
+
|
|
48
107
|
/** The chat an answer is currently owed to, if any. */
|
|
49
108
|
export function getWakeTarget(now = Date.now()): { chatId: string; label: string } | undefined {
|
|
109
|
+
// A chat worker is pinned for its whole life: it exists to answer one chat,
|
|
110
|
+
// so there is no turn after which it may write anywhere else.
|
|
111
|
+
if (worker) return { chatId: worker.chatId, label: worker.label };
|
|
50
112
|
if (!pin) return undefined;
|
|
51
113
|
if (now - pin.at > PIN_TTL_MS) {
|
|
52
114
|
pin = undefined;
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Listen mode — who a chat worker is.
|
|
3
|
+
*
|
|
4
|
+
* In dispatch mode (`watch.dispatch.mode: "process"`) every chat that wakes pi
|
|
5
|
+
* gets a pi process of its own. That process loads this extension like any
|
|
6
|
+
* other, so it has to know two things the moment it starts: that it must never
|
|
7
|
+
* run a watcher of its own, and which single chat it may write to.
|
|
8
|
+
*
|
|
9
|
+
* Both travel in the environment, set by the dispatcher when it spawns the
|
|
10
|
+
* worker. An environment variable is the one channel that exists before the
|
|
11
|
+
* first line of the extension runs, and the model cannot change it.
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
/** Environment variables a chat worker is started with. */
|
|
15
|
+
export const WORKER_ENV = {
|
|
16
|
+
/** The chat this worker answers, and the only one it may write to */
|
|
17
|
+
chat: "PI_TEAMS_WORKER_CHAT",
|
|
18
|
+
/** Chat label, for refusal messages and the session name */
|
|
19
|
+
label: "PI_TEAMS_WORKER_LABEL",
|
|
20
|
+
/** "oneOnOne" | "group" | "meeting" */
|
|
21
|
+
chatType: "PI_TEAMS_WORKER_CHAT_TYPE",
|
|
22
|
+
/**
|
|
23
|
+
* Address of the other person, set for one-to-one chats only.
|
|
24
|
+
*
|
|
25
|
+
* Used to decide whether this chat may look at what pi wrote elsewhere
|
|
26
|
+
* (`teams_history`). In a group chat there is no single person to ask, so
|
|
27
|
+
* the variable is absent and the answer is no.
|
|
28
|
+
*/
|
|
29
|
+
peer: "PI_TEAMS_WORKER_PEER",
|
|
30
|
+
} as const;
|
|
31
|
+
|
|
32
|
+
export interface WorkerIdentity {
|
|
33
|
+
chatId: string;
|
|
34
|
+
label: string;
|
|
35
|
+
chatType?: string;
|
|
36
|
+
peer?: string;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/** The chat this process works for, or undefined in an ordinary pi session. */
|
|
40
|
+
export function workerIdentity(env: NodeJS.ProcessEnv = process.env): WorkerIdentity | undefined {
|
|
41
|
+
const chatId = env[WORKER_ENV.chat]?.trim();
|
|
42
|
+
if (!chatId) return undefined;
|
|
43
|
+
return {
|
|
44
|
+
chatId,
|
|
45
|
+
label: env[WORKER_ENV.label]?.trim() || chatId,
|
|
46
|
+
chatType: env[WORKER_ENV.chatType]?.trim() || undefined,
|
|
47
|
+
peer: env[WORKER_ENV.peer]?.trim() || undefined,
|
|
48
|
+
};
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/** The environment a worker for this chat is started with. */
|
|
52
|
+
export function workerEnv(identity: WorkerIdentity): Record<string, string> {
|
|
53
|
+
const env: Record<string, string> = {
|
|
54
|
+
[WORKER_ENV.chat]: identity.chatId,
|
|
55
|
+
[WORKER_ENV.label]: identity.label,
|
|
56
|
+
};
|
|
57
|
+
if (identity.chatType) env[WORKER_ENV.chatType] = identity.chatType;
|
|
58
|
+
if (identity.peer) env[WORKER_ENV.peer] = identity.peer;
|
|
59
|
+
return env;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* Tools a chat worker may not call at all.
|
|
64
|
+
*
|
|
65
|
+
* A worker answers one conversation. Switching listen mode, rewriting the
|
|
66
|
+
* config or signing the account out would change what every other worker and
|
|
67
|
+
* the watcher itself do, which is not a decision one chat gets to make.
|
|
68
|
+
*/
|
|
69
|
+
export const WORKER_FORBIDDEN_TOOLS = new Set<string>(["teams_watch", "teams_setup", "teams_logout"]);
|
|
70
|
+
|
|
71
|
+
export function workerViolation(toolName: string, identity: WorkerIdentity | undefined): string | undefined {
|
|
72
|
+
if (!identity) return undefined;
|
|
73
|
+
if (!WORKER_FORBIDDEN_TOOLS.has(toolName)) return undefined;
|
|
74
|
+
return (
|
|
75
|
+
`This pi process answers the Teams chat "${identity.label}" only. ${toolName} changes listen mode or the ` +
|
|
76
|
+
"account for every chat, so it is not available here. Ask the user to do it in the main session."
|
|
77
|
+
);
|
|
78
|
+
}
|