@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.
@@ -1,6 +1,6 @@
1
1
  ---
2
2
  name: teams-collaboration
3
- description: Microsoft Teams chats, channels, meetings and presence, acting as the signed-in user. Use when the user asks to read, search, summarize, send or reply to Teams messages; to catch up on what they missed; to post in a channel or a thread; to start a chat; to react to a message; to check or set their Teams status; to see who is available; or to schedule, change or cancel a Teams meeting. Also covers multiple Teams accounts and multiple company tenants, and the allow/deny rules that decide what pi may touch.
3
+ description: Microsoft Teams chats, channels, meetings and presence, acting as the signed-in user. Use when the user asks to read, search, summarize, send or reply to Teams messages; to catch up on what they missed; to post in a channel or a thread; to start a chat; to react to a message; to check or set their Teams status; to see who is available; to schedule, change or cancel a Teams meeting; or to make pi notice incoming Teams messages on its own (listen mode). Also covers how to format a message for a chat rather than a document, multiple Teams accounts and multiple company tenants, and the allow/deny rules that decide what pi may touch.
4
4
  ---
5
5
 
6
6
  # Microsoft Teams
@@ -12,8 +12,10 @@ something the user is personally saying.
12
12
  ## Before anything else
13
13
 
14
14
  Run `teams_status` if you are unsure which account is active or whether pi is
15
- signed in. If it reports "not signed in", run `teams_login` — it prints a short
16
- code and a URL that the user must open themselves.
15
+ signed in. If it reports "not signed in", run `teams_login`: it opens the
16
+ user's browser and completes on its own. On a machine with no browser it falls
17
+ back to a device code — then show the code and the URL to the user verbatim,
18
+ because they cannot finish signing in without them.
17
19
 
18
20
  If a tool fails with an authentication, permission or configuration error, run
19
21
  `teams_doctor` and report what it says. Do not retry the failing call.
@@ -89,6 +91,7 @@ checks a target without touching it.
89
91
  | `teams_create_channel` | Adding a channel to a team |
90
92
  | `teams_set_presence` / `teams_set_status_message` | Changing the user's status |
91
93
  | `teams_create_meeting` / `teams_update_meeting` / `teams_cancel_meeting` | Scheduling work |
94
+ | `teams_watch` | Letting pi notice incoming messages on its own |
92
95
 
93
96
  ## How to write as the user
94
97
 
@@ -103,6 +106,48 @@ checks a target without touching it.
103
106
  - Prefer `teams_reply_channel_message` over a new post when answering something
104
107
  — starting a second thread fragments the conversation.
105
108
 
109
+ ## How to format a Teams message
110
+
111
+ A Teams chat is a chat bubble, not a document. What arrives badly is long
112
+ prose: the reader has to hunt for the point, and it reads as if a machine wrote
113
+ it. The body is rendered as HTML — markdown is converted for you, so write
114
+ markdown and let the tool do the rest.
115
+
116
+ **Reach for this shape, not for paragraphs:**
117
+
118
+ ```
119
+ Kurz: die QA-Umgebung läuft wieder.
120
+
121
+ - Ursache: abgelaufenes Zertifikat
122
+ - Fix: erneuert und neu deployt
123
+ - Offen: Monitoring für 90 Tage
124
+ ```
125
+
126
+ - **Lead with the answer.** The first line should work as the whole message for
127
+ someone who reads nothing else.
128
+ - **Three short paragraphs at most.** If it needs more, it is a document — put
129
+ it in a file or a wiki page and link it.
130
+ - **Bullets for anything enumerable** — causes, options, next steps. Three items
131
+ and up belong in a list, not in a sentence with commas.
132
+ - **Bold the one thing that must not be missed**, and only that. Bold used
133
+ everywhere is bold nowhere.
134
+ - **Numbers, names and dates exactly as they are.** Do not round a figure or
135
+ paraphrase a deadline into something friendlier.
136
+ - **One question per message.** Two questions get one answer.
137
+
138
+ **What Teams renders:** `**bold**`, `*italic*`, `` `code` ``, `~~struck~~`,
139
+ `[label](url)`, `- bullets`, `1. numbered`, ``` fences. A `# heading` arrives as
140
+ a bold line — fine for structure, not for hierarchy. **Tables are not supported
141
+ in a chat**: convert them to bullets, or put the table in a file and link it.
142
+
143
+ **Language and tone:** answer in the language of the conversation, in the
144
+ register of the people in it. Short forms are normal in a chat ("passt", "ok,
145
+ 5 min") — but never shorten a fact into ambiguity.
146
+
147
+ When pi listens on its own (see *Listen mode*), this matters most: an answer
148
+ that arrives unrequested must be readable at a glance, because the recipient is
149
+ not expecting it.
150
+
106
151
  ## Channel addressing
107
152
 
108
153
  Channels are addressed as `Team/Channel` or via separate `team` and `channel`
@@ -141,3 +186,46 @@ summarize from openers alone.
141
186
  Before replying anywhere, read the recent messages first. It prevents
142
187
  answering a question that was already answered, and it is what tells you the
143
188
  tone to match.
189
+
190
+ ## Listen mode
191
+
192
+ Listen mode is the only case where pi acts without being asked: it polls the
193
+ user's chats, and an incoming message is turned into a prompt. Off by default,
194
+ and it costs one model turn per message that wakes it.
195
+
196
+ ```yaml
197
+ teams_watch:
198
+ action: enable
199
+ chats: ["Anna*", "Vertrieb*"] # where to listen
200
+ from: ["anna.schmidt@contoso.com"] # who to listen to
201
+ mentionOnly: false
202
+ intervalSeconds: 60
203
+ ```
204
+
205
+ - `chats` decides **where** pi listens (topic, label, chat ID, participants),
206
+ `from` decides **to whom** (name, UPN, e-mail). Narrow both: a watcher that
207
+ watches everything answers everything.
208
+ - `/teams-listen on|off|status` switches it in the session; `teams_watch
209
+ action: status` reports what is configured and what is actually running.
210
+ - The first poll only records what is there — switching it on never answers the
211
+ backlog, only what arrives afterwards.
212
+ - pi never wakes for its own messages, so it cannot answer itself.
213
+ - Listen mode does **not** bypass anything: a reply it decides to send is
214
+ subject to the same safety level and scope rules as a reply you asked for. At
215
+ `safetyLevel: confirm` every one of them is confirmed with the user first.
216
+
217
+ **When a listen prompt arrives**, do this:
218
+
219
+ 1. Judge the message before answering. "Danke, passt" needs no reply; a
220
+ question, a blocker or a decision does.
221
+ 2. If an answer is warranted, send it with `teams_send_chat_message` to the
222
+ **chat ID from the prompt** — never re-resolve the chat by name. pi posts as
223
+ the user, so the wrong chat is a wrong statement in the user's name.
224
+ 3. Keep it to the shape in *How to format a Teams message*: answer first, three
225
+ short paragraphs at most, bullets for lists. An unrequested message has to be
226
+ readable at a glance.
227
+ 4. If no answer is warranted, say so in one line and stop. Do not react, do not
228
+ touch other chats, do not post to a channel.
229
+
230
+ Never let listen mode turn into a conversation with itself: if the incoming
231
+ message is already an answer to something pi sent, the loop ends there.
@@ -0,0 +1,215 @@
1
+ /**
2
+ * MSAL token cache, persisted to disk.
3
+ *
4
+ * MSAL keeps its cache in memory and hands us a serialized blob through the
5
+ * `ICachePlugin` hooks; without persisting it, every pi session would start
6
+ * with a fresh sign-in. One file per account+tenant under
7
+ * `~/.pi/agent/pi-teams-tokens/`, mode 0600 — the blob contains refresh
8
+ * tokens.
9
+ *
10
+ * Writes go through a private temp file and an atomic rename: a truncated
11
+ * cache would silently sign the user out.
12
+ */
13
+
14
+ import {
15
+ existsSync,
16
+ mkdirSync,
17
+ readFileSync,
18
+ writeFileSync,
19
+ chmodSync,
20
+ statSync,
21
+ renameSync,
22
+ unlinkSync,
23
+ readdirSync,
24
+ } from "node:fs";
25
+ import { join } from "node:path";
26
+ import type { ICachePlugin, TokenCacheContext } from "@azure/msal-node";
27
+ import { getAgentDir } from "../config/index.ts";
28
+
29
+ // ---------------------------------------------------------------------------
30
+ // Paths
31
+ // ---------------------------------------------------------------------------
32
+
33
+ export function getCacheDir(): string {
34
+ return join(getAgentDir(), "pi-teams-tokens");
35
+ }
36
+
37
+ /** Filesystem-safe cache key for an account+tenant pair. */
38
+ export function cacheKey(account: string, tenantId: string): string {
39
+ const clean = (value: string) => value.toLowerCase().replace(/[^a-z0-9._-]/g, "_");
40
+ return `${clean(account)}__${clean(tenantId)}`;
41
+ }
42
+
43
+ export function getCachePath(account: string, tenantId: string): string {
44
+ return join(getCacheDir(), `${cacheKey(account, tenantId)}.json`);
45
+ }
46
+
47
+ // ---------------------------------------------------------------------------
48
+ // I/O
49
+ // ---------------------------------------------------------------------------
50
+
51
+ function readBlob(path: string): string | undefined {
52
+ if (!existsSync(path)) return undefined;
53
+ try {
54
+ // Older files, or a cache copied between machines, may be too permissive
55
+ // for something holding refresh tokens.
56
+ try {
57
+ const mode = statSync(path).mode & 0o777;
58
+ if (mode !== 0o600) chmodSync(path, 0o600);
59
+ } catch {
60
+ /* ignore */
61
+ }
62
+ return readFileSync(path, "utf-8");
63
+ } catch {
64
+ return undefined;
65
+ }
66
+ }
67
+
68
+ function writeBlob(path: string, blob: string): void {
69
+ const dir = getCacheDir();
70
+ if (!existsSync(dir)) mkdirSync(dir, { recursive: true, mode: 0o700 });
71
+
72
+ const tmpPath = `${path}.${process.pid}.tmp`;
73
+ try {
74
+ writeFileSync(tmpPath, blob, { encoding: "utf-8", mode: 0o600 });
75
+ chmodSync(tmpPath, 0o600);
76
+ renameSync(tmpPath, path);
77
+ } catch {
78
+ try {
79
+ unlinkSync(tmpPath);
80
+ } catch {
81
+ /* ignore */
82
+ }
83
+ // A cache that cannot be written is a degraded session, not a failed one:
84
+ // the tokens still work until the process exits.
85
+ }
86
+ }
87
+
88
+ // ---------------------------------------------------------------------------
89
+ // Plugin
90
+ // ---------------------------------------------------------------------------
91
+
92
+ /** Build the cache plugin for one account+tenant. */
93
+ export function createCachePlugin(account: string, tenantId: string): ICachePlugin {
94
+ const path = getCachePath(account, tenantId);
95
+
96
+ return {
97
+ async beforeCacheAccess(context: TokenCacheContext): Promise<void> {
98
+ const blob = readBlob(path);
99
+ if (blob) context.tokenCache.deserialize(blob);
100
+ },
101
+ async afterCacheAccess(context: TokenCacheContext): Promise<void> {
102
+ if (context.cacheHasChanged) writeBlob(path, context.tokenCache.serialize());
103
+ },
104
+ };
105
+ }
106
+
107
+ // ---------------------------------------------------------------------------
108
+ // Inspection
109
+ // ---------------------------------------------------------------------------
110
+
111
+ /** What the status surfaces need to know, readable without MSAL or a network call. */
112
+ export interface CacheSummary {
113
+ /** A cache file exists and holds an account */
114
+ present: boolean;
115
+ /** Signed-in identity, when the cache holds one */
116
+ username?: string;
117
+ name?: string;
118
+ /** Epoch milliseconds at which the newest access token expires */
119
+ expiresAt?: number;
120
+ /** Whether that access token is still valid */
121
+ fresh: boolean;
122
+ }
123
+
124
+ /* eslint-disable @typescript-eslint/no-explicit-any */
125
+
126
+ /**
127
+ * Read the identity and expiry out of a cache file.
128
+ *
129
+ * The blob is MSAL's own schema, so this reads it defensively: a shape change
130
+ * upstream must degrade to "not signed in" in the status line, never throw in
131
+ * the middle of rendering the TUI.
132
+ */
133
+ export function readCacheSummary(account: string, tenantId: string): CacheSummary {
134
+ const blob = readBlob(getCachePath(account, tenantId));
135
+ if (!blob) return { present: false, fresh: false };
136
+
137
+ try {
138
+ const parsed = JSON.parse(blob) as Record<string, any>;
139
+
140
+ const accounts = Object.values(parsed.Account ?? {}) as any[];
141
+ const first = accounts[0];
142
+
143
+ const tokens = Object.values(parsed.AccessToken ?? {}) as any[];
144
+ // expires_on is seconds-since-epoch, as a string, in MSAL's schema.
145
+ const expiries = tokens
146
+ .map((token) => Number(token?.expires_on))
147
+ .filter((value) => Number.isFinite(value) && value > 0);
148
+ const expiresAt = expiries.length > 0 ? Math.max(...expiries) * 1000 : undefined;
149
+
150
+ return {
151
+ present: accounts.length > 0 || tokens.length > 0,
152
+ username: first?.username,
153
+ name: first?.name,
154
+ expiresAt,
155
+ fresh: expiresAt !== undefined && expiresAt - 5 * 60 * 1000 > Date.now(),
156
+ };
157
+ } catch {
158
+ return { present: false, fresh: false };
159
+ }
160
+ }
161
+
162
+ /** Delete one cached session. Returns true when a file was removed. */
163
+ export function removeCache(account: string, tenantId: string): boolean {
164
+ const path = getCachePath(account, tenantId);
165
+ if (!existsSync(path)) return false;
166
+ try {
167
+ unlinkSync(path);
168
+ return true;
169
+ } catch {
170
+ return false;
171
+ }
172
+ }
173
+
174
+ /** Delete every cached session. Returns how many were removed. */
175
+ export function removeAllCaches(): number {
176
+ const dir = getCacheDir();
177
+ if (!existsSync(dir)) return 0;
178
+
179
+ let removed = 0;
180
+ for (const entry of readdirSync(dir)) {
181
+ if (!entry.endsWith(".json")) continue;
182
+ try {
183
+ unlinkSync(join(dir, entry));
184
+ removed += 1;
185
+ } catch {
186
+ /* skip what we cannot remove */
187
+ }
188
+ }
189
+ return removed;
190
+ }
191
+
192
+ /** Every cached session on disk, for `teams_status` and the doctor. */
193
+ export function listCachedSessions(): Array<{ key: string; summary: CacheSummary }> {
194
+ const dir = getCacheDir();
195
+ if (!existsSync(dir)) return [];
196
+
197
+ const sessions: Array<{ key: string; summary: CacheSummary }> = [];
198
+ for (const entry of readdirSync(dir)) {
199
+ if (!entry.endsWith(".json")) continue;
200
+ const key = entry.replace(/\.json$/, "");
201
+ const blob = readBlob(join(dir, entry));
202
+ if (!blob) continue;
203
+ try {
204
+ const parsed = JSON.parse(blob) as Record<string, any>;
205
+ const first = (Object.values(parsed.Account ?? {}) as any[])[0];
206
+ sessions.push({
207
+ key,
208
+ summary: { present: true, username: first?.username, name: first?.name, fresh: false },
209
+ });
210
+ } catch {
211
+ /* ignore an unreadable cache file */
212
+ }
213
+ }
214
+ return sessions;
215
+ }