@patimweb/pi-teams 1.1.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 +120 -12
- package/docs/entra-app-registration.md +218 -0
- package/package.json +1 -1
- package/skills/teams-collaboration/SKILL.md +87 -1
- package/src/config/index.ts +145 -1
- package/src/extension/index.ts +176 -2
- package/src/graph/messages.ts +14 -4
- package/src/safety/index.ts +1 -1
- package/src/status.ts +22 -1
- package/src/tools/teams-send-channel-message.ts +7 -2
- package/src/tools/teams-send-chat-message.ts +7 -2
- package/src/tools/teams-watch.ts +229 -0
- package/src/tools/tool-names.ts +1 -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/config/index.ts
CHANGED
|
@@ -89,9 +89,54 @@ export interface TenantConfig {
|
|
|
89
89
|
permissions?: PermissionBlock;
|
|
90
90
|
}
|
|
91
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
|
+
|
|
92
137
|
/** A Teams identity pi can sign in as. */
|
|
93
138
|
export interface AccountConfig {
|
|
94
|
-
/** Short label used in tool parameters, e.g. "
|
|
139
|
+
/** Short label used in tool parameters, e.g. "work" */
|
|
95
140
|
name: string;
|
|
96
141
|
/** Friendly name for status output */
|
|
97
142
|
displayName?: string;
|
|
@@ -117,6 +162,8 @@ export interface AccountConfig {
|
|
|
117
162
|
safetyLevel?: SafetyLevel;
|
|
118
163
|
/** Scope rules layered on top of the global ones */
|
|
119
164
|
permissions?: PermissionBlock;
|
|
165
|
+
/** Listen-mode overrides for this identity */
|
|
166
|
+
watch?: WatchConfig;
|
|
120
167
|
/** Additional tenants this identity reaches */
|
|
121
168
|
tenants?: TenantConfig[];
|
|
122
169
|
}
|
|
@@ -132,6 +179,8 @@ export interface TeamsRootConfig {
|
|
|
132
179
|
safetyLevel?: SafetyLevel;
|
|
133
180
|
/** Global scope rules */
|
|
134
181
|
permissions?: PermissionBlock;
|
|
182
|
+
/** Global listen-mode settings */
|
|
183
|
+
watch?: WatchConfig;
|
|
135
184
|
/** Default page size for message listings (default: 25) */
|
|
136
185
|
maxMessages?: number;
|
|
137
186
|
/** Append every write to ~/.pi/agent/pi-teams-audit.jsonl (default: true) */
|
|
@@ -161,6 +210,8 @@ export interface TeamsConnection {
|
|
|
161
210
|
loopbackPort?: number;
|
|
162
211
|
safetyLevel: SafetyLevel;
|
|
163
212
|
permissions: ResolvedPermissions;
|
|
213
|
+
/** Listen-mode settings in force for this account+tenant */
|
|
214
|
+
watch: ResolvedWatchConfig;
|
|
164
215
|
maxMessages: number;
|
|
165
216
|
audit: boolean;
|
|
166
217
|
graphBaseUrl: string;
|
|
@@ -220,6 +271,29 @@ const DEFAULTS = {
|
|
|
220
271
|
authorityHost: "https://login.microsoftonline.com",
|
|
221
272
|
};
|
|
222
273
|
|
|
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
|
+
|
|
223
297
|
const VALID_AUTH_MODES = new Set<string>([
|
|
224
298
|
"interactive",
|
|
225
299
|
"device-code",
|
|
@@ -454,6 +528,7 @@ export function resolveConnection(
|
|
|
454
528
|
scopes,
|
|
455
529
|
safetyLevel: resolveEffectiveSafetyLevel(config.safetyLevel, account, tenant),
|
|
456
530
|
permissions: resolvePermissions([config.permissions, account.permissions, tenant?.permissions]),
|
|
531
|
+
watch: resolveWatchConfig(config.watch, account),
|
|
457
532
|
maxMessages: config.maxMessages ?? DEFAULTS.maxMessages,
|
|
458
533
|
audit: config.audit ?? DEFAULTS.audit,
|
|
459
534
|
graphBaseUrl: (config.graphBaseUrl ?? DEFAULTS.graphBaseUrl).replace(/\/+$/, ""),
|
|
@@ -593,6 +668,75 @@ export function setDefaultAccount(name: string, tenantName?: string): void {
|
|
|
593
668
|
writeRootConfig(config);
|
|
594
669
|
}
|
|
595
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
|
+
|
|
596
740
|
/** Set the safety level globally, on an account, or on a tenant. */
|
|
597
741
|
export function setSafetyLevel(
|
|
598
742
|
level: SafetyLevel,
|
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
|
@@ -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
|
package/src/status.ts
CHANGED
|
@@ -27,6 +27,10 @@ export interface ConnectionCard {
|
|
|
27
27
|
otherAccounts: string[];
|
|
28
28
|
/** Rendered scope rules */
|
|
29
29
|
permissionSummary: string;
|
|
30
|
+
/** Listen mode is switched on */
|
|
31
|
+
watching?: boolean;
|
|
32
|
+
/** One line on what the watcher is doing, when it runs */
|
|
33
|
+
watchSummary?: string;
|
|
30
34
|
}
|
|
31
35
|
|
|
32
36
|
export function buildConnectionCard(
|
|
@@ -50,6 +54,19 @@ export function buildConnectionCard(
|
|
|
50
54
|
expiresAt: cache.expiresAt ? new Date(cache.expiresAt).toISOString() : undefined,
|
|
51
55
|
otherAccounts: conn.allAccounts.map((a) => a.name).filter((name) => name !== conn.account),
|
|
52
56
|
permissionSummary: formatPermissions(conn.permissions),
|
|
57
|
+
// What the configuration asks for; a caller that knows whether the watcher
|
|
58
|
+
// is actually running overrides this with the runtime truth.
|
|
59
|
+
watching: conn.watch.enabled,
|
|
60
|
+
watchSummary: conn.watch.enabled
|
|
61
|
+
? [
|
|
62
|
+
`every ${conn.watch.intervalSeconds} s`,
|
|
63
|
+
conn.watch.chats.length > 0 ? conn.watch.chats.join(", ") : "all recent chats",
|
|
64
|
+
conn.watch.from.length > 0 ? `from ${conn.watch.from.join(", ")}` : undefined,
|
|
65
|
+
`max ${conn.watch.maxTriggersPerHour}/h`,
|
|
66
|
+
]
|
|
67
|
+
.filter(Boolean)
|
|
68
|
+
.join(" · ")
|
|
69
|
+
: undefined,
|
|
53
70
|
};
|
|
54
71
|
}
|
|
55
72
|
|
|
@@ -59,7 +76,8 @@ export function buildConnectionLabel(card: ConnectionCard | undefined): string {
|
|
|
59
76
|
if (!card.signedIn) return `Teams · ${card.account} · not signed in`;
|
|
60
77
|
|
|
61
78
|
const scope = card.tenant === card.account ? card.account : `${card.account}/${card.tenant}`;
|
|
62
|
-
|
|
79
|
+
const listening = card.watching ? " · 👂 listening" : "";
|
|
80
|
+
return `✓ Teams · ${scope}${card.user ? ` · ${card.user}` : ""}${listening}`;
|
|
63
81
|
}
|
|
64
82
|
|
|
65
83
|
/** Rich markdown for `/teams-status` and the expanded card. */
|
|
@@ -98,6 +116,9 @@ export function formatStatusText(card: ConnectionCard | undefined): string {
|
|
|
98
116
|
if (card.otherAccounts.length > 0) {
|
|
99
117
|
lines.push(`- **Other accounts:** ${card.otherAccounts.join(", ")}`);
|
|
100
118
|
}
|
|
119
|
+
lines.push(
|
|
120
|
+
`- **Listen mode:** ${card.watching ? `on — ${card.watchSummary ?? "running"}` : "off"}`,
|
|
121
|
+
);
|
|
101
122
|
|
|
102
123
|
lines.push("", "### What pi may do", "", card.permissionSummary);
|
|
103
124
|
return lines.join("\n");
|
|
@@ -57,14 +57,17 @@ async function resolveMentions(
|
|
|
57
57
|
const sharedParams = {
|
|
58
58
|
channel: Type.String({ description: "Channel name or ID, or a 'Team/Channel' path" }),
|
|
59
59
|
team: Type.Optional(Type.String({ description: "Team name or ID (omit when using a path)" })),
|
|
60
|
-
body: Type.String({
|
|
60
|
+
body: Type.String({
|
|
61
|
+
description:
|
|
62
|
+
"Message text as lightweight markdown (bold, italics, code, bullets, numbered lists, links)",
|
|
63
|
+
}),
|
|
61
64
|
account: AccountParam,
|
|
62
65
|
tenant: TenantParam,
|
|
63
66
|
mentions: Type.Optional(
|
|
64
67
|
Type.Array(Type.String(), { description: "People to @-mention, by name, UPN or e-mail" }),
|
|
65
68
|
),
|
|
66
69
|
importance: Type.Optional(Type.String({ description: "'normal' (default), 'high' or 'urgent'" })),
|
|
67
|
-
html: Type.Optional(Type.Boolean({ description: "
|
|
70
|
+
html: Type.Optional(Type.Boolean({ description: "Send 'body' as raw HTML instead of converting markdown" })),
|
|
68
71
|
};
|
|
69
72
|
|
|
70
73
|
export const teamsSendChannelMessageTool = {
|
|
@@ -81,6 +84,8 @@ export const teamsSendChannelMessageTool = {
|
|
|
81
84
|
promptGuidelines: [
|
|
82
85
|
"Write in the user's voice — the post is attributed to them, not to an assistant.",
|
|
83
86
|
"Show the exact text and the target channel before posting when there is any ambiguity.",
|
|
87
|
+
"Format for a chat, not for a document: lead with the answer, three short paragraphs at most, bullets for lists. Bold, italics, code, links and bullets are rendered; headings become bold lines and tables are not supported — keep those out.",
|
|
88
|
+
"Answer in the language of the conversation you are writing into, and match its register.",
|
|
84
89
|
],
|
|
85
90
|
|
|
86
91
|
async execute(
|
|
@@ -46,7 +46,10 @@ export const teamsSendChatMessageTool = {
|
|
|
46
46
|
"where the mention should appear.",
|
|
47
47
|
parameters: Type.Object({
|
|
48
48
|
chat: Type.String({ description: "Chat ID, group chat topic, or a participant's name/e-mail" }),
|
|
49
|
-
body: Type.String({
|
|
49
|
+
body: Type.String({
|
|
50
|
+
description:
|
|
51
|
+
"Message text as lightweight markdown (bold, italics, code, bullets, numbered lists, links)",
|
|
52
|
+
}),
|
|
50
53
|
account: AccountParam,
|
|
51
54
|
tenant: TenantParam,
|
|
52
55
|
mentions: Type.Optional(
|
|
@@ -58,7 +61,7 @@ export const teamsSendChatMessageTool = {
|
|
|
58
61
|
Type.String({ description: "'normal' (default), 'high' or 'urgent'" }),
|
|
59
62
|
),
|
|
60
63
|
html: Type.Optional(
|
|
61
|
-
Type.Boolean({ description: "
|
|
64
|
+
Type.Boolean({ description: "Send 'body' as raw HTML instead of converting markdown" }),
|
|
62
65
|
),
|
|
63
66
|
}),
|
|
64
67
|
promptSnippet: "Send a message in a Teams chat as the user",
|
|
@@ -66,6 +69,8 @@ export const teamsSendChatMessageTool = {
|
|
|
66
69
|
"Write the message in the user's voice — it is sent from their account, not from an assistant.",
|
|
67
70
|
"Show the user the exact text before sending when the intent is even slightly ambiguous.",
|
|
68
71
|
"Do not add signatures, disclaimers, or a note that the message was written by an AI unless the user asks.",
|
|
72
|
+
"Format for a chat, not for a document: lead with the answer, three short paragraphs at most, bullets for lists. Bold, italics, code, links and bullets are rendered; headings become bold lines and tables are not supported — keep those out.",
|
|
73
|
+
"Answer in the language of the conversation you are writing into, and match its register.",
|
|
69
74
|
],
|
|
70
75
|
|
|
71
76
|
async execute(
|