@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
|
@@ -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;
|
|
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
|
|
16
|
-
|
|
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
|
+
}
|