@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.
@@ -4,7 +4,7 @@
4
4
 
5
5
  import { Type } from "typebox";
6
6
  import { buildConnectionCard, formatStatusText } from "../status.ts";
7
- import { getToken, isFresh } from "../auth/token-store.ts";
7
+ import { readCacheSummary } from "../auth/cache-plugin.ts";
8
8
  import { readRootConfig } from "../config/index.ts";
9
9
  import {
10
10
  AccountParam,
@@ -47,13 +47,13 @@ export const teamsStatusTool = {
47
47
  ...(account.tenants ?? []).map((t) => ({ name: `${account.name}/${t.name}`, tenantId: t.tenantId })),
48
48
  ];
49
49
  for (const entry of tenants) {
50
- const token = getToken(account.name, entry.tenantId);
51
- const state = isFresh(token)
52
- ? "signed in"
53
- : token?.refreshToken
54
- ? "session saved (will refresh)"
55
- : "not signed in";
56
- const who = token?.user?.upn ? ` — ${token.user.upn}` : "";
50
+ const cache = readCacheSummary(account.name, entry.tenantId);
51
+ const state = !cache.present
52
+ ? "not signed in"
53
+ : cache.fresh
54
+ ? "signed in"
55
+ : "session saved (renews on next use)";
56
+ const who = cache.username ? ` — ${cache.username}` : "";
57
57
  others.push(`- ${entry.name}: ${state}${who}`);
58
58
  }
59
59
  }
@@ -0,0 +1,229 @@
1
+ /**
2
+ * teams_watch — switch listen mode on and off, and report what it is doing.
3
+ *
4
+ * The actual polling lives in the extension, because only it can inject a
5
+ * message into the session and update the status line. This tool owns the
6
+ * configuration and the explanation; the extension restarts the loop when the
7
+ * config changes.
8
+ */
9
+
10
+ import { Type } from "typebox";
11
+ import {
12
+ getConfigPath,
13
+ resolveConnection,
14
+ setWatchConfig,
15
+ type WatchConfig,
16
+ } from "../config/index.ts";
17
+ import { getActiveWatchLoop, type WatchRuntimeStatus } from "../watch/loop.ts";
18
+ import { errorResult, run, textResult, type ToolContext, type ToolResult } from "./shared.ts";
19
+
20
+ interface WatchParams {
21
+ action?: string;
22
+ account?: string;
23
+ tenant?: string;
24
+ intervalSeconds?: number;
25
+ chats?: string[];
26
+ from?: string[];
27
+ mentionOnly?: boolean;
28
+ cooldownSeconds?: number;
29
+ maxTriggersPerHour?: number;
30
+ }
31
+
32
+ /** Renders the effective settings the same way the config file would. */
33
+ function describe(watch: {
34
+ enabled: boolean;
35
+ intervalSeconds: number;
36
+ chats: string[];
37
+ from: string[];
38
+ mentionOnly: boolean;
39
+ cooldownSeconds: number;
40
+ maxTriggersPerHour: number;
41
+ }): string {
42
+ return [
43
+ `- enabled: ${watch.enabled}`,
44
+ `- poll every: ${watch.intervalSeconds} s`,
45
+ `- chats: ${watch.chats.length > 0 ? watch.chats.map((c) => `\`${c}\``).join(", ") : "every chat with recent activity"}`,
46
+ `- people: ${watch.from.length > 0 ? watch.from.map((p) => `\`${p}\``).join(", ") : "any sender"}`,
47
+ `- mentions only: ${watch.mentionOnly}`,
48
+ `- cooldown per chat: ${watch.cooldownSeconds} s`,
49
+ `- wake limit: ${watch.maxTriggersPerHour} per hour`,
50
+ ].join("\n");
51
+ }
52
+
53
+ /**
54
+ * The live facts about a running watcher, for the status action.
55
+ *
56
+ * Distinguishes "configured on" from "actually polling": after an edit to the
57
+ * config file the two can differ until the extension picks it up.
58
+ */
59
+ function runtimeLines(runtime: WatchRuntimeStatus | undefined): string[] {
60
+ if (!runtime) {
61
+ return [
62
+ "Nothing is polling in this session.",
63
+ "",
64
+ "Listen mode is read when a pi session starts. If the config says `enabled: true` and this "
65
+ + "says otherwise, start a new session — or switch it with the `/teams-listen` command.",
66
+ ];
67
+ }
68
+
69
+ const lines = [
70
+ "### Running in this session",
71
+ "",
72
+ `- running: ${runtime.running}`,
73
+ `- chats tracked: ${runtime.trackedChats}`,
74
+ `- woken this hour: ${runtime.wakesThisHour}`,
75
+ ];
76
+ if (runtime.lastTickAt) {
77
+ lines.push(`- last poll: ${new Date(runtime.lastTickAt).toLocaleTimeString()}`);
78
+ }
79
+ if (runtime.lastWakeChat) {
80
+ lines.push(`- last wake: ${runtime.lastWakeChat}`);
81
+ }
82
+ if (runtime.lastError) {
83
+ lines.push(`- ⚠️ last error: ${runtime.lastError}`);
84
+ }
85
+ return lines;
86
+ }
87
+
88
+ export const teamsWatchTool = {
89
+ name: "teams_watch",
90
+ description:
91
+ "Control listen mode: pi polls your Teams chats and turns an incoming message into a prompt it answers. " +
92
+ "Use action 'status' to see the current settings, 'enable' or 'disable' to switch it, and the optional " +
93
+ "fields to narrow what may wake pi (chat patterns, mentions only, interval, cooldown, hourly limit). " +
94
+ "The watcher runs in the extension session; this tool changes the configuration it reads.",
95
+ parameters: Type.Object({
96
+ action: Type.Optional(
97
+ Type.String({ description: "'status' (default), 'enable' or 'disable'" }),
98
+ ),
99
+ account: Type.Optional(
100
+ Type.String({ description: "Account the settings apply to; omit to set them globally" }),
101
+ ),
102
+ tenant: Type.Optional(
103
+ Type.String({
104
+ description:
105
+ "Tenant beneath that account. Used by action 'status' to report the settings in force for it — listen mode itself is configured per account, not per tenant.",
106
+ }),
107
+ ),
108
+ intervalSeconds: Type.Optional(
109
+ Type.Number({ description: "Seconds between polls (minimum 15, default 60)" }),
110
+ ),
111
+ chats: Type.Optional(
112
+ Type.Array(Type.String(), {
113
+ description:
114
+ "Glob patterns for the chats to watch — matched against topic, label, chat ID and participant names",
115
+ }),
116
+ ),
117
+ from: Type.Optional(
118
+ Type.Array(Type.String(), {
119
+ description:
120
+ "Glob patterns for the people pi listens to — matched against display name, UPN and e-mail. Omit for any sender.",
121
+ }),
122
+ ),
123
+ mentionOnly: Type.Optional(
124
+ Type.Boolean({ description: "Only wake pi where the signed-in user is mentioned" }),
125
+ ),
126
+ cooldownSeconds: Type.Optional(
127
+ Type.Number({ description: "Seconds to stay quiet in a chat after waking pi for it (default 300)" }),
128
+ ),
129
+ maxTriggersPerHour: Type.Optional(
130
+ Type.Number({ description: "Hard cap on wakes per hour (default 10)" }),
131
+ ),
132
+ }),
133
+ promptSnippet: "Turn Teams listen mode on or off",
134
+ promptGuidelines: [
135
+ "Use teams_watch when the user wants pi to notice incoming Teams messages on its own.",
136
+ "Tell the user plainly that listen mode spends a model turn per incoming message, and that pi answers in their name.",
137
+ "Enabling listen mode takes effect immediately; the settings survive a restart.",
138
+ ],
139
+
140
+ async execute(
141
+ _toolCallId: string,
142
+ params: WatchParams,
143
+ _signal: AbortSignal | undefined,
144
+ _onUpdate: undefined,
145
+ _ctx: ToolContext,
146
+ ): Promise<ToolResult> {
147
+ return run(async () => {
148
+ const action = (params.action ?? "status").toLowerCase();
149
+
150
+ // Reported from the resolved connection, so the answer shows what is in
151
+ // force for that account and tenant — not just what the file says.
152
+ const inspect = () => {
153
+ try {
154
+ return resolveConnection(params.account, params.tenant).watch;
155
+ } catch {
156
+ return undefined;
157
+ }
158
+ };
159
+
160
+ const patch: WatchConfig = {};
161
+ if (params.intervalSeconds !== undefined) patch.intervalSeconds = params.intervalSeconds;
162
+ if (params.chats !== undefined) patch.chats = params.chats;
163
+ if (params.from !== undefined) patch.from = params.from;
164
+ if (params.mentionOnly !== undefined) patch.mentionOnly = params.mentionOnly;
165
+ if (params.cooldownSeconds !== undefined) patch.cooldownSeconds = params.cooldownSeconds;
166
+ if (params.maxTriggersPerHour !== undefined) {
167
+ patch.maxTriggersPerHour = params.maxTriggersPerHour;
168
+ }
169
+
170
+ switch (action) {
171
+ case "status": {
172
+ const watch = inspect();
173
+ if (!watch) {
174
+ return errorResult(
175
+ `No usable Teams account for these settings. Check ${getConfigPath()}.`,
176
+ );
177
+ }
178
+ const loop = getActiveWatchLoop();
179
+ const runtime = loop?.status();
180
+ return textResult(
181
+ [
182
+ `## Listen mode — ${watch.enabled ? "on" : "off"}`,
183
+ "",
184
+ describe(watch),
185
+ "",
186
+ ...runtimeLines(runtime),
187
+ ].join("\n"),
188
+ { watch, runtime },
189
+ );
190
+ }
191
+
192
+ case "enable": {
193
+ const watch = setWatchConfig({ ...patch, enabled: true }, params.account);
194
+ return textResult(
195
+ [
196
+ "## Listen mode — on",
197
+ "",
198
+ describe(watch),
199
+ "",
200
+ "pi now polls these chats and treats a new message as a prompt. It answers in your name " +
201
+ "and under the safety level in force, so anything it decides to send still follows the " +
202
+ "usual confirmation rules.",
203
+ "",
204
+ `Written to ${getConfigPath()}.`,
205
+ ].join("\n"),
206
+ { watch },
207
+ );
208
+ }
209
+
210
+ case "disable": {
211
+ const watch = setWatchConfig({ ...patch, enabled: false }, params.account);
212
+ return textResult(
213
+ [
214
+ "## Listen mode — off",
215
+ "",
216
+ describe(watch),
217
+ "",
218
+ "Polling stops now; the settings stay in the file for the next time.",
219
+ ].join("\n"),
220
+ { watch },
221
+ );
222
+ }
223
+
224
+ default:
225
+ return errorResult(`Unknown action "${action}". Use 'status', 'enable' or 'disable'.`);
226
+ }
227
+ });
228
+ },
229
+ };
@@ -27,4 +27,5 @@ export const MUTATION_TOOLS = new Set<string>([
27
27
  // Local configuration and credentials
28
28
  "teams_setup",
29
29
  "teams_logout",
30
+ "teams_watch",
30
31
  ]);
@@ -0,0 +1,41 @@
1
+ /**
2
+ * Environment detection.
3
+ *
4
+ * Deliberately dependency-free: both the config layer and the auth layer need
5
+ * to know whether a browser is reachable, and neither should have to import
6
+ * the other to find out.
7
+ */
8
+
9
+ /**
10
+ * Can we realistically put a browser in front of this user?
11
+ *
12
+ * The interactive sign-in needs two things: a browser to open, and a loopback
13
+ * listener that browser can reach. Over SSH both assumptions break — `open`
14
+ * would launch a browser on the *remote* machine, where nobody is looking — so
15
+ * those sessions get the device code flow instead.
16
+ *
17
+ * Set `PI_TEAMS_NO_BROWSER=1` to force that fallback anywhere.
18
+ */
19
+ export function canOpenBrowser(): boolean {
20
+ if (process.env.PI_TEAMS_NO_BROWSER) return false;
21
+
22
+ // A remote shell: the browser would appear on the wrong machine.
23
+ if (process.env.SSH_CONNECTION || process.env.SSH_TTY || process.env.SSH_CLIENT) return false;
24
+
25
+ if (process.platform === "darwin" || process.platform === "win32") return true;
26
+
27
+ // Linux and the BSDs need a display server for a browser to appear at all.
28
+ return !!(process.env.DISPLAY || process.env.WAYLAND_DISPLAY);
29
+ }
30
+
31
+ /** Why the interactive flow is unavailable, phrased for a user-facing message. */
32
+ export function browserUnavailableReason(): string {
33
+ if (process.env.PI_TEAMS_NO_BROWSER) return "PI_TEAMS_NO_BROWSER is set";
34
+ if (process.env.SSH_CONNECTION || process.env.SSH_TTY || process.env.SSH_CLIENT) {
35
+ return "this is an SSH session, so the browser would open on the wrong machine";
36
+ }
37
+ if (process.platform !== "darwin" && process.platform !== "win32") {
38
+ return "no display server is available (DISPLAY and WAYLAND_DISPLAY are unset)";
39
+ }
40
+ return "no browser is available";
41
+ }
@@ -82,18 +82,6 @@ export function htmlToText(content: string, contentType = "html"): string {
82
82
  .trim();
83
83
  }
84
84
 
85
- /** Convert plain text to the minimal HTML Teams expects for a message body. */
86
- export function textToHtml(text: string): string {
87
- const escaped = text
88
- .replace(/&/g, "&amp;")
89
- .replace(/</g, "&lt;")
90
- .replace(/>/g, "&gt;");
91
- return escaped
92
- .split(/\n{2,}/)
93
- .map((para) => `<p>${para.replace(/\n/g, "<br>")}</p>`)
94
- .join("");
95
- }
96
-
97
85
  // ---------------------------------------------------------------------------
98
86
  // Dates
99
87
  // ---------------------------------------------------------------------------
@@ -0,0 +1,150 @@
1
+ /**
2
+ * Markdown-ish text → the HTML subset Microsoft Teams renders in a message.
3
+ *
4
+ * Why this exists: a message body is HTML, but the model writes markdown. Sent
5
+ * raw, `**wichtig**` arrives as literal asterisks and `- Punkt` as literal
6
+ * dashes — which is exactly the "wall of text" a person sees in a chat when
7
+ * nothing was formatted for the medium.
8
+ *
9
+ * Teams supports far less HTML than a browser. This translates only what
10
+ * survives in a chat bubble:
11
+ *
12
+ * `**bold**` → `<b>` `- item` → `<ul><li>`
13
+ * `*italic*` / `_italic_` → `<i>` `1. item` → `<ol><li>`
14
+ * `` `code` `` → `<code>` ``` fences → `<pre>`
15
+ * `~~struck~~` → `<s>` `# Heading` → a bold line
16
+ * `[label](url)` → `<a href>`
17
+ *
18
+ * Tables and images are deliberately unsupported: Teams renders neither in a
19
+ * chat, so pretending otherwise would produce worse output than plain text.
20
+ * Everything else is escaped and kept as written.
21
+ */
22
+
23
+ const HTML_ESCAPES: Record<string, string> = {
24
+ "&": "&amp;",
25
+ "<": "&lt;",
26
+ ">": "&gt;",
27
+ };
28
+
29
+ function escapeHtml(text: string): string {
30
+ return text.replace(/[&<>]/g, (char) => HTML_ESCAPES[char] ?? char);
31
+ }
32
+
33
+ /**
34
+ * Inline markup of a single line.
35
+ *
36
+ * Order matters: code spans are protected first (so `**` inside them stays
37
+ * literal), then links, then emphasis. The underscore rule requires a boundary
38
+ * on both sides so `snake_case_name` survives untouched.
39
+ */
40
+ function inline(raw: string): string {
41
+ let text = escapeHtml(raw);
42
+
43
+ text = text.replace(/`([^`]+)`/g, "<code>$1</code>");
44
+ text = text.replace(
45
+ /\[([^\]]+)\]\((https?:\/\/[^\s)]+|mailto:[^\s)]+)\)/g,
46
+ '<a href="$2">$1</a>',
47
+ );
48
+ text = text.replace(/\*\*([^*]+)\*\*/g, "<b>$1</b>");
49
+ text = text.replace(/(^|[^*\w])\*([^*\n]+)\*/g, "$1<i>$2</i>");
50
+ text = text.replace(/(^|[\s(])_([^_\n]+)_(?=$|[\s).,;:!?])/g, "$1<i>$2</i>");
51
+ text = text.replace(/~~([^~]+)~~/g, "<s>$1</s>");
52
+
53
+ return text;
54
+ }
55
+
56
+ const BULLET = /^\s*[-*+]\s+(.*)$/;
57
+ const NUMBERED = /^\s*\d+[.)]\s+(.*)$/;
58
+ const HEADING = /^\s{0,3}#{1,6}\s+(.*)$/;
59
+
60
+ /**
61
+ * Convert a message body to Teams HTML.
62
+ *
63
+ * Blank lines separate paragraphs, single newlines become `<br>` inside one —
64
+ * which is what a person typing into the chat box gets, and the safest
65
+ * assumption for text that was generated, not composed.
66
+ */
67
+ export function markdownToTeamsHtml(text: string): string {
68
+ const lines = text.replace(/\r\n?/g, "\n").split("\n");
69
+ const blocks: string[] = [];
70
+
71
+ let paragraph: string[] = [];
72
+ let list: { ordered: boolean; items: string[] } | undefined;
73
+ let code: string[] | undefined;
74
+
75
+ const flushParagraph = () => {
76
+ if (paragraph.length === 0) return;
77
+ blocks.push(`<p>${paragraph.map(inline).join("<br>")}</p>`);
78
+ paragraph = [];
79
+ };
80
+
81
+ const flushList = () => {
82
+ if (!list) return;
83
+ const tag = list.ordered ? "ol" : "ul";
84
+ const items = list.items.map((item) => `<li>${inline(item)}</li>`).join("");
85
+ blocks.push(`<${tag}>${items}</${tag}>`);
86
+ list = undefined;
87
+ };
88
+
89
+ const flushBlock = () => {
90
+ flushParagraph();
91
+ flushList();
92
+ };
93
+
94
+ for (const line of lines) {
95
+ // A fence swallows everything until the next one, verbatim.
96
+ if (/^\s*```/.test(line)) {
97
+ if (code) {
98
+ blocks.push(`<pre>${escapeHtml(code.join("\n"))}</pre>`);
99
+ code = undefined;
100
+ } else {
101
+ flushBlock();
102
+ code = [];
103
+ }
104
+ continue;
105
+ }
106
+ if (code) {
107
+ code.push(line);
108
+ continue;
109
+ }
110
+
111
+ if (line.trim() === "") {
112
+ flushBlock();
113
+ continue;
114
+ }
115
+
116
+ const heading = HEADING.exec(line);
117
+ if (heading) {
118
+ flushBlock();
119
+ blocks.push(`<p><b>${inline(heading[1])}</b></p>`);
120
+ continue;
121
+ }
122
+
123
+ const bullet = BULLET.exec(line);
124
+ if (bullet) {
125
+ flushParagraph();
126
+ if (list && list.ordered) flushList();
127
+ list ??= { ordered: false, items: [] };
128
+ list.items.push(bullet[1]);
129
+ continue;
130
+ }
131
+
132
+ const numbered = NUMBERED.exec(line);
133
+ if (numbered) {
134
+ flushParagraph();
135
+ if (list && !list.ordered) flushList();
136
+ list ??= { ordered: true, items: [] };
137
+ list.items.push(numbered[1]);
138
+ continue;
139
+ }
140
+
141
+ flushList();
142
+ paragraph.push(line);
143
+ }
144
+
145
+ // An unterminated fence is still code, not lost text.
146
+ if (code) blocks.push(`<pre>${escapeHtml(code.join("\n"))}</pre>`);
147
+ flushBlock();
148
+
149
+ return blocks.join("");
150
+ }