@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/status.ts
CHANGED
|
@@ -6,7 +6,7 @@
|
|
|
6
6
|
*/
|
|
7
7
|
|
|
8
8
|
import type { TeamsConnection } from "./config/index.ts";
|
|
9
|
-
import {
|
|
9
|
+
import { readCacheSummary } from "./auth/cache-plugin.ts";
|
|
10
10
|
import { formatPermissions } from "./config/scope.ts";
|
|
11
11
|
|
|
12
12
|
/** Immutable snapshot of the active Teams connection. */
|
|
@@ -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(
|
|
@@ -34,7 +38,7 @@ export function buildConnectionCard(
|
|
|
34
38
|
): ConnectionCard | undefined {
|
|
35
39
|
if (!conn) return undefined;
|
|
36
40
|
|
|
37
|
-
const
|
|
41
|
+
const cache = readCacheSummary(conn.account, conn.tenantId);
|
|
38
42
|
|
|
39
43
|
return {
|
|
40
44
|
account: conn.account,
|
|
@@ -43,11 +47,26 @@ export function buildConnectionCard(
|
|
|
43
47
|
tenantId: conn.tenantId,
|
|
44
48
|
authMode: conn.authMode,
|
|
45
49
|
safetyLevel: conn.safetyLevel,
|
|
46
|
-
user:
|
|
47
|
-
|
|
48
|
-
|
|
50
|
+
user: cache.username ?? cache.name,
|
|
51
|
+
// A cached session counts as signed in even when the access token has
|
|
52
|
+
// expired: MSAL renews it silently on the next call.
|
|
53
|
+
signedIn: cache.present,
|
|
54
|
+
expiresAt: cache.expiresAt ? new Date(cache.expiresAt).toISOString() : undefined,
|
|
49
55
|
otherAccounts: conn.allAccounts.map((a) => a.name).filter((name) => name !== conn.account),
|
|
50
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,
|
|
51
70
|
};
|
|
52
71
|
}
|
|
53
72
|
|
|
@@ -57,7 +76,8 @@ export function buildConnectionLabel(card: ConnectionCard | undefined): string {
|
|
|
57
76
|
if (!card.signedIn) return `Teams · ${card.account} · not signed in`;
|
|
58
77
|
|
|
59
78
|
const scope = card.tenant === card.account ? card.account : `${card.account}/${card.tenant}`;
|
|
60
|
-
|
|
79
|
+
const listening = card.watching ? " · 👂 listening" : "";
|
|
80
|
+
return `✓ Teams · ${scope}${card.user ? ` · ${card.user}` : ""}${listening}`;
|
|
61
81
|
}
|
|
62
82
|
|
|
63
83
|
/** Rich markdown for `/teams-status` and the expanded card. */
|
|
@@ -78,12 +98,27 @@ export function formatStatusText(card: ConnectionCard | undefined): string {
|
|
|
78
98
|
if (card.tenant !== card.account) lines.push(`- **Tenant:** ${card.tenant}`);
|
|
79
99
|
lines.push(`- **Tenant ID:** ${card.tenantId}`);
|
|
80
100
|
lines.push(`- **Acting as:** ${card.signedIn ? (card.user ?? "(signed in)") : "not signed in — run teams_login"}`);
|
|
81
|
-
lines.push(
|
|
101
|
+
lines.push(
|
|
102
|
+
`- **Auth mode:** ${card.authMode}${
|
|
103
|
+
card.authMode === "client-credentials"
|
|
104
|
+
? " (app-only — cannot post as a person)"
|
|
105
|
+
: card.authMode === "interactive"
|
|
106
|
+
? " (browser sign-in)"
|
|
107
|
+
: " (device code)"
|
|
108
|
+
}`,
|
|
109
|
+
);
|
|
82
110
|
lines.push(`- **Safety level:** ${card.safetyLevel}`);
|
|
83
|
-
if (card.expiresAt)
|
|
111
|
+
if (card.expiresAt) {
|
|
112
|
+
lines.push(
|
|
113
|
+
`- **Access token expires:** ${new Date(card.expiresAt).toLocaleString()} (renewed silently)`,
|
|
114
|
+
);
|
|
115
|
+
}
|
|
84
116
|
if (card.otherAccounts.length > 0) {
|
|
85
117
|
lines.push(`- **Other accounts:** ${card.otherAccounts.join(", ")}`);
|
|
86
118
|
}
|
|
119
|
+
lines.push(
|
|
120
|
+
`- **Listen mode:** ${card.watching ? `on — ${card.watchSummary ?? "running"}` : "off"}`,
|
|
121
|
+
);
|
|
87
122
|
|
|
88
123
|
lines.push("", "### What pi may do", "", card.permissionSummary);
|
|
89
124
|
return lines.join("\n");
|
|
@@ -12,7 +12,8 @@ import {
|
|
|
12
12
|
setSafetyLevel,
|
|
13
13
|
validateSafetyLevel,
|
|
14
14
|
} from "../config/index.ts";
|
|
15
|
-
import {
|
|
15
|
+
import { readCacheSummary, removeCache } from "../auth/cache-plugin.ts";
|
|
16
|
+
import { resetApps } from "../auth/index.ts";
|
|
16
17
|
import { clearUserCache, errorResult, run, textResult, type ToolResult } from "./shared.ts";
|
|
17
18
|
|
|
18
19
|
interface AccountsParams {
|
|
@@ -69,12 +70,12 @@ export const teamsAccountsTool = {
|
|
|
69
70
|
const lines = ["## Configured Teams accounts", ""];
|
|
70
71
|
for (const account of config.accounts) {
|
|
71
72
|
const isDefault = config.defaultAccount === account.name;
|
|
72
|
-
const
|
|
73
|
-
const state =
|
|
74
|
-
? "signed in"
|
|
75
|
-
:
|
|
76
|
-
? "
|
|
77
|
-
: "
|
|
73
|
+
const cache = readCacheSummary(account.name, account.tenantId);
|
|
74
|
+
const state = !cache.present
|
|
75
|
+
? "not signed in"
|
|
76
|
+
: cache.fresh
|
|
77
|
+
? "signed in"
|
|
78
|
+
: "session saved";
|
|
78
79
|
|
|
79
80
|
lines.push(
|
|
80
81
|
`### ${account.displayName ?? account.name}${isDefault ? " (default)" : ""}`,
|
|
@@ -83,16 +84,16 @@ export const teamsAccountsTool = {
|
|
|
83
84
|
`- tenant: ${account.tenantId}`,
|
|
84
85
|
`- auth mode: ${account.authMode ?? "device-code"}`,
|
|
85
86
|
`- safety: ${account.safetyLevel ?? config.safetyLevel ?? "confirm"}`,
|
|
86
|
-
`- status: ${state}${
|
|
87
|
+
`- status: ${state}${cache.username ? ` as ${cache.username}` : ""}`,
|
|
87
88
|
);
|
|
88
89
|
|
|
89
90
|
for (const tenant of account.tenants ?? []) {
|
|
90
|
-
const
|
|
91
|
-
const tenantState =
|
|
92
|
-
? "signed in"
|
|
93
|
-
:
|
|
94
|
-
? "
|
|
95
|
-
: "
|
|
91
|
+
const tenantCache = readCacheSummary(account.name, tenant.tenantId);
|
|
92
|
+
const tenantState = !tenantCache.present
|
|
93
|
+
? "not signed in"
|
|
94
|
+
: tenantCache.fresh
|
|
95
|
+
? "signed in"
|
|
96
|
+
: "session saved";
|
|
96
97
|
lines.push(
|
|
97
98
|
` - tenant \`${tenant.name}\` (${tenant.tenantId}) — ${tenantState}` +
|
|
98
99
|
`${tenant.safetyLevel ? `, safety: ${tenant.safetyLevel}` : ""}`,
|
|
@@ -125,7 +126,10 @@ export const teamsAccountsTool = {
|
|
|
125
126
|
.find((a) => a.name === params.account)
|
|
126
127
|
?.tenants?.find((t) => t.name === params.tenant);
|
|
127
128
|
const removed = removeTenant(params.account, params.tenant);
|
|
128
|
-
if (removed && tenantConfig)
|
|
129
|
+
if (removed && tenantConfig) {
|
|
130
|
+
removeCache(params.account, tenantConfig.tenantId);
|
|
131
|
+
resetApps();
|
|
132
|
+
}
|
|
129
133
|
return removed
|
|
130
134
|
? textResult(`✅ Removed tenant "${params.tenant}" from account "${params.account}".`, {})
|
|
131
135
|
: errorResult(`Tenant "${params.tenant}" not found in account "${params.account}".`);
|
|
@@ -134,10 +138,11 @@ export const teamsAccountsTool = {
|
|
|
134
138
|
const accountConfig = config.accounts.find((a) => a.name === params.account);
|
|
135
139
|
const removed = removeAccount(params.account);
|
|
136
140
|
if (removed && accountConfig) {
|
|
137
|
-
|
|
141
|
+
removeCache(params.account, accountConfig.tenantId);
|
|
138
142
|
for (const tenant of accountConfig.tenants ?? []) {
|
|
139
|
-
|
|
143
|
+
removeCache(params.account, tenant.tenantId);
|
|
140
144
|
}
|
|
145
|
+
resetApps();
|
|
141
146
|
}
|
|
142
147
|
clearUserCache();
|
|
143
148
|
return removed
|
|
@@ -13,9 +13,9 @@ import {
|
|
|
13
13
|
DEFAULT_SCOPES,
|
|
14
14
|
type TeamsConnection,
|
|
15
15
|
} from "../config/index.ts";
|
|
16
|
-
import {
|
|
16
|
+
import { getCacheDir, readCacheSummary } from "../auth/cache-plugin.ts";
|
|
17
17
|
import { tokenScopes } from "../auth/jwt.ts";
|
|
18
|
-
import { getAccessToken } from "../auth/index.ts";
|
|
18
|
+
import { browserUnavailableReason, canOpenBrowser, getAccessToken } from "../auth/index.ts";
|
|
19
19
|
import { getMe } from "../graph/me.ts";
|
|
20
20
|
import { listJoinedTeams } from "../graph/teams.ts";
|
|
21
21
|
import { formatGraphError, describeScope } from "../utils/errors.ts";
|
|
@@ -47,28 +47,46 @@ async function checkConnection(
|
|
|
47
47
|
"- ⚠️ app-only: pi cannot post messages as a person with this mode " +
|
|
48
48
|
"(Microsoft Graph restricts app-only message writes to migration scenarios)",
|
|
49
49
|
);
|
|
50
|
+
} else if (conn.authMode === "interactive" && !canOpenBrowser()) {
|
|
51
|
+
lines.push(
|
|
52
|
+
`- ⚠️ browser sign-in is configured but unavailable here (${browserUnavailableReason()}); ` +
|
|
53
|
+
"`teams_login` will use the device code flow",
|
|
54
|
+
);
|
|
50
55
|
}
|
|
51
56
|
|
|
52
|
-
const
|
|
53
|
-
if (!
|
|
57
|
+
const cache = readCacheSummary(conn.account, conn.tenantId);
|
|
58
|
+
if (!cache.present) {
|
|
54
59
|
lines.push("- ❌ not signed in — run `teams_login`");
|
|
55
60
|
return lines;
|
|
56
61
|
}
|
|
57
62
|
|
|
58
63
|
lines.push(
|
|
59
|
-
|
|
60
|
-
? `- ✅ token valid until ${new Date(
|
|
61
|
-
: token
|
|
62
|
-
? "- ⚠️ access token expired, refresh token present (will renew on the next call)"
|
|
63
|
-
: "- ❌ token expired and no refresh token — run `teams_login`",
|
|
64
|
+
cache.fresh
|
|
65
|
+
? `- ✅ access token valid until ${new Date(cache.expiresAt!).toLocaleString()}`
|
|
66
|
+
: "- ⚠️ access token expired; MSAL renews it silently on the next call",
|
|
64
67
|
);
|
|
68
|
+
if (cache.username) lines.push(`- cached identity: ${cache.username}`);
|
|
69
|
+
|
|
70
|
+
if (offline) {
|
|
71
|
+
lines.push("- _offline check: scopes and connectivity not verified_");
|
|
72
|
+
return lines;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
// Everything below needs a live token. Acquiring it also proves that silent
|
|
76
|
+
// renewal works, which is the failure users actually hit.
|
|
77
|
+
let accessToken: string;
|
|
78
|
+
try {
|
|
79
|
+
const token = await getAccessToken(conn, signal);
|
|
80
|
+
accessToken = token.accessToken;
|
|
81
|
+
} catch (err) {
|
|
82
|
+
lines.push(`- ❌ could not obtain a token: ${formatGraphError(err)}`);
|
|
83
|
+
return lines;
|
|
84
|
+
}
|
|
65
85
|
|
|
66
|
-
//
|
|
67
|
-
const granted = tokenScopes(
|
|
86
|
+
// The granted scopes are inside the token, so this needs no extra call.
|
|
87
|
+
const granted = tokenScopes(accessToken);
|
|
68
88
|
if (granted.length > 0) {
|
|
69
|
-
const wanted =
|
|
70
|
-
(scope) => !["openid", "profile", "offline_access"].includes(scope),
|
|
71
|
-
);
|
|
89
|
+
const wanted = conn.scopes.length > 0 ? conn.scopes : DEFAULT_SCOPES;
|
|
72
90
|
const missing = wanted.filter(
|
|
73
91
|
(scope) => !granted.some((g) => g.toLowerCase() === scope.toLowerCase()),
|
|
74
92
|
);
|
|
@@ -86,15 +104,6 @@ async function checkConnection(
|
|
|
86
104
|
}
|
|
87
105
|
}
|
|
88
106
|
|
|
89
|
-
if (offline) return lines;
|
|
90
|
-
|
|
91
|
-
try {
|
|
92
|
-
await getAccessToken(conn, signal);
|
|
93
|
-
} catch (err) {
|
|
94
|
-
lines.push(`- ❌ could not obtain a token: ${formatGraphError(err)}`);
|
|
95
|
-
return lines;
|
|
96
|
-
}
|
|
97
|
-
|
|
98
107
|
try {
|
|
99
108
|
const me = await getMe(conn, signal);
|
|
100
109
|
lines.push(`- ✅ acting as **${me.displayName}** <${me.upn}>`);
|
|
@@ -146,8 +155,11 @@ export const teamsDoctorTool = {
|
|
|
146
155
|
|
|
147
156
|
const lines = ["## Teams doctor", ""];
|
|
148
157
|
lines.push(`- config: \`${getConfigPath()}\``);
|
|
149
|
-
lines.push(`- tokens: \`${
|
|
158
|
+
lines.push(`- tokens: \`${getCacheDir()}/\``);
|
|
150
159
|
lines.push(`- audit log: \`${getAuditPath()}\``);
|
|
160
|
+
lines.push(
|
|
161
|
+
`- browser sign-in: ${canOpenBrowser() ? "available" : `unavailable — ${browserUnavailableReason()}`}`,
|
|
162
|
+
);
|
|
151
163
|
lines.push("");
|
|
152
164
|
|
|
153
165
|
if (errors.length > 0) {
|
package/src/tools/teams-login.ts
CHANGED
|
@@ -1,17 +1,25 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* teams_login — sign in as the user
|
|
2
|
+
* teams_login — sign in as the user.
|
|
3
|
+
* teams_logout — forget a saved session.
|
|
3
4
|
*
|
|
4
|
-
* The
|
|
5
|
-
*
|
|
6
|
-
*
|
|
7
|
-
*
|
|
5
|
+
* The browser flow is the default: pi opens the Microsoft sign-in page, the
|
|
6
|
+
* user signs in with their own credentials and MFA, and MSAL catches the
|
|
7
|
+
* redirect on a loopback port. Where no browser can be reached — SSH, a
|
|
8
|
+
* container, a headless box — it falls back to the device code flow and says
|
|
9
|
+
* why.
|
|
8
10
|
*/
|
|
9
11
|
|
|
10
12
|
import { Type } from "typebox";
|
|
11
|
-
import {
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
13
|
+
import {
|
|
14
|
+
browserUnavailableReason,
|
|
15
|
+
canOpenBrowser,
|
|
16
|
+
defaultSignInMode,
|
|
17
|
+
signIn,
|
|
18
|
+
signOut,
|
|
19
|
+
signOutAll,
|
|
20
|
+
type DeviceCodeInfo,
|
|
21
|
+
type SignInMode,
|
|
22
|
+
} from "../auth/index.ts";
|
|
15
23
|
import { tokenScopes } from "../auth/jwt.ts";
|
|
16
24
|
import { getMe } from "../graph/me.ts";
|
|
17
25
|
import { formatGraphError } from "../utils/errors.ts";
|
|
@@ -31,30 +39,64 @@ import {
|
|
|
31
39
|
interface LoginParams {
|
|
32
40
|
account?: string;
|
|
33
41
|
tenant?: string;
|
|
42
|
+
mode?: string;
|
|
43
|
+
port?: number;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
type Update = (update: { content: Array<{ type: "text"; text: string }> }) => void;
|
|
47
|
+
|
|
48
|
+
/** What the user has to do, rendered the moment MSAL tells us. */
|
|
49
|
+
function deviceCodeInstructions(label: string, info: DeviceCodeInfo, why?: string): string {
|
|
50
|
+
return [
|
|
51
|
+
`## Sign in to Teams (${label})`,
|
|
52
|
+
"",
|
|
53
|
+
why ? `_Using the device code flow because ${why}._` : "",
|
|
54
|
+
"",
|
|
55
|
+
`1. Open **${info.verificationUri}**`,
|
|
56
|
+
`2. Enter the code: **${info.userCode}**`,
|
|
57
|
+
"3. Sign in with the account pi should act as.",
|
|
58
|
+
"",
|
|
59
|
+
`The code expires in ${Math.round(info.expiresIn / 60)} minutes. Waiting…`,
|
|
60
|
+
]
|
|
61
|
+
.filter((line) => line !== "")
|
|
62
|
+
.join("\n");
|
|
34
63
|
}
|
|
35
64
|
|
|
36
65
|
export const teamsLoginTool = {
|
|
37
66
|
name: "teams_login",
|
|
38
67
|
description:
|
|
39
|
-
"Sign in to Microsoft Teams as yourself
|
|
40
|
-
"
|
|
41
|
-
"
|
|
42
|
-
"
|
|
68
|
+
"Sign in to Microsoft Teams as yourself. By default this opens your browser at the Microsoft sign-in " +
|
|
69
|
+
"page and completes automatically — nothing to type. On a machine with no browser (SSH, a container) " +
|
|
70
|
+
"it falls back to the device code flow and shows a short code to enter on another device. " +
|
|
71
|
+
"The session is saved and renewed silently, so this is normally needed once per account. " +
|
|
72
|
+
"Use 'account' and 'tenant' to sign in to a second company or a guest tenant.",
|
|
43
73
|
parameters: Type.Object({
|
|
44
74
|
account: AccountParam,
|
|
45
75
|
tenant: TenantParam,
|
|
76
|
+
mode: Type.Optional(
|
|
77
|
+
Type.String({
|
|
78
|
+
description:
|
|
79
|
+
"Force a flow: 'interactive' (browser) or 'device-code'. Omit to let pi choose for this machine.",
|
|
80
|
+
}),
|
|
81
|
+
),
|
|
82
|
+
port: Type.Optional(
|
|
83
|
+
Type.Number({
|
|
84
|
+
description:
|
|
85
|
+
"Fixed loopback port for the browser redirect. Only needed when the app registration lists an exact redirect URI such as http://localhost:3000.",
|
|
86
|
+
}),
|
|
87
|
+
),
|
|
46
88
|
}),
|
|
47
89
|
promptSnippet: "Sign in to Microsoft Teams as the user",
|
|
48
90
|
promptGuidelines: [
|
|
49
|
-
"Use teams_login when a Teams tool reports that pi is not signed in, or when the user wants to add a second account.",
|
|
50
|
-
"
|
|
91
|
+
"Use teams_login when a Teams tool reports that pi is not signed in, or when the user wants to add a second account or tenant.",
|
|
92
|
+
"If the device code flow is used, show the user code and URL to the user verbatim — they cannot finish signing in without them.",
|
|
51
93
|
],
|
|
52
94
|
|
|
53
95
|
async execute(
|
|
54
96
|
_toolCallId: string,
|
|
55
97
|
params: LoginParams,
|
|
56
98
|
signal: AbortSignal | undefined,
|
|
57
|
-
onUpdate:
|
|
99
|
+
onUpdate: Update | undefined,
|
|
58
100
|
ctx: ToolContext,
|
|
59
101
|
): Promise<ToolResult> {
|
|
60
102
|
return run(async () => {
|
|
@@ -63,8 +105,7 @@ export const teamsLoginTool = {
|
|
|
63
105
|
|
|
64
106
|
// App-only needs no user interaction at all.
|
|
65
107
|
if (conn.authMode === "client-credentials") {
|
|
66
|
-
const token = await
|
|
67
|
-
storeToken(conn, token);
|
|
108
|
+
const token = await signIn(conn, { signal });
|
|
68
109
|
return textResult(
|
|
69
110
|
[
|
|
70
111
|
`✅ Acquired an app-only token for ${label}.`,
|
|
@@ -72,50 +113,60 @@ export const teamsLoginTool = {
|
|
|
72
113
|
`Granted app roles: ${tokenScopes(token.accessToken).join(", ") || "(none visible in the token)"}`,
|
|
73
114
|
"",
|
|
74
115
|
"Note: app-only tokens cannot post messages as a person. Switch this account to " +
|
|
75
|
-
'authMode "
|
|
116
|
+
'authMode "interactive" if pi should act as you.',
|
|
76
117
|
].join("\n"),
|
|
77
118
|
{ account: conn.account, tenant: conn.tenant, authMode: "client-credentials" },
|
|
78
119
|
);
|
|
79
120
|
}
|
|
80
121
|
|
|
81
|
-
const
|
|
122
|
+
const requested = params.mode?.toLowerCase();
|
|
123
|
+
if (requested && requested !== "interactive" && requested !== "device-code") {
|
|
124
|
+
return errorResult(`mode must be 'interactive' or 'device-code', not "${params.mode}".`);
|
|
125
|
+
}
|
|
82
126
|
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
"",
|
|
86
|
-
`1. Open **${challenge.verificationUri}**`,
|
|
87
|
-
`2. Enter the code: **${challenge.userCode}**`,
|
|
88
|
-
"3. Sign in with the account pi should act as.",
|
|
89
|
-
"",
|
|
90
|
-
`The code expires at ${new Date(challenge.expiresAt).toLocaleTimeString()}. Waiting…`,
|
|
91
|
-
].join("\n");
|
|
127
|
+
let mode: SignInMode = (requested as SignInMode | undefined) ?? defaultSignInMode();
|
|
128
|
+
let fallbackReason: string | undefined;
|
|
92
129
|
|
|
93
|
-
|
|
130
|
+
// An explicit request for the browser on a machine that has none would
|
|
131
|
+
// hang until the timeout; say so and use the code flow instead.
|
|
132
|
+
if (mode === "interactive" && !canOpenBrowser()) {
|
|
133
|
+
fallbackReason = browserUnavailableReason();
|
|
134
|
+
mode = "device-code";
|
|
135
|
+
} else if (mode === "device-code" && !requested && !canOpenBrowser()) {
|
|
136
|
+
fallbackReason = browserUnavailableReason();
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
if (mode === "interactive") {
|
|
140
|
+
onUpdate?.({
|
|
141
|
+
content: [
|
|
142
|
+
{
|
|
143
|
+
type: "text",
|
|
144
|
+
text: [
|
|
145
|
+
`## Signing in to Teams (${label})`,
|
|
146
|
+
"",
|
|
147
|
+
"Opening your browser. Sign in there with the account pi should act as —",
|
|
148
|
+
"the page closes itself when you are done.",
|
|
149
|
+
].join("\n"),
|
|
150
|
+
},
|
|
151
|
+
],
|
|
152
|
+
});
|
|
153
|
+
}
|
|
94
154
|
|
|
95
|
-
|
|
96
|
-
|
|
155
|
+
const result = await signIn(conn, {
|
|
156
|
+
mode,
|
|
97
157
|
signal,
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
content: [
|
|
104
|
-
{
|
|
105
|
-
type: "text",
|
|
106
|
-
text: `${instructions}\n\nStill waiting for sign-in… (${secondsLeft}s left)`,
|
|
107
|
-
},
|
|
108
|
-
],
|
|
109
|
-
});
|
|
110
|
-
}
|
|
158
|
+
loopbackPort: params.port ?? conn.loopbackPort,
|
|
159
|
+
onDeviceCode: (info) => {
|
|
160
|
+
onUpdate?.({
|
|
161
|
+
content: [{ type: "text", text: deviceCodeInstructions(label, info, fallbackReason) }],
|
|
162
|
+
});
|
|
111
163
|
},
|
|
112
164
|
});
|
|
113
165
|
|
|
114
|
-
storeToken(conn, token);
|
|
115
166
|
clearUserCache();
|
|
116
167
|
|
|
117
|
-
// Confirm the token actually works and report who pi now is.
|
|
118
|
-
let identity =
|
|
168
|
+
// Confirm the token actually works, and report who pi now is.
|
|
169
|
+
let identity = result.account?.username ?? result.account?.name ?? "(unknown)";
|
|
119
170
|
let jobTitle: string | undefined;
|
|
120
171
|
try {
|
|
121
172
|
const me = await getMe(conn, signal);
|
|
@@ -132,16 +183,17 @@ export const teamsLoginTool = {
|
|
|
132
183
|
);
|
|
133
184
|
}
|
|
134
185
|
|
|
135
|
-
const granted = tokenScopes(
|
|
136
|
-
const
|
|
137
|
-
|
|
138
|
-
(scope) => !granted.some((g) => g.toLowerCase() === scope.toLowerCase()),
|
|
186
|
+
const granted = tokenScopes(result.accessToken);
|
|
187
|
+
const missing = conn.scopes.filter(
|
|
188
|
+
(scope) => !granted.some((entry) => entry.toLowerCase() === scope.toLowerCase()),
|
|
139
189
|
);
|
|
140
190
|
|
|
141
191
|
return textResult(
|
|
142
192
|
[
|
|
143
193
|
`✅ Signed in to ${label} as **${identity}**${jobTitle ? ` (${jobTitle})` : ""}.`,
|
|
144
194
|
"",
|
|
195
|
+
`Flow: ${result.mode === "interactive" ? "browser sign-in" : "device code"}` +
|
|
196
|
+
`${fallbackReason ? ` (${fallbackReason})` : ""}`,
|
|
145
197
|
`pi now acts as this user in Teams. Safety level: **${conn.safetyLevel}**.`,
|
|
146
198
|
missing.length > 0
|
|
147
199
|
? `\n⚠️ Not granted: ${missing.join(", ")}\nSome tools will fail until an admin consents to these scopes.`
|
|
@@ -151,6 +203,7 @@ export const teamsLoginTool = {
|
|
|
151
203
|
account: conn.account,
|
|
152
204
|
tenant: conn.tenant,
|
|
153
205
|
user: identity,
|
|
206
|
+
mode: result.mode,
|
|
154
207
|
grantedScopes: granted,
|
|
155
208
|
missingScopes: missing,
|
|
156
209
|
},
|
|
@@ -162,12 +215,12 @@ export const teamsLoginTool = {
|
|
|
162
215
|
export const teamsLogoutTool = {
|
|
163
216
|
name: "teams_logout",
|
|
164
217
|
description:
|
|
165
|
-
"Sign out of a Teams account: removes the saved
|
|
166
|
-
"
|
|
218
|
+
"Sign out of a Teams account: removes the saved session from ~/.pi/agent/pi-teams-tokens/. " +
|
|
219
|
+
"The account stays configured, so teams_login can sign in again.",
|
|
167
220
|
parameters: Type.Object({
|
|
168
221
|
account: AccountParam,
|
|
169
222
|
tenant: TenantParam,
|
|
170
|
-
all: Type.Optional(Type.Boolean({ description: "Remove the
|
|
223
|
+
all: Type.Optional(Type.Boolean({ description: "Remove the saved sessions of every account" })),
|
|
171
224
|
}),
|
|
172
225
|
promptSnippet: "Sign out of a Teams account",
|
|
173
226
|
|
|
@@ -182,14 +235,12 @@ export const teamsLogoutTool = {
|
|
|
182
235
|
clearUserCache();
|
|
183
236
|
|
|
184
237
|
if (params.all) {
|
|
185
|
-
const count =
|
|
186
|
-
|
|
187
|
-
return textResult(`✅ Removed ${count} cached Teams session(s).`, { removed: count });
|
|
238
|
+
const count = signOutAll();
|
|
239
|
+
return textResult(`✅ Removed ${count} saved Teams session(s).`, { removed: count });
|
|
188
240
|
}
|
|
189
241
|
|
|
190
242
|
const conn = connectionFor(ctx, params.account, params.tenant);
|
|
191
|
-
const removed =
|
|
192
|
-
forgetToken(conn);
|
|
243
|
+
const removed = await signOut(conn);
|
|
193
244
|
|
|
194
245
|
return removed
|
|
195
246
|
? textResult(`✅ Signed out of ${connectionLabel(conn)}.`, { account: conn.account })
|
|
@@ -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(
|
package/src/tools/teams-setup.ts
CHANGED
|
@@ -27,6 +27,7 @@ interface SetupParams {
|
|
|
27
27
|
displayName?: string;
|
|
28
28
|
clientSecret?: string;
|
|
29
29
|
authMode?: string;
|
|
30
|
+
loopbackPort?: number;
|
|
30
31
|
safetyLevel?: string;
|
|
31
32
|
parentAccount?: string;
|
|
32
33
|
setDefault?: boolean;
|
|
@@ -116,7 +117,14 @@ export const teamsSetupTool = {
|
|
|
116
117
|
authMode: Type.Optional(
|
|
117
118
|
Type.String({
|
|
118
119
|
description:
|
|
119
|
-
"'
|
|
120
|
+
"'interactive' (browser sign-in, acts as the user — default), 'device-code' (same, but for machines with no browser), " +
|
|
121
|
+
"'client-credentials' (app-only, cannot post as a person), or 'auto' to decide per machine",
|
|
122
|
+
}),
|
|
123
|
+
),
|
|
124
|
+
loopbackPort: Type.Optional(
|
|
125
|
+
Type.Number({
|
|
126
|
+
description:
|
|
127
|
+
"Fixed port for the browser sign-in redirect. Only needed when the app registration lists an exact redirect URI such as http://localhost:3000.",
|
|
120
128
|
}),
|
|
121
129
|
),
|
|
122
130
|
safetyLevel: Type.Optional(
|
|
@@ -179,6 +187,7 @@ export const teamsSetupTool = {
|
|
|
179
187
|
clientSecret: params.clientSecret,
|
|
180
188
|
authMode: params.authMode as TenantConfig["authMode"],
|
|
181
189
|
scopes: params.scopes,
|
|
190
|
+
loopbackPort: params.loopbackPort,
|
|
182
191
|
safetyLevel,
|
|
183
192
|
permissions,
|
|
184
193
|
};
|
|
@@ -211,8 +220,9 @@ export const teamsSetupTool = {
|
|
|
211
220
|
tenantId: params.tenantId,
|
|
212
221
|
clientId: params.clientId,
|
|
213
222
|
clientSecret: params.clientSecret,
|
|
214
|
-
authMode: (params.authMode as AccountConfig["authMode"]) ?? "
|
|
223
|
+
authMode: (params.authMode as AccountConfig["authMode"]) ?? "interactive",
|
|
215
224
|
scopes: params.scopes,
|
|
225
|
+
loopbackPort: params.loopbackPort,
|
|
216
226
|
safetyLevel: safetyLevel ?? "confirm",
|
|
217
227
|
permissions,
|
|
218
228
|
};
|