@patimweb/pi-teams 1.0.0 → 1.2.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 +232 -40
- package/docs/entra-app-registration.md +218 -0
- package/package.json +3 -2
- package/prompts/teams-doctor.md +9 -2
- package/skills/teams-collaboration/SKILL.md +91 -3
- package/src/auth/cache-plugin.ts +215 -0
- package/src/auth/index.ts +301 -54
- package/src/auth/msal.ts +168 -0
- package/src/config/index.ts +197 -19
- package/src/extension/index.ts +176 -2
- package/src/graph/messages.ts +14 -4
- package/src/safety/index.ts +4 -4
- package/src/status.ts +43 -8
- package/src/tools/teams-accounts.ts +22 -17
- package/src/tools/teams-doctor.ts +36 -24
- package/src/tools/teams-login.ts +111 -60
- package/src/tools/teams-send-channel-message.ts +7 -2
- package/src/tools/teams-send-chat-message.ts +7 -2
- package/src/tools/teams-setup.ts +12 -2
- package/src/tools/teams-status.ts +8 -8
- package/src/tools/teams-watch.ts +229 -0
- package/src/tools/tool-names.ts +1 -0
- package/src/utils/environment.ts +41 -0
- package/src/utils/formatting.ts +0 -12
- package/src/utils/richtext.ts +150 -0
- package/src/watch/index.ts +224 -0
- package/src/watch/loop.ts +304 -0
- package/src/watch/prompt.ts +59 -0
- package/src/auth/client-credentials.ts +0 -72
- package/src/auth/device-code.ts +0 -276
- package/src/auth/token-store.ts +0 -156
package/src/config/index.ts
CHANGED
|
@@ -37,6 +37,7 @@ import {
|
|
|
37
37
|
type PermissionBlock,
|
|
38
38
|
type ResolvedPermissions,
|
|
39
39
|
} from "./scope.ts";
|
|
40
|
+
import { canOpenBrowser } from "../utils/environment.ts";
|
|
40
41
|
|
|
41
42
|
// ---------------------------------------------------------------------------
|
|
42
43
|
// Types
|
|
@@ -45,15 +46,23 @@ import {
|
|
|
45
46
|
/**
|
|
46
47
|
* How pi obtains a token.
|
|
47
48
|
*
|
|
48
|
-
* - `
|
|
49
|
-
*
|
|
49
|
+
* - `interactive`: delegated. The browser opens at the Microsoft sign-in page
|
|
50
|
+
* and MSAL catches the redirect on a loopback port. pi then acts **as the
|
|
51
|
+
* signed-in user** — messages come from their account, and everything they
|
|
52
|
+
* can see, pi can see.
|
|
53
|
+
* - `device-code`: delegated as well, but the user types a short code on
|
|
54
|
+
* another device. For SSH sessions, containers, and anything headless.
|
|
50
55
|
* - `client-credentials`: app-only. No user identity; Microsoft Graph does not
|
|
51
56
|
* allow posting chat or channel messages this way (outside of migration
|
|
52
57
|
* scenarios), so writes are refused with an explanation.
|
|
53
|
-
* - `auto`:
|
|
54
|
-
* `device-code
|
|
58
|
+
* - `auto`: `client-credentials` when a secret is configured, otherwise
|
|
59
|
+
* `interactive` where a browser is reachable and `device-code` where it is
|
|
60
|
+
* not.
|
|
55
61
|
*/
|
|
56
|
-
export type AuthMode = "device-code" | "client-credentials" | "auto";
|
|
62
|
+
export type AuthMode = "interactive" | "device-code" | "client-credentials" | "auto";
|
|
63
|
+
|
|
64
|
+
/** An auth mode after "auto" has been resolved away. */
|
|
65
|
+
export type ResolvedAuthMode = "interactive" | "device-code" | "client-credentials";
|
|
57
66
|
|
|
58
67
|
/** Safety level for mutation operations. */
|
|
59
68
|
export type SafetyLevel = "open" | "confirm" | "readonly";
|
|
@@ -72,15 +81,62 @@ export interface TenantConfig {
|
|
|
72
81
|
authMode?: AuthMode;
|
|
73
82
|
/** Requested Graph scopes override */
|
|
74
83
|
scopes?: string[];
|
|
84
|
+
/** Fixed loopback port for the browser sign-in redirect */
|
|
85
|
+
loopbackPort?: number;
|
|
75
86
|
/** Safety level override — most specific wins */
|
|
76
87
|
safetyLevel?: SafetyLevel;
|
|
77
88
|
/** Scope rules layered on top of the account's */
|
|
78
89
|
permissions?: PermissionBlock;
|
|
79
90
|
}
|
|
80
91
|
|
|
92
|
+
/**
|
|
93
|
+
* Settings for "listen" mode — where pi is woken by an incoming Teams chat.
|
|
94
|
+
*
|
|
95
|
+
* Every field is optional and merges field by field down the cascade
|
|
96
|
+
* (global → account), so an interval can be set once while the chat filter is
|
|
97
|
+
* narrowed per account.
|
|
98
|
+
*/
|
|
99
|
+
export interface WatchConfig {
|
|
100
|
+
/** Whether the watcher runs at all (default: false) */
|
|
101
|
+
enabled?: boolean;
|
|
102
|
+
/** Seconds between polls (default: 60, minimum: 15) */
|
|
103
|
+
intervalSeconds?: number;
|
|
104
|
+
/**
|
|
105
|
+
* Glob patterns for the chats to watch — matched against topic, label,
|
|
106
|
+
* chat ID and participant names, exactly like the scope rules. Empty or
|
|
107
|
+
* absent means every recent chat.
|
|
108
|
+
*/
|
|
109
|
+
chats?: string[];
|
|
110
|
+
/**
|
|
111
|
+
* Only wake pi for messages from these people — matched against display
|
|
112
|
+
* name, UPN and e-mail. Empty or absent means any sender.
|
|
113
|
+
*
|
|
114
|
+
* This is the "listen to these people" switch: `chats` decides *where* pi
|
|
115
|
+
* listens, `from` decides *to whom*.
|
|
116
|
+
*/
|
|
117
|
+
from?: string[];
|
|
118
|
+
/** Only wake pi where the signed-in user is mentioned (default: false) */
|
|
119
|
+
mentionOnly?: boolean;
|
|
120
|
+
/** Seconds to stay quiet in a chat after waking pi for it (default: 300) */
|
|
121
|
+
cooldownSeconds?: number;
|
|
122
|
+
/** Hard cap on wakes per hour, across all chats (default: 10) */
|
|
123
|
+
maxTriggersPerHour?: number;
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
/** A watch config after every default and clamp has been applied. */
|
|
127
|
+
export interface ResolvedWatchConfig {
|
|
128
|
+
enabled: boolean;
|
|
129
|
+
intervalSeconds: number;
|
|
130
|
+
chats: string[];
|
|
131
|
+
from: string[];
|
|
132
|
+
mentionOnly: boolean;
|
|
133
|
+
cooldownSeconds: number;
|
|
134
|
+
maxTriggersPerHour: number;
|
|
135
|
+
}
|
|
136
|
+
|
|
81
137
|
/** A Teams identity pi can sign in as. */
|
|
82
138
|
export interface AccountConfig {
|
|
83
|
-
/** Short label used in tool parameters, e.g. "
|
|
139
|
+
/** Short label used in tool parameters, e.g. "work" */
|
|
84
140
|
name: string;
|
|
85
141
|
/** Friendly name for status output */
|
|
86
142
|
displayName?: string;
|
|
@@ -94,10 +150,20 @@ export interface AccountConfig {
|
|
|
94
150
|
authMode?: AuthMode;
|
|
95
151
|
/** Requested Graph scopes (default: DEFAULT_SCOPES) */
|
|
96
152
|
scopes?: string[];
|
|
153
|
+
/**
|
|
154
|
+
* Fixed loopback port for the browser sign-in redirect.
|
|
155
|
+
*
|
|
156
|
+
* Only needed where the app registration lists an exact redirect URI such
|
|
157
|
+
* as `http://localhost:3000`; by default MSAL picks a free port and Entra
|
|
158
|
+
* accepts any port on localhost for a public client.
|
|
159
|
+
*/
|
|
160
|
+
loopbackPort?: number;
|
|
97
161
|
/** Safety level override */
|
|
98
162
|
safetyLevel?: SafetyLevel;
|
|
99
163
|
/** Scope rules layered on top of the global ones */
|
|
100
164
|
permissions?: PermissionBlock;
|
|
165
|
+
/** Listen-mode overrides for this identity */
|
|
166
|
+
watch?: WatchConfig;
|
|
101
167
|
/** Additional tenants this identity reaches */
|
|
102
168
|
tenants?: TenantConfig[];
|
|
103
169
|
}
|
|
@@ -113,6 +179,8 @@ export interface TeamsRootConfig {
|
|
|
113
179
|
safetyLevel?: SafetyLevel;
|
|
114
180
|
/** Global scope rules */
|
|
115
181
|
permissions?: PermissionBlock;
|
|
182
|
+
/** Global listen-mode settings */
|
|
183
|
+
watch?: WatchConfig;
|
|
116
184
|
/** Default page size for message listings (default: 25) */
|
|
117
185
|
maxMessages?: number;
|
|
118
186
|
/** Append every write to ~/.pi/agent/pi-teams-audit.jsonl (default: true) */
|
|
@@ -136,10 +204,14 @@ export interface TeamsConnection {
|
|
|
136
204
|
clientId: string;
|
|
137
205
|
clientSecret?: string;
|
|
138
206
|
/** Effective auth mode — never "auto" after resolution */
|
|
139
|
-
authMode:
|
|
207
|
+
authMode: ResolvedAuthMode;
|
|
140
208
|
scopes: string[];
|
|
209
|
+
/** Fixed loopback port for the browser sign-in, when one is configured */
|
|
210
|
+
loopbackPort?: number;
|
|
141
211
|
safetyLevel: SafetyLevel;
|
|
142
212
|
permissions: ResolvedPermissions;
|
|
213
|
+
/** Listen-mode settings in force for this account+tenant */
|
|
214
|
+
watch: ResolvedWatchConfig;
|
|
143
215
|
maxMessages: number;
|
|
144
216
|
audit: boolean;
|
|
145
217
|
graphBaseUrl: string;
|
|
@@ -165,13 +237,11 @@ export class ConfigError extends Error {
|
|
|
165
237
|
/**
|
|
166
238
|
* Delegated scopes requested by default.
|
|
167
239
|
*
|
|
168
|
-
* `
|
|
169
|
-
*
|
|
240
|
+
* `openid`, `profile` and `offline_access` are absent on purpose: MSAL always
|
|
241
|
+
* requests them and rejects them in an explicit scope list. The lasting
|
|
242
|
+
* sign-in they buy is therefore automatic.
|
|
170
243
|
*/
|
|
171
244
|
export const DEFAULT_SCOPES: string[] = [
|
|
172
|
-
"offline_access",
|
|
173
|
-
"openid",
|
|
174
|
-
"profile",
|
|
175
245
|
"User.Read",
|
|
176
246
|
"User.ReadBasic.All",
|
|
177
247
|
"Team.ReadBasic.All",
|
|
@@ -201,7 +271,35 @@ const DEFAULTS = {
|
|
|
201
271
|
authorityHost: "https://login.microsoftonline.com",
|
|
202
272
|
};
|
|
203
273
|
|
|
204
|
-
|
|
274
|
+
/**
|
|
275
|
+
* Listen-mode defaults and the bounds they are clamped to.
|
|
276
|
+
*
|
|
277
|
+
* The lower bound on `intervalSeconds` is what keeps a watcher from turning
|
|
278
|
+
* into a Graph-throttling loop: every tick costs at least one `/me/chats`
|
|
279
|
+
* round-trip, and each wake costs a full model turn.
|
|
280
|
+
*/
|
|
281
|
+
export const WATCH_DEFAULTS: ResolvedWatchConfig = {
|
|
282
|
+
enabled: false,
|
|
283
|
+
intervalSeconds: 60,
|
|
284
|
+
chats: [],
|
|
285
|
+
from: [],
|
|
286
|
+
mentionOnly: false,
|
|
287
|
+
cooldownSeconds: 300,
|
|
288
|
+
maxTriggersPerHour: 10,
|
|
289
|
+
};
|
|
290
|
+
|
|
291
|
+
const WATCH_BOUNDS = {
|
|
292
|
+
intervalSeconds: { min: 15, max: 3600 },
|
|
293
|
+
cooldownSeconds: { min: 0, max: 86400 },
|
|
294
|
+
maxTriggersPerHour: { min: 0, max: 1000 },
|
|
295
|
+
};
|
|
296
|
+
|
|
297
|
+
const VALID_AUTH_MODES = new Set<string>([
|
|
298
|
+
"interactive",
|
|
299
|
+
"device-code",
|
|
300
|
+
"client-credentials",
|
|
301
|
+
"auto",
|
|
302
|
+
]);
|
|
205
303
|
const VALID_SAFETY_LEVELS = new Set<string>(["open", "confirm", "readonly"]);
|
|
206
304
|
|
|
207
305
|
// ---------------------------------------------------------------------------
|
|
@@ -299,18 +397,27 @@ export function resolveEffectiveSafetyLevel(
|
|
|
299
397
|
);
|
|
300
398
|
}
|
|
301
399
|
|
|
302
|
-
/**
|
|
400
|
+
/**
|
|
401
|
+
* Effective auth mode, resolving "auto" against the available credentials and
|
|
402
|
+
* the environment.
|
|
403
|
+
*
|
|
404
|
+
* `browserAvailable` is injected rather than detected here so the cascade stays
|
|
405
|
+
* a pure function — the detection itself lives in ../auth/msal.ts.
|
|
406
|
+
*/
|
|
303
407
|
export function resolveAuthMode(
|
|
304
408
|
account: AccountConfig | undefined,
|
|
305
409
|
tenant: TenantConfig | undefined,
|
|
306
410
|
hasSecret: boolean,
|
|
307
|
-
|
|
411
|
+
browserAvailable = true,
|
|
412
|
+
): ResolvedAuthMode {
|
|
308
413
|
const configured =
|
|
309
414
|
validateAuthMode(tenant?.authMode) ??
|
|
310
415
|
validateAuthMode(account?.authMode) ??
|
|
311
416
|
DEFAULTS.authMode;
|
|
312
|
-
|
|
313
|
-
return configured;
|
|
417
|
+
|
|
418
|
+
if (configured !== "auto") return configured;
|
|
419
|
+
if (hasSecret) return "client-credentials";
|
|
420
|
+
return browserAvailable ? "interactive" : "device-code";
|
|
314
421
|
}
|
|
315
422
|
|
|
316
423
|
// ---------------------------------------------------------------------------
|
|
@@ -395,7 +502,7 @@ export function resolveConnection(
|
|
|
395
502
|
if (missing.length > 0) throw new ConfigError(missing);
|
|
396
503
|
|
|
397
504
|
const clientSecret = tenant?.clientSecret ?? account.clientSecret ?? undefined;
|
|
398
|
-
const authMode = resolveAuthMode(account, tenant, !!clientSecret);
|
|
505
|
+
const authMode = resolveAuthMode(account, tenant, !!clientSecret, canOpenBrowser());
|
|
399
506
|
|
|
400
507
|
if (authMode === "client-credentials" && !clientSecret) {
|
|
401
508
|
throw new ConfigError(
|
|
@@ -417,9 +524,11 @@ export function resolveConnection(
|
|
|
417
524
|
clientId,
|
|
418
525
|
clientSecret,
|
|
419
526
|
authMode,
|
|
527
|
+
loopbackPort: tenant?.loopbackPort ?? account.loopbackPort,
|
|
420
528
|
scopes,
|
|
421
529
|
safetyLevel: resolveEffectiveSafetyLevel(config.safetyLevel, account, tenant),
|
|
422
530
|
permissions: resolvePermissions([config.permissions, account.permissions, tenant?.permissions]),
|
|
531
|
+
watch: resolveWatchConfig(config.watch, account),
|
|
423
532
|
maxMessages: config.maxMessages ?? DEFAULTS.maxMessages,
|
|
424
533
|
audit: config.audit ?? DEFAULTS.audit,
|
|
425
534
|
graphBaseUrl: (config.graphBaseUrl ?? DEFAULTS.graphBaseUrl).replace(/\/+$/, ""),
|
|
@@ -559,6 +668,75 @@ export function setDefaultAccount(name: string, tenantName?: string): void {
|
|
|
559
668
|
writeRootConfig(config);
|
|
560
669
|
}
|
|
561
670
|
|
|
671
|
+
/**
|
|
672
|
+
* Effective listen-mode settings: account > global > defaults.
|
|
673
|
+
*
|
|
674
|
+
* Exported for tests — every field is clamped here, so the watcher itself can
|
|
675
|
+
* assume sane numbers instead of defending against a hand-edited config file.
|
|
676
|
+
*/
|
|
677
|
+
export function resolveWatchConfig(
|
|
678
|
+
global: WatchConfig | undefined,
|
|
679
|
+
account: AccountConfig | undefined,
|
|
680
|
+
): ResolvedWatchConfig {
|
|
681
|
+
const local = account?.watch;
|
|
682
|
+
const pick = <K extends keyof WatchConfig>(key: K): WatchConfig[K] => local?.[key] ?? global?.[key];
|
|
683
|
+
|
|
684
|
+
return {
|
|
685
|
+
enabled: pick("enabled") ?? WATCH_DEFAULTS.enabled,
|
|
686
|
+
intervalSeconds: clamp(pick("intervalSeconds"), WATCH_BOUNDS.intervalSeconds, WATCH_DEFAULTS.intervalSeconds),
|
|
687
|
+
chats: normalizePatterns(pick("chats")),
|
|
688
|
+
from: normalizePatterns(pick("from")),
|
|
689
|
+
mentionOnly: pick("mentionOnly") ?? WATCH_DEFAULTS.mentionOnly,
|
|
690
|
+
cooldownSeconds: clamp(pick("cooldownSeconds"), WATCH_BOUNDS.cooldownSeconds, WATCH_DEFAULTS.cooldownSeconds),
|
|
691
|
+
maxTriggersPerHour: clamp(
|
|
692
|
+
pick("maxTriggersPerHour"),
|
|
693
|
+
WATCH_BOUNDS.maxTriggersPerHour,
|
|
694
|
+
WATCH_DEFAULTS.maxTriggersPerHour,
|
|
695
|
+
),
|
|
696
|
+
};
|
|
697
|
+
}
|
|
698
|
+
|
|
699
|
+
function clamp(value: unknown, bounds: { min: number; max: number }, fallback: number): number {
|
|
700
|
+
const number = typeof value === "number" && Number.isFinite(value) ? value : fallback;
|
|
701
|
+
return Math.min(bounds.max, Math.max(bounds.min, Math.round(number)));
|
|
702
|
+
}
|
|
703
|
+
|
|
704
|
+
function normalizePatterns(patterns: string[] | undefined): string[] {
|
|
705
|
+
if (!Array.isArray(patterns)) return [];
|
|
706
|
+
const cleaned = patterns
|
|
707
|
+
.filter((pattern): pattern is string => typeof pattern === "string")
|
|
708
|
+
.map((pattern) => pattern.trim())
|
|
709
|
+
.filter((pattern) => pattern.length > 0);
|
|
710
|
+
return [...new Set(cleaned)];
|
|
711
|
+
}
|
|
712
|
+
|
|
713
|
+
/**
|
|
714
|
+
* Merge a watch patch into the config file and report what is now in force.
|
|
715
|
+
*
|
|
716
|
+
* Writes to the account block when an account is named and to the global block
|
|
717
|
+
* otherwise — matching how `watch` itself is resolved. The resolved settings
|
|
718
|
+
* are returned rather than the patch, so a caller reports the effect and not
|
|
719
|
+
* the request.
|
|
720
|
+
*/
|
|
721
|
+
export function setWatchConfig(patch: WatchConfig, accountName?: string): ResolvedWatchConfig {
|
|
722
|
+
const config = readRootConfig();
|
|
723
|
+
|
|
724
|
+
if (!accountName) {
|
|
725
|
+
config.watch = { ...config.watch, ...patch };
|
|
726
|
+
writeRootConfig(config);
|
|
727
|
+
return resolveWatchConfig(config.watch, undefined);
|
|
728
|
+
}
|
|
729
|
+
|
|
730
|
+
const account = config.accounts.find((a) => a.name.toLowerCase() === accountName.toLowerCase());
|
|
731
|
+
if (!account) {
|
|
732
|
+
throw new ConfigError([`account "${accountName}"`], `Account "${accountName}" not found.`);
|
|
733
|
+
}
|
|
734
|
+
|
|
735
|
+
account.watch = { ...account.watch, ...patch };
|
|
736
|
+
writeRootConfig(config);
|
|
737
|
+
return resolveWatchConfig(config.watch, account);
|
|
738
|
+
}
|
|
739
|
+
|
|
562
740
|
/** Set the safety level globally, on an account, or on a tenant. */
|
|
563
741
|
export function setSafetyLevel(
|
|
564
742
|
level: SafetyLevel,
|
|
@@ -644,7 +822,7 @@ const TEMPLATE_JSON = `{
|
|
|
644
822
|
"displayName": "Work account",
|
|
645
823
|
"tenantId": "contoso.onmicrosoft.com",
|
|
646
824
|
"clientId": "00000000-0000-0000-0000-000000000000",
|
|
647
|
-
"authMode": "
|
|
825
|
+
"authMode": "interactive",
|
|
648
826
|
"safetyLevel": "confirm",
|
|
649
827
|
"permissions": {
|
|
650
828
|
"read": {
|
package/src/extension/index.ts
CHANGED
|
@@ -12,6 +12,7 @@ import { Box, Text } from "@earendil-works/pi-tui";
|
|
|
12
12
|
import {
|
|
13
13
|
ensureConfigTemplate,
|
|
14
14
|
getConfigPath,
|
|
15
|
+
setWatchConfig,
|
|
15
16
|
tryResolveConnection,
|
|
16
17
|
type TeamsConnection,
|
|
17
18
|
} from "../config/index.ts";
|
|
@@ -22,6 +23,13 @@ import {
|
|
|
22
23
|
isMutationTool,
|
|
23
24
|
LOCAL_CONFIG_TOOLS,
|
|
24
25
|
} from "../safety/index.ts";
|
|
26
|
+
import {
|
|
27
|
+
setActiveWatchLoop,
|
|
28
|
+
startWatchLoop,
|
|
29
|
+
stopActiveWatchLoop,
|
|
30
|
+
type WatchLoop,
|
|
31
|
+
} from "../watch/loop.ts";
|
|
32
|
+
import { composeWatchPrompt } from "../watch/prompt.ts";
|
|
25
33
|
|
|
26
34
|
import { teamsSetupTool } from "../tools/teams-setup.ts";
|
|
27
35
|
import { teamsLoginTool, teamsLogoutTool } from "../tools/teams-login.ts";
|
|
@@ -49,6 +57,7 @@ import { teamsSearchMessagesTool } from "../tools/teams-search-messages.ts";
|
|
|
49
57
|
import { teamsReactTool } from "../tools/teams-react.ts";
|
|
50
58
|
import { teamsDeleteMessageTool } from "../tools/teams-delete-message.ts";
|
|
51
59
|
import { teamsInboxTool } from "../tools/teams-inbox.ts";
|
|
60
|
+
import { teamsWatchTool } from "../tools/teams-watch.ts";
|
|
52
61
|
import {
|
|
53
62
|
teamsGetPresenceTool,
|
|
54
63
|
teamsSetPresenceTool,
|
|
@@ -73,6 +82,7 @@ const tools = [
|
|
|
73
82
|
teamsAccountsTool,
|
|
74
83
|
teamsStatusTool,
|
|
75
84
|
teamsPermissionsTool,
|
|
85
|
+
teamsWatchTool,
|
|
76
86
|
teamsDoctorTool,
|
|
77
87
|
// Discovery
|
|
78
88
|
teamsListTeamsTool,
|
|
@@ -118,6 +128,74 @@ export default function (pi: ExtensionAPI) {
|
|
|
118
128
|
return connection;
|
|
119
129
|
};
|
|
120
130
|
|
|
131
|
+
// -----------------------------------------------------------------------
|
|
132
|
+
// Listen mode
|
|
133
|
+
// -----------------------------------------------------------------------
|
|
134
|
+
|
|
135
|
+
/** The watcher running in this session, when listen mode is on. */
|
|
136
|
+
let watchLoop: WatchLoop | undefined;
|
|
137
|
+
|
|
138
|
+
/** The last polling error the user was told about, so it is said once. */
|
|
139
|
+
let reportedWatchError: string | undefined;
|
|
140
|
+
|
|
141
|
+
const stopWatch = () => {
|
|
142
|
+
watchLoop?.stop();
|
|
143
|
+
watchLoop = undefined;
|
|
144
|
+
setActiveWatchLoop(undefined);
|
|
145
|
+
};
|
|
146
|
+
|
|
147
|
+
/**
|
|
148
|
+
* Start the watcher for the session's account, if the config asks for it.
|
|
149
|
+
*
|
|
150
|
+
* Restarting is the same call: the old loop is stopped first, so a config
|
|
151
|
+
* change cannot leave two pollers running.
|
|
152
|
+
*/
|
|
153
|
+
const startWatch = (ctx: any) => {
|
|
154
|
+
stopWatch();
|
|
155
|
+
|
|
156
|
+
const conn = refresh();
|
|
157
|
+
if (!conn?.watch.enabled) return;
|
|
158
|
+
|
|
159
|
+
reportedWatchError = undefined;
|
|
160
|
+
const loop = startWatchLoop({
|
|
161
|
+
connection: conn,
|
|
162
|
+
onWake: (event) => {
|
|
163
|
+
pi.sendUserMessage(composeWatchPrompt(event, event.me), { deliverAs: "followUp" });
|
|
164
|
+
},
|
|
165
|
+
onTick: (status) => {
|
|
166
|
+
if (!status.lastError) reportedWatchError = undefined;
|
|
167
|
+
},
|
|
168
|
+
onError: (message) => {
|
|
169
|
+
// A watcher that fails every tick would otherwise say so every minute.
|
|
170
|
+
if (message === reportedWatchError) return;
|
|
171
|
+
reportedWatchError = message;
|
|
172
|
+
ctx.ui.notify(`Teams listen mode: ${message}`, "warning");
|
|
173
|
+
},
|
|
174
|
+
});
|
|
175
|
+
|
|
176
|
+
watchLoop = loop;
|
|
177
|
+
setActiveWatchLoop(loop);
|
|
178
|
+
};
|
|
179
|
+
|
|
180
|
+
/**
|
|
181
|
+
* Paint the footer status line from the current connection.
|
|
182
|
+
*
|
|
183
|
+
* The only place the status line is written, so the three surfaces —
|
|
184
|
+
* session start, `/teams-status`, and a tool result — cannot drift apart.
|
|
185
|
+
*/
|
|
186
|
+
const paintStatus = (ctx: any) => {
|
|
187
|
+
const card = buildConnectionCard(refresh());
|
|
188
|
+
if (!card) return;
|
|
189
|
+
const watching = !!watchLoop?.status().running;
|
|
190
|
+
ctx.ui.setStatus(
|
|
191
|
+
"teams",
|
|
192
|
+
ctx.ui.theme.fg(
|
|
193
|
+
card.signedIn ? "success" : "warning",
|
|
194
|
+
buildConnectionLabel({ ...card, watching }),
|
|
195
|
+
),
|
|
196
|
+
);
|
|
197
|
+
};
|
|
198
|
+
|
|
121
199
|
// -----------------------------------------------------------------------
|
|
122
200
|
// Status card — rendered in the transcript, never sent to the model
|
|
123
201
|
// -----------------------------------------------------------------------
|
|
@@ -162,7 +240,7 @@ export default function (pi: ExtensionAPI) {
|
|
|
162
240
|
return;
|
|
163
241
|
}
|
|
164
242
|
pi.appendEntry<ConnectionCard>("teams-connection", card);
|
|
165
|
-
|
|
243
|
+
paintStatus(ctx);
|
|
166
244
|
ctx.ui.notify(buildConnectionLabel(card), "info");
|
|
167
245
|
},
|
|
168
246
|
});
|
|
@@ -188,6 +266,56 @@ export default function (pi: ExtensionAPI) {
|
|
|
188
266
|
},
|
|
189
267
|
});
|
|
190
268
|
|
|
269
|
+
pi.registerCommand("teams-listen", {
|
|
270
|
+
description: "Let pi listen to Teams: on, off, or status (default)",
|
|
271
|
+
handler: async (args, ctx) => {
|
|
272
|
+
const conn = refresh();
|
|
273
|
+
if (!conn) {
|
|
274
|
+
ctx.ui.notify(
|
|
275
|
+
`Teams: no account configured yet. Use the teams_setup tool, or edit ${getConfigPath()}.`,
|
|
276
|
+
"warning",
|
|
277
|
+
);
|
|
278
|
+
return;
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
const action = args.trim().toLowerCase() || "status";
|
|
282
|
+
|
|
283
|
+
if (action === "on" || action === "off") {
|
|
284
|
+
// Written to the account, not globally: the watcher listens as one
|
|
285
|
+
// identity, and "which account" must not be a guess.
|
|
286
|
+
const watch = setWatchConfig({ enabled: action === "on" }, conn.account);
|
|
287
|
+
startWatch(ctx);
|
|
288
|
+
paintStatus(ctx);
|
|
289
|
+
ctx.ui.notify(
|
|
290
|
+
watch.enabled
|
|
291
|
+
? `Teams listen mode on — polling every ${watch.intervalSeconds} s, max ${watch.maxTriggersPerHour} wakes/hour.`
|
|
292
|
+
: "Teams listen mode off.",
|
|
293
|
+
"info",
|
|
294
|
+
);
|
|
295
|
+
return;
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
if (action !== "status") {
|
|
299
|
+
ctx.ui.notify(`Unknown argument "${action}". Use on, off, or status.`, "warning");
|
|
300
|
+
return;
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
const runtime = watchLoop?.status();
|
|
304
|
+
pi.appendEntry<ConnectionCard>("teams-connection", { ...buildConnectionCard(conn)!, watching: !!runtime?.running });
|
|
305
|
+
ctx.ui.notify(
|
|
306
|
+
[
|
|
307
|
+
`Listen mode: ${conn.watch.enabled ? "on" : "off"}`,
|
|
308
|
+
`every ${conn.watch.intervalSeconds} s`,
|
|
309
|
+
conn.watch.chats.length > 0 ? `chats: ${conn.watch.chats.join(", ")}` : "all recent chats",
|
|
310
|
+
conn.watch.from.length > 0 ? `people: ${conn.watch.from.join(", ")}` : "any sender",
|
|
311
|
+
conn.watch.mentionOnly ? "mentions only" : "every message",
|
|
312
|
+
runtime ? `${runtime.wakesThisHour} wake(s) this hour` : "not running in this session",
|
|
313
|
+
].join(" · "),
|
|
314
|
+
"info",
|
|
315
|
+
);
|
|
316
|
+
},
|
|
317
|
+
});
|
|
318
|
+
|
|
191
319
|
pi.registerCommand("teams-inbox", {
|
|
192
320
|
description: "Show what needs your attention in Microsoft Teams",
|
|
193
321
|
handler: async (_args, ctx) => {
|
|
@@ -246,7 +374,8 @@ export default function (pi: ExtensionAPI) {
|
|
|
246
374
|
const card = buildConnectionCard(connection);
|
|
247
375
|
if (!card) return;
|
|
248
376
|
|
|
249
|
-
|
|
377
|
+
paintStatus(ctx);
|
|
378
|
+
startWatch(ctx);
|
|
250
379
|
|
|
251
380
|
if (!card.signedIn) {
|
|
252
381
|
ctx.ui.notify(
|
|
@@ -294,6 +423,16 @@ export default function (pi: ExtensionAPI) {
|
|
|
294
423
|
});
|
|
295
424
|
}
|
|
296
425
|
|
|
426
|
+
// -----------------------------------------------------------------------
|
|
427
|
+
// Listen mode lifecycle
|
|
428
|
+
// -----------------------------------------------------------------------
|
|
429
|
+
|
|
430
|
+
pi.on("session_shutdown", () => {
|
|
431
|
+
// The timer would be unref'd anyway, but a poll in flight during shutdown
|
|
432
|
+
// would still be a Graph call for a session that is gone.
|
|
433
|
+
stopWatch();
|
|
434
|
+
});
|
|
435
|
+
|
|
297
436
|
// -----------------------------------------------------------------------
|
|
298
437
|
// Safety interceptor
|
|
299
438
|
// -----------------------------------------------------------------------
|
|
@@ -333,4 +472,39 @@ export default function (pi: ExtensionAPI) {
|
|
|
333
472
|
if (!approved) return { block: true, reason: `User declined: ${summary}` };
|
|
334
473
|
}
|
|
335
474
|
});
|
|
475
|
+
|
|
476
|
+
// -----------------------------------------------------------------------
|
|
477
|
+
// Status after a tool ran
|
|
478
|
+
// -----------------------------------------------------------------------
|
|
479
|
+
|
|
480
|
+
/**
|
|
481
|
+
* The status line is a snapshot of session start — but every change to the
|
|
482
|
+
* connection happens afterwards, in a tool, and tools have no UI context of
|
|
483
|
+
* their own. Without this the footer would still read "not signed in" after a
|
|
484
|
+
* successful login, and "listening" after listen mode was switched off.
|
|
485
|
+
*/
|
|
486
|
+
const STATUS_TOOLS = new Set([
|
|
487
|
+
"teams_login",
|
|
488
|
+
"teams_logout",
|
|
489
|
+
"teams_setup",
|
|
490
|
+
"teams_accounts",
|
|
491
|
+
"teams_watch",
|
|
492
|
+
]);
|
|
493
|
+
|
|
494
|
+
/** Tools after which the watcher has to be re-read from the config. */
|
|
495
|
+
const WATCH_CONFIG_TOOLS = new Set(["teams_watch", "teams_setup", "teams_accounts"]);
|
|
496
|
+
|
|
497
|
+
pi.on("tool_result", async (event, ctx) => {
|
|
498
|
+
if (!STATUS_TOOLS.has(event.toolName) || event.isError) return;
|
|
499
|
+
|
|
500
|
+
if (event.toolName === "teams_logout") {
|
|
501
|
+
// A watcher polling a session that was just removed only produces
|
|
502
|
+
// errors; leaving it running would be noise, not service.
|
|
503
|
+
stopWatch();
|
|
504
|
+
} else if (WATCH_CONFIG_TOOLS.has(event.toolName)) {
|
|
505
|
+
startWatch(ctx);
|
|
506
|
+
}
|
|
507
|
+
|
|
508
|
+
paintStatus(ctx);
|
|
509
|
+
});
|
|
336
510
|
}
|
package/src/graph/messages.ts
CHANGED
|
@@ -11,7 +11,7 @@ import type { TeamsConnection } from "../config/index.ts";
|
|
|
11
11
|
import type { ChannelSummary, MessageLocation, MessageSummary, PersonRef } from "../types.ts";
|
|
12
12
|
import { graphGetOptional, graphList, graphPost } from "./client.ts";
|
|
13
13
|
import { mapMessage } from "./mappers.ts";
|
|
14
|
-
import {
|
|
14
|
+
import { markdownToTeamsHtml } from "../utils/richtext.ts";
|
|
15
15
|
|
|
16
16
|
/* eslint-disable @typescript-eslint/no-explicit-any */
|
|
17
17
|
type Raw = Record<string, any>;
|
|
@@ -21,9 +21,15 @@ type Raw = Record<string, any>;
|
|
|
21
21
|
// ---------------------------------------------------------------------------
|
|
22
22
|
|
|
23
23
|
export interface MessageInput {
|
|
24
|
-
/**
|
|
24
|
+
/**
|
|
25
|
+
* Message text.
|
|
26
|
+
*
|
|
27
|
+
* Treated as lightweight markdown by default — bold, italic, code, bullets,
|
|
28
|
+
* numbered lists, links and `# headings` are converted to the HTML Teams
|
|
29
|
+
* renders. Set `html` to true to send raw HTML instead.
|
|
30
|
+
*/
|
|
25
31
|
body: string;
|
|
26
|
-
/** Treat `body` as raw HTML instead of
|
|
32
|
+
/** Treat `body` as raw HTML instead of converting markdown */
|
|
27
33
|
html?: boolean;
|
|
28
34
|
/** Channel messages only */
|
|
29
35
|
subject?: string;
|
|
@@ -39,10 +45,14 @@ export interface MessageInput {
|
|
|
39
45
|
* Mentions are the fiddly part: Teams requires an `<at id="N">` span in the
|
|
40
46
|
* HTML *and* a matching entry in the `mentions` array. The display name is
|
|
41
47
|
* replaced in the text so the caller can just write "@Anna Schmidt".
|
|
48
|
+
*
|
|
49
|
+
* Markdown is converted unless the caller says the body is already HTML: what
|
|
50
|
+
* comes back from a model is markdown, and markdown sent raw arrives as
|
|
51
|
+
* asterisks and dashes.
|
|
42
52
|
*/
|
|
43
53
|
export function buildMessageBody(input: MessageInput): Record<string, unknown> {
|
|
44
54
|
const mentions = input.mentions ?? [];
|
|
45
|
-
let content = input.html ? input.body :
|
|
55
|
+
let content = input.html ? input.body : markdownToTeamsHtml(input.body);
|
|
46
56
|
|
|
47
57
|
const mentionEntries: Record<string, unknown>[] = [];
|
|
48
58
|
mentions.forEach((person, index) => {
|
package/src/safety/index.ts
CHANGED
|
@@ -18,7 +18,7 @@
|
|
|
18
18
|
* which channel a message is actually headed for.
|
|
19
19
|
*/
|
|
20
20
|
|
|
21
|
-
import type { SafetyLevel, TeamsConnection } from "../config/index.ts";
|
|
21
|
+
import type { ResolvedAuthMode, SafetyLevel, TeamsConnection } from "../config/index.ts";
|
|
22
22
|
import { checkScope, type ScopeCategory, type ScopeMode } from "../config/scope.ts";
|
|
23
23
|
import { ScopeDeniedError } from "../utils/errors.ts";
|
|
24
24
|
import { MUTATION_TOOLS } from "../tools/tool-names.ts";
|
|
@@ -39,7 +39,7 @@ export function isMutationTool(toolName: string): boolean {
|
|
|
39
39
|
* needs a way to add an account or sign out. They also work before anything is
|
|
40
40
|
* configured at all, which is the whole point of `teams_setup`.
|
|
41
41
|
*/
|
|
42
|
-
export const LOCAL_CONFIG_TOOLS = new Set<string>(["teams_setup", "teams_logout"]);
|
|
42
|
+
export const LOCAL_CONFIG_TOOLS = new Set<string>(["teams_setup", "teams_logout", "teams_watch"]);
|
|
43
43
|
|
|
44
44
|
/**
|
|
45
45
|
* Why a mutation must be refused outright, or undefined when it may proceed
|
|
@@ -47,7 +47,7 @@ export const LOCAL_CONFIG_TOOLS = new Set<string>(["teams_setup", "teams_logout"
|
|
|
47
47
|
*/
|
|
48
48
|
export function blockReason(
|
|
49
49
|
safetyLevel: SafetyLevel,
|
|
50
|
-
authMode:
|
|
50
|
+
authMode: ResolvedAuthMode,
|
|
51
51
|
toolName: string,
|
|
52
52
|
): string | undefined {
|
|
53
53
|
if (!isMutationTool(toolName)) return undefined;
|
|
@@ -64,7 +64,7 @@ export function blockReason(
|
|
|
64
64
|
return (
|
|
65
65
|
`"${toolName}" cannot run on an app-only token. Microsoft Graph only permits app-only writes to ` +
|
|
66
66
|
`chats and channels for migration scenarios, so pi cannot post as a person this way. ` +
|
|
67
|
-
`Set authMode to "
|
|
67
|
+
`Set authMode to "interactive" for this account and run teams_login.`
|
|
68
68
|
);
|
|
69
69
|
}
|
|
70
70
|
|