@patimweb/pi-teams 1.0.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/LICENSE +21 -0
- package/README.md +412 -0
- package/index.ts +1 -0
- package/package.json +61 -0
- package/prompts/teams-catch-up.md +24 -0
- package/prompts/teams-channel-digest.md +23 -0
- package/prompts/teams-doctor.md +21 -0
- package/prompts/teams-meeting-prep.md +22 -0
- package/prompts/teams-standup.md +21 -0
- package/prompts/teams-triage.md +22 -0
- package/skills/teams-collaboration/SKILL.md +143 -0
- package/src/auth/client-credentials.ts +72 -0
- package/src/auth/device-code.ts +276 -0
- package/src/auth/index.ts +105 -0
- package/src/auth/jwt.ts +50 -0
- package/src/auth/token-store.ts +156 -0
- package/src/config/index.ts +685 -0
- package/src/config/scope.ts +272 -0
- package/src/extension/index.ts +336 -0
- package/src/graph/calendar.ts +165 -0
- package/src/graph/chats.ts +159 -0
- package/src/graph/client.ts +234 -0
- package/src/graph/mappers.ts +268 -0
- package/src/graph/me.ts +80 -0
- package/src/graph/messages.ts +383 -0
- package/src/graph/presence.ts +160 -0
- package/src/graph/search.ts +111 -0
- package/src/graph/teams.ts +271 -0
- package/src/safety/audit.ts +57 -0
- package/src/safety/index.ts +186 -0
- package/src/status.ts +90 -0
- package/src/tools/resolve.ts +138 -0
- package/src/tools/shared.ts +124 -0
- package/src/tools/teams-accounts.ts +169 -0
- package/src/tools/teams-create-channel.ts +89 -0
- package/src/tools/teams-create-chat.ts +112 -0
- package/src/tools/teams-delete-message.ts +114 -0
- package/src/tools/teams-doctor.ts +182 -0
- package/src/tools/teams-find-user.ts +64 -0
- package/src/tools/teams-inbox.ts +142 -0
- package/src/tools/teams-list-channels.ts +62 -0
- package/src/tools/teams-list-chats.ts +74 -0
- package/src/tools/teams-list-files.ts +54 -0
- package/src/tools/teams-list-members.ts +72 -0
- package/src/tools/teams-list-teams.ts +65 -0
- package/src/tools/teams-login.ts +199 -0
- package/src/tools/teams-meetings.ts +342 -0
- package/src/tools/teams-permissions.ts +169 -0
- package/src/tools/teams-presence.ts +216 -0
- package/src/tools/teams-react.ts +109 -0
- package/src/tools/teams-read-channel.ts +75 -0
- package/src/tools/teams-read-chat.ts +73 -0
- package/src/tools/teams-read-thread.ts +88 -0
- package/src/tools/teams-search-messages.ts +71 -0
- package/src/tools/teams-send-channel-message.ts +250 -0
- package/src/tools/teams-send-chat-message.ts +152 -0
- package/src/tools/teams-setup.ts +245 -0
- package/src/tools/teams-status.ts +73 -0
- package/src/tools/tool-names.ts +30 -0
- package/src/types.ts +192 -0
- package/src/utils/errors.ts +133 -0
- package/src/utils/formatting.ts +356 -0
- package/tsconfig.json +16 -0
|
@@ -0,0 +1,271 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Teams, channels, members and channel files.
|
|
3
|
+
*
|
|
4
|
+
* Every lookup accepts a name *or* an ID, because that is how people refer to
|
|
5
|
+
* their own teams ("post this in Engineering/General"). Name resolution is
|
|
6
|
+
* case-insensitive and reports ambiguity rather than guessing — posting in the
|
|
7
|
+
* wrong channel is not a recoverable mistake.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
import type { TeamsConnection } from "../config/index.ts";
|
|
11
|
+
import type { ChannelSummary, DriveItemSummary, MemberSummary, TeamSummary } from "../types.ts";
|
|
12
|
+
import { graphGetOptional, graphList, graphPost, graphRequest } from "./client.ts";
|
|
13
|
+
import { mapChannel, mapDriveItem, mapMember, mapTeam } from "./mappers.ts";
|
|
14
|
+
|
|
15
|
+
/** Raised when a name matches more than one team or channel. */
|
|
16
|
+
export class AmbiguousNameError extends Error {
|
|
17
|
+
readonly candidates: string[];
|
|
18
|
+
constructor(kind: string, name: string, candidates: string[]) {
|
|
19
|
+
super(
|
|
20
|
+
`${kind} "${name}" is ambiguous — ${candidates.length} matches: ${candidates.join(", ")}. ` +
|
|
21
|
+
`Use the ID instead.`,
|
|
22
|
+
);
|
|
23
|
+
this.name = "AmbiguousNameError";
|
|
24
|
+
this.candidates = candidates;
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
const isGuid = (value: string): boolean => /^[0-9a-f-]{36}$/i.test(value);
|
|
29
|
+
const isChannelId = (value: string): boolean => value.startsWith("19:");
|
|
30
|
+
|
|
31
|
+
// ---------------------------------------------------------------------------
|
|
32
|
+
// Teams
|
|
33
|
+
// ---------------------------------------------------------------------------
|
|
34
|
+
|
|
35
|
+
/** Teams the signed-in user is a member of. */
|
|
36
|
+
export async function listJoinedTeams(
|
|
37
|
+
conn: TeamsConnection,
|
|
38
|
+
max = 100,
|
|
39
|
+
signal?: AbortSignal,
|
|
40
|
+
): Promise<TeamSummary[]> {
|
|
41
|
+
const raw = await graphList<Record<string, unknown>>(conn, "/me/joinedTeams", { max, signal });
|
|
42
|
+
return raw.map((entry) => mapTeam(entry as Record<string, never>));
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
export async function getTeam(
|
|
46
|
+
conn: TeamsConnection,
|
|
47
|
+
teamId: string,
|
|
48
|
+
signal?: AbortSignal,
|
|
49
|
+
): Promise<TeamSummary | undefined> {
|
|
50
|
+
const raw = await graphGetOptional<Record<string, unknown>>(
|
|
51
|
+
conn,
|
|
52
|
+
`/teams/${encodeURIComponent(teamId)}`,
|
|
53
|
+
{ signal },
|
|
54
|
+
);
|
|
55
|
+
return raw ? mapTeam(raw as Record<string, never>) : undefined;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* Resolve a team by ID or display name.
|
|
60
|
+
*
|
|
61
|
+
* @throws {AmbiguousNameError} when a name matches several teams
|
|
62
|
+
*/
|
|
63
|
+
export async function resolveTeam(
|
|
64
|
+
conn: TeamsConnection,
|
|
65
|
+
reference: string,
|
|
66
|
+
signal?: AbortSignal,
|
|
67
|
+
): Promise<TeamSummary | undefined> {
|
|
68
|
+
if (isGuid(reference)) {
|
|
69
|
+
const direct = await getTeam(conn, reference, signal);
|
|
70
|
+
if (direct) return direct;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
const teams = await listJoinedTeams(conn, 200, signal);
|
|
74
|
+
const lower = reference.toLowerCase();
|
|
75
|
+
|
|
76
|
+
const exact = teams.filter((t) => t.displayName.toLowerCase() === lower || t.id === reference);
|
|
77
|
+
if (exact.length === 1) return exact[0];
|
|
78
|
+
if (exact.length > 1) {
|
|
79
|
+
throw new AmbiguousNameError("Team", reference, exact.map((t) => `${t.displayName} (${t.id})`));
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
const partial = teams.filter((t) => t.displayName.toLowerCase().includes(lower));
|
|
83
|
+
if (partial.length === 1) return partial[0];
|
|
84
|
+
if (partial.length > 1) {
|
|
85
|
+
throw new AmbiguousNameError("Team", reference, partial.map((t) => t.displayName));
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
return undefined;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
// ---------------------------------------------------------------------------
|
|
92
|
+
// Channels
|
|
93
|
+
// ---------------------------------------------------------------------------
|
|
94
|
+
|
|
95
|
+
export async function listChannels(
|
|
96
|
+
conn: TeamsConnection,
|
|
97
|
+
team: TeamSummary,
|
|
98
|
+
max = 100,
|
|
99
|
+
signal?: AbortSignal,
|
|
100
|
+
): Promise<ChannelSummary[]> {
|
|
101
|
+
const raw = await graphList<Record<string, unknown>>(
|
|
102
|
+
conn,
|
|
103
|
+
`/teams/${encodeURIComponent(team.id)}/channels`,
|
|
104
|
+
{ max, signal },
|
|
105
|
+
);
|
|
106
|
+
return raw.map((entry) =>
|
|
107
|
+
mapChannel(entry as Record<string, never>, { id: team.id, name: team.displayName }),
|
|
108
|
+
);
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
export async function getChannel(
|
|
112
|
+
conn: TeamsConnection,
|
|
113
|
+
team: TeamSummary,
|
|
114
|
+
channelId: string,
|
|
115
|
+
signal?: AbortSignal,
|
|
116
|
+
): Promise<ChannelSummary | undefined> {
|
|
117
|
+
const raw = await graphGetOptional<Record<string, unknown>>(
|
|
118
|
+
conn,
|
|
119
|
+
`/teams/${encodeURIComponent(team.id)}/channels/${encodeURIComponent(channelId)}`,
|
|
120
|
+
{ signal },
|
|
121
|
+
);
|
|
122
|
+
return raw
|
|
123
|
+
? mapChannel(raw as Record<string, never>, { id: team.id, name: team.displayName })
|
|
124
|
+
: undefined;
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
/**
|
|
128
|
+
* Resolve a channel by ID or display name inside a team.
|
|
129
|
+
*
|
|
130
|
+
* @throws {AmbiguousNameError} when a name matches several channels
|
|
131
|
+
*/
|
|
132
|
+
export async function resolveChannel(
|
|
133
|
+
conn: TeamsConnection,
|
|
134
|
+
team: TeamSummary,
|
|
135
|
+
reference: string,
|
|
136
|
+
signal?: AbortSignal,
|
|
137
|
+
): Promise<ChannelSummary | undefined> {
|
|
138
|
+
if (isChannelId(reference)) {
|
|
139
|
+
const direct = await getChannel(conn, team, reference, signal);
|
|
140
|
+
if (direct) return direct;
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
const channels = await listChannels(conn, team, 200, signal);
|
|
144
|
+
const lower = reference.toLowerCase();
|
|
145
|
+
|
|
146
|
+
const exact = channels.filter((c) => c.displayName.toLowerCase() === lower || c.id === reference);
|
|
147
|
+
if (exact.length === 1) return exact[0];
|
|
148
|
+
if (exact.length > 1) {
|
|
149
|
+
throw new AmbiguousNameError("Channel", reference, exact.map((c) => `${c.displayName} (${c.id})`));
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
const partial = channels.filter((c) => c.displayName.toLowerCase().includes(lower));
|
|
153
|
+
if (partial.length === 1) return partial[0];
|
|
154
|
+
if (partial.length > 1) {
|
|
155
|
+
throw new AmbiguousNameError("Channel", reference, partial.map((c) => c.displayName));
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
return undefined;
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
/**
|
|
162
|
+
* Resolve "Team/Channel", or a team plus channel reference, in one step.
|
|
163
|
+
* Returns whatever it could resolve so callers can produce a precise error.
|
|
164
|
+
*/
|
|
165
|
+
export async function resolveTeamAndChannel(
|
|
166
|
+
conn: TeamsConnection,
|
|
167
|
+
teamRef: string | undefined,
|
|
168
|
+
channelRef: string,
|
|
169
|
+
signal?: AbortSignal,
|
|
170
|
+
): Promise<{ team?: TeamSummary; channel?: ChannelSummary }> {
|
|
171
|
+
let teamReference = teamRef;
|
|
172
|
+
let channelReference = channelRef;
|
|
173
|
+
|
|
174
|
+
if (!teamReference && channelRef.includes("/")) {
|
|
175
|
+
const [first, ...rest] = channelRef.split("/");
|
|
176
|
+
teamReference = first;
|
|
177
|
+
channelReference = rest.join("/");
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
if (!teamReference) return {};
|
|
181
|
+
|
|
182
|
+
const team = await resolveTeam(conn, teamReference, signal);
|
|
183
|
+
if (!team) return {};
|
|
184
|
+
|
|
185
|
+
const channel = await resolveChannel(conn, team, channelReference, signal);
|
|
186
|
+
return { team, channel };
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
export async function createChannel(
|
|
190
|
+
conn: TeamsConnection,
|
|
191
|
+
team: TeamSummary,
|
|
192
|
+
input: { displayName: string; description?: string; membershipType?: string },
|
|
193
|
+
signal?: AbortSignal,
|
|
194
|
+
): Promise<ChannelSummary> {
|
|
195
|
+
const raw = await graphPost<Record<string, unknown>>(
|
|
196
|
+
conn,
|
|
197
|
+
`/teams/${encodeURIComponent(team.id)}/channels`,
|
|
198
|
+
{
|
|
199
|
+
displayName: input.displayName,
|
|
200
|
+
description: input.description,
|
|
201
|
+
membershipType: input.membershipType ?? "standard",
|
|
202
|
+
},
|
|
203
|
+
{ signal },
|
|
204
|
+
);
|
|
205
|
+
return mapChannel((raw ?? {}) as Record<string, never>, { id: team.id, name: team.displayName });
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
// ---------------------------------------------------------------------------
|
|
209
|
+
// Members
|
|
210
|
+
// ---------------------------------------------------------------------------
|
|
211
|
+
|
|
212
|
+
export async function listTeamMembers(
|
|
213
|
+
conn: TeamsConnection,
|
|
214
|
+
teamId: string,
|
|
215
|
+
max = 200,
|
|
216
|
+
signal?: AbortSignal,
|
|
217
|
+
): Promise<MemberSummary[]> {
|
|
218
|
+
const raw = await graphList<Record<string, unknown>>(
|
|
219
|
+
conn,
|
|
220
|
+
`/teams/${encodeURIComponent(teamId)}/members`,
|
|
221
|
+
{ max, signal },
|
|
222
|
+
);
|
|
223
|
+
return raw.map((entry) => mapMember(entry as Record<string, never>));
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
export async function listChannelMembers(
|
|
227
|
+
conn: TeamsConnection,
|
|
228
|
+
teamId: string,
|
|
229
|
+
channelId: string,
|
|
230
|
+
max = 200,
|
|
231
|
+
signal?: AbortSignal,
|
|
232
|
+
): Promise<MemberSummary[]> {
|
|
233
|
+
const raw = await graphList<Record<string, unknown>>(
|
|
234
|
+
conn,
|
|
235
|
+
`/teams/${encodeURIComponent(teamId)}/channels/${encodeURIComponent(channelId)}/members`,
|
|
236
|
+
{ max, signal },
|
|
237
|
+
);
|
|
238
|
+
return raw.map((entry) => mapMember(entry as Record<string, never>));
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
// ---------------------------------------------------------------------------
|
|
242
|
+
// Files
|
|
243
|
+
// ---------------------------------------------------------------------------
|
|
244
|
+
|
|
245
|
+
/** Files in a channel's SharePoint folder. */
|
|
246
|
+
export async function listChannelFiles(
|
|
247
|
+
conn: TeamsConnection,
|
|
248
|
+
teamId: string,
|
|
249
|
+
channelId: string,
|
|
250
|
+
max = 50,
|
|
251
|
+
signal?: AbortSignal,
|
|
252
|
+
): Promise<DriveItemSummary[]> {
|
|
253
|
+
const folder = await graphRequest<Record<string, unknown>>(
|
|
254
|
+
conn,
|
|
255
|
+
"GET",
|
|
256
|
+
`/teams/${encodeURIComponent(teamId)}/channels/${encodeURIComponent(channelId)}/filesFolder`,
|
|
257
|
+
{ signal },
|
|
258
|
+
);
|
|
259
|
+
|
|
260
|
+
const parentReference = folder?.["parentReference"] as { driveId?: string } | undefined;
|
|
261
|
+
const driveId = parentReference?.driveId;
|
|
262
|
+
const itemId = folder?.["id"] as string | undefined;
|
|
263
|
+
if (!driveId || !itemId) return [];
|
|
264
|
+
|
|
265
|
+
const raw = await graphList<Record<string, unknown>>(
|
|
266
|
+
conn,
|
|
267
|
+
`/drives/${encodeURIComponent(driveId)}/items/${encodeURIComponent(itemId)}/children`,
|
|
268
|
+
{ max, signal },
|
|
269
|
+
);
|
|
270
|
+
return raw.map((entry) => mapDriveItem(entry as Record<string, never>));
|
|
271
|
+
}
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Append-only audit log of everything pi wrote in the user's name.
|
|
3
|
+
*
|
|
4
|
+
* One JSON object per line in `~/.pi/agent/pi-teams-audit.jsonl`, mode 0600.
|
|
5
|
+
* The point is answerability: when a colleague asks "did you really send that
|
|
6
|
+
* at 23:40?", the user can look it up instead of guessing. Logging never
|
|
7
|
+
* blocks or fails an operation — a broken log is a smaller problem than a
|
|
8
|
+
* refused message.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
import { appendFileSync, existsSync, mkdirSync, chmodSync } from "node:fs";
|
|
12
|
+
import { dirname, join } from "node:path";
|
|
13
|
+
import { getAgentDir } from "../config/index.ts";
|
|
14
|
+
|
|
15
|
+
export interface AuditEntry {
|
|
16
|
+
/** ISO timestamp */
|
|
17
|
+
at: string;
|
|
18
|
+
/** Tool that performed the write */
|
|
19
|
+
tool: string;
|
|
20
|
+
account: string;
|
|
21
|
+
tenant: string;
|
|
22
|
+
/** Who pi acted as, when known */
|
|
23
|
+
actor?: string;
|
|
24
|
+
/** Human-readable target, e.g. "Engineering/General" */
|
|
25
|
+
target: string;
|
|
26
|
+
/** One-line description of what happened */
|
|
27
|
+
summary: string;
|
|
28
|
+
/** Present when the operation failed */
|
|
29
|
+
error?: string;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export function getAuditPath(): string {
|
|
33
|
+
return join(getAgentDir(), "pi-teams-audit.jsonl");
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/** Append an entry. Never throws. */
|
|
37
|
+
export function recordAudit(entry: AuditEntry): void {
|
|
38
|
+
try {
|
|
39
|
+
const path = getAuditPath();
|
|
40
|
+
const dir = dirname(path);
|
|
41
|
+
if (!existsSync(dir)) mkdirSync(dir, { recursive: true, mode: 0o700 });
|
|
42
|
+
const isNew = !existsSync(path);
|
|
43
|
+
appendFileSync(path, `${JSON.stringify(entry)}\n`, { encoding: "utf-8", mode: 0o600 });
|
|
44
|
+
if (isNew) chmodSync(path, 0o600);
|
|
45
|
+
} catch {
|
|
46
|
+
/* auditing must never break the operation it describes */
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/** Convenience wrapper used by the tools. */
|
|
51
|
+
export function auditWrite(
|
|
52
|
+
enabled: boolean,
|
|
53
|
+
fields: Omit<AuditEntry, "at">,
|
|
54
|
+
): void {
|
|
55
|
+
if (!enabled) return;
|
|
56
|
+
recordAudit({ at: new Date().toISOString(), ...fields });
|
|
57
|
+
}
|
|
@@ -0,0 +1,186 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The safety model — three independent gates, all of which must pass before
|
|
3
|
+
* pi says or changes anything as the user.
|
|
4
|
+
*
|
|
5
|
+
* 1. **Auth mode.** An app-only (client-credentials) token cannot post as a
|
|
6
|
+
* person; Microsoft Graph only allows that for migration. Rather than
|
|
7
|
+
* letting the call fail at Graph, writes are refused here with an
|
|
8
|
+
* explanation.
|
|
9
|
+
* 2. **Safety level.** `readonly` blocks every mutation, `confirm` asks the
|
|
10
|
+
* user first, `open` lets them through. Configurable globally, per account
|
|
11
|
+
* and per tenant, most specific winning.
|
|
12
|
+
* 3. **Scope rules.** Allow/deny lists decide *which* teams, channels, chats
|
|
13
|
+
* and people are in play at all — for reading as well as writing. See
|
|
14
|
+
* ../config/scope.ts.
|
|
15
|
+
*
|
|
16
|
+
* Gates 1 and 2 live in the extension's `tool_call` interceptor because they
|
|
17
|
+
* need only the tool name. Gate 3 lives in the tools, because only they know
|
|
18
|
+
* which channel a message is actually headed for.
|
|
19
|
+
*/
|
|
20
|
+
|
|
21
|
+
import type { SafetyLevel, TeamsConnection } from "../config/index.ts";
|
|
22
|
+
import { checkScope, type ScopeCategory, type ScopeMode } from "../config/scope.ts";
|
|
23
|
+
import { ScopeDeniedError } from "../utils/errors.ts";
|
|
24
|
+
import { MUTATION_TOOLS } from "../tools/tool-names.ts";
|
|
25
|
+
|
|
26
|
+
// ---------------------------------------------------------------------------
|
|
27
|
+
// Gate 1 + 2 — tool-level
|
|
28
|
+
// ---------------------------------------------------------------------------
|
|
29
|
+
|
|
30
|
+
export function isMutationTool(toolName: string): boolean {
|
|
31
|
+
return MUTATION_TOOLS.has(toolName);
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* Tools that only change local files — the config and the token cache.
|
|
36
|
+
*
|
|
37
|
+
* They still ask for confirmation, but `readonly` must not block them: that
|
|
38
|
+
* level means "do not change anything in Teams", and a user who set it still
|
|
39
|
+
* needs a way to add an account or sign out. They also work before anything is
|
|
40
|
+
* configured at all, which is the whole point of `teams_setup`.
|
|
41
|
+
*/
|
|
42
|
+
export const LOCAL_CONFIG_TOOLS = new Set<string>(["teams_setup", "teams_logout"]);
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* Why a mutation must be refused outright, or undefined when it may proceed
|
|
46
|
+
* (possibly after confirmation).
|
|
47
|
+
*/
|
|
48
|
+
export function blockReason(
|
|
49
|
+
safetyLevel: SafetyLevel,
|
|
50
|
+
authMode: "device-code" | "client-credentials",
|
|
51
|
+
toolName: string,
|
|
52
|
+
): string | undefined {
|
|
53
|
+
if (!isMutationTool(toolName)) return undefined;
|
|
54
|
+
if (LOCAL_CONFIG_TOOLS.has(toolName)) return undefined;
|
|
55
|
+
|
|
56
|
+
if (safetyLevel === "readonly") {
|
|
57
|
+
return (
|
|
58
|
+
`"${toolName}" is blocked: safetyLevel is "readonly". ` +
|
|
59
|
+
`Set safetyLevel to "confirm" or "open" in pi-teams.json (globally, on the account, or on the tenant).`
|
|
60
|
+
);
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
if (authMode === "client-credentials" && APP_ONLY_FORBIDDEN.has(toolName)) {
|
|
64
|
+
return (
|
|
65
|
+
`"${toolName}" cannot run on an app-only token. Microsoft Graph only permits app-only writes to ` +
|
|
66
|
+
`chats and channels for migration scenarios, so pi cannot post as a person this way. ` +
|
|
67
|
+
`Set authMode to "device-code" for this account and run teams_login.`
|
|
68
|
+
);
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
return undefined;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/**
|
|
75
|
+
* Tools that need a real user behind them.
|
|
76
|
+
* Directory reads and calendar work still function app-only.
|
|
77
|
+
*/
|
|
78
|
+
const APP_ONLY_FORBIDDEN = new Set<string>([
|
|
79
|
+
"teams_send_chat_message",
|
|
80
|
+
"teams_send_channel_message",
|
|
81
|
+
"teams_reply_channel_message",
|
|
82
|
+
"teams_create_chat",
|
|
83
|
+
"teams_delete_message",
|
|
84
|
+
"teams_react",
|
|
85
|
+
"teams_set_presence",
|
|
86
|
+
"teams_set_status_message",
|
|
87
|
+
]);
|
|
88
|
+
|
|
89
|
+
// ---------------------------------------------------------------------------
|
|
90
|
+
// Confirmation summaries
|
|
91
|
+
// ---------------------------------------------------------------------------
|
|
92
|
+
|
|
93
|
+
/**
|
|
94
|
+
* One line describing what is about to happen, shown in the confirm dialog.
|
|
95
|
+
*
|
|
96
|
+
* This is the last thing a user reads before pi speaks for them, so it shows
|
|
97
|
+
* the destination and the actual text — never just the tool name.
|
|
98
|
+
*/
|
|
99
|
+
export function formatMutationSummary(
|
|
100
|
+
toolName: string,
|
|
101
|
+
params: Record<string, unknown>,
|
|
102
|
+
): string {
|
|
103
|
+
const text = (value: unknown, max = 160): string => {
|
|
104
|
+
const str = String(value ?? "").replace(/\s*\n\s*/g, " ").trim();
|
|
105
|
+
return str.length > max ? `${str.slice(0, max - 1)}…` : str;
|
|
106
|
+
};
|
|
107
|
+
|
|
108
|
+
switch (toolName) {
|
|
109
|
+
case "teams_send_chat_message":
|
|
110
|
+
return `Send a chat message to "${text(params.chat, 60)}":\n\n${text(params.body, 400)}`;
|
|
111
|
+
case "teams_send_channel_message": {
|
|
112
|
+
const where = params.team ? `${text(params.team, 40)}/${text(params.channel, 40)}` : text(params.channel, 60);
|
|
113
|
+
return `Post in channel ${where}:\n\n${text(params.body, 400)}`;
|
|
114
|
+
}
|
|
115
|
+
case "teams_reply_channel_message": {
|
|
116
|
+
const where = params.team ? `${text(params.team, 40)}/${text(params.channel, 40)}` : text(params.channel, 60);
|
|
117
|
+
return `Reply in thread ${text(params.messageId, 40)} (${where}):\n\n${text(params.body, 400)}`;
|
|
118
|
+
}
|
|
119
|
+
case "teams_create_chat": {
|
|
120
|
+
const people = Array.isArray(params.participants) ? params.participants.join(", ") : text(params.participants);
|
|
121
|
+
return `Create a new chat with: ${text(people, 200)}`;
|
|
122
|
+
}
|
|
123
|
+
case "teams_create_channel":
|
|
124
|
+
return `Create channel "${text(params.name, 60)}" in team "${text(params.team, 60)}"`;
|
|
125
|
+
case "teams_delete_message":
|
|
126
|
+
return `Delete your message ${text(params.messageId, 60)}`;
|
|
127
|
+
case "teams_react":
|
|
128
|
+
return `${params.remove ? "Remove" : "Add"} reaction ${text(params.reaction, 10)} on message ${text(params.messageId, 50)}`;
|
|
129
|
+
case "teams_set_presence":
|
|
130
|
+
return `Set your Teams presence to ${text(params.availability, 30)}${params.expiresIn ? ` for ${text(params.expiresIn, 20)}` : ""}`;
|
|
131
|
+
case "teams_set_status_message":
|
|
132
|
+
return `Set your Teams status message to: "${text(params.message, 200)}"`;
|
|
133
|
+
case "teams_create_meeting":
|
|
134
|
+
return `Create meeting "${text(params.subject, 60)}" (${text(params.start, 30)} – ${text(params.end, 30)}) with ${text(Array.isArray(params.attendees) ? params.attendees.join(", ") : params.attendees, 160)}`;
|
|
135
|
+
case "teams_update_meeting":
|
|
136
|
+
return `Update meeting ${text(params.eventId, 50)}`;
|
|
137
|
+
case "teams_cancel_meeting":
|
|
138
|
+
return `Cancel meeting ${text(params.eventId, 50)}`;
|
|
139
|
+
case "teams_setup":
|
|
140
|
+
return `Write Teams account "${text(params.name, 40)}" to pi-teams.json`;
|
|
141
|
+
case "teams_logout":
|
|
142
|
+
return `Sign out of Teams account "${text(params.account, 40) || "(default)"}"`;
|
|
143
|
+
default:
|
|
144
|
+
return `${toolName}: ${text(JSON.stringify(params), 200)}`;
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
// ---------------------------------------------------------------------------
|
|
149
|
+
// Gate 3 — scope enforcement
|
|
150
|
+
// ---------------------------------------------------------------------------
|
|
151
|
+
|
|
152
|
+
/**
|
|
153
|
+
* Assert that a target is in scope, or throw.
|
|
154
|
+
*
|
|
155
|
+
* @param conn - the resolved connection carrying the rules
|
|
156
|
+
* @param mode - read or write
|
|
157
|
+
* @param category - which rule set applies
|
|
158
|
+
* @param label - how the target is named in the error message
|
|
159
|
+
* @param candidates - identifiers to match (id, name, path, e-mail, …)
|
|
160
|
+
*
|
|
161
|
+
* @throws {ScopeDeniedError}
|
|
162
|
+
*/
|
|
163
|
+
export function assertAccess(
|
|
164
|
+
conn: TeamsConnection,
|
|
165
|
+
mode: ScopeMode,
|
|
166
|
+
category: ScopeCategory,
|
|
167
|
+
label: string,
|
|
168
|
+
candidates: (string | undefined)[],
|
|
169
|
+
): void {
|
|
170
|
+
const decision = checkScope(conn.permissions, mode, category, candidates);
|
|
171
|
+
if (!decision.allowed) {
|
|
172
|
+
throw new ScopeDeniedError(`${mode} ${category.replace(/s$/, "")} "${label}"`, decision.reason);
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
/** Non-throwing variant, for filtering lists. */
|
|
177
|
+
export function hasAccess(
|
|
178
|
+
conn: TeamsConnection,
|
|
179
|
+
mode: ScopeMode,
|
|
180
|
+
category: ScopeCategory,
|
|
181
|
+
candidates: (string | undefined)[],
|
|
182
|
+
): boolean {
|
|
183
|
+
return checkScope(conn.permissions, mode, category, candidates).allowed;
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
export { ScopeDeniedError };
|
package/src/status.ts
ADDED
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Connection status helpers — pure, testable, TUI-free.
|
|
3
|
+
*
|
|
4
|
+
* Consumed by three surfaces: the footer status line, the persistent
|
|
5
|
+
* connection card in the transcript, and the `/teams-status` command.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import type { TeamsConnection } from "./config/index.ts";
|
|
9
|
+
import { getToken, isFresh } from "./auth/token-store.ts";
|
|
10
|
+
import { formatPermissions } from "./config/scope.ts";
|
|
11
|
+
|
|
12
|
+
/** Immutable snapshot of the active Teams connection. */
|
|
13
|
+
export interface ConnectionCard {
|
|
14
|
+
account: string;
|
|
15
|
+
accountDisplayName: string;
|
|
16
|
+
tenant: string;
|
|
17
|
+
tenantId: string;
|
|
18
|
+
authMode: string;
|
|
19
|
+
safetyLevel: string;
|
|
20
|
+
/** Signed-in user, when a token is cached */
|
|
21
|
+
user?: string;
|
|
22
|
+
/** Whether a usable token exists right now */
|
|
23
|
+
signedIn: boolean;
|
|
24
|
+
/** ISO timestamp of token expiry, when known */
|
|
25
|
+
expiresAt?: string;
|
|
26
|
+
/** Other configured account names */
|
|
27
|
+
otherAccounts: string[];
|
|
28
|
+
/** Rendered scope rules */
|
|
29
|
+
permissionSummary: string;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export function buildConnectionCard(
|
|
33
|
+
conn: TeamsConnection | undefined,
|
|
34
|
+
): ConnectionCard | undefined {
|
|
35
|
+
if (!conn) return undefined;
|
|
36
|
+
|
|
37
|
+
const token = getToken(conn.account, conn.tenantId);
|
|
38
|
+
|
|
39
|
+
return {
|
|
40
|
+
account: conn.account,
|
|
41
|
+
accountDisplayName: conn.accountDisplayName,
|
|
42
|
+
tenant: conn.tenant,
|
|
43
|
+
tenantId: conn.tenantId,
|
|
44
|
+
authMode: conn.authMode,
|
|
45
|
+
safetyLevel: conn.safetyLevel,
|
|
46
|
+
user: token?.user?.upn ?? token?.user?.displayName,
|
|
47
|
+
signedIn: isFresh(token) || !!token?.refreshToken,
|
|
48
|
+
expiresAt: token ? new Date(token.expiresAt).toISOString() : undefined,
|
|
49
|
+
otherAccounts: conn.allAccounts.map((a) => a.name).filter((name) => name !== conn.account),
|
|
50
|
+
permissionSummary: formatPermissions(conn.permissions),
|
|
51
|
+
};
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/** Short footer label, e.g. "✓ Teams · work · anna@contoso.com". */
|
|
55
|
+
export function buildConnectionLabel(card: ConnectionCard | undefined): string {
|
|
56
|
+
if (!card) return "Teams · not configured";
|
|
57
|
+
if (!card.signedIn) return `Teams · ${card.account} · not signed in`;
|
|
58
|
+
|
|
59
|
+
const scope = card.tenant === card.account ? card.account : `${card.account}/${card.tenant}`;
|
|
60
|
+
return `✓ Teams · ${scope}${card.user ? ` · ${card.user}` : ""}`;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/** Rich markdown for `/teams-status` and the expanded card. */
|
|
64
|
+
export function formatStatusText(card: ConnectionCard | undefined): string {
|
|
65
|
+
if (!card) {
|
|
66
|
+
return [
|
|
67
|
+
"## Microsoft Teams",
|
|
68
|
+
"",
|
|
69
|
+
"⚠️ **Not configured.**",
|
|
70
|
+
"",
|
|
71
|
+
"A template was created at `~/.pi/agent/pi-teams.json`. Fill in your Entra ID " +
|
|
72
|
+
"`tenantId` and `clientId` — or run the `teams_setup` tool — then run `teams_login`.",
|
|
73
|
+
].join("\n");
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
const lines: string[] = ["## Microsoft Teams", ""];
|
|
77
|
+
lines.push(`- **Account:** ${card.accountDisplayName}${card.accountDisplayName !== card.account ? ` (${card.account})` : ""}`);
|
|
78
|
+
if (card.tenant !== card.account) lines.push(`- **Tenant:** ${card.tenant}`);
|
|
79
|
+
lines.push(`- **Tenant ID:** ${card.tenantId}`);
|
|
80
|
+
lines.push(`- **Acting as:** ${card.signedIn ? (card.user ?? "(signed in)") : "not signed in — run teams_login"}`);
|
|
81
|
+
lines.push(`- **Auth mode:** ${card.authMode}${card.authMode === "client-credentials" ? " (app-only — cannot post as a person)" : ""}`);
|
|
82
|
+
lines.push(`- **Safety level:** ${card.safetyLevel}`);
|
|
83
|
+
if (card.expiresAt) lines.push(`- **Token expires:** ${new Date(card.expiresAt).toLocaleString()}`);
|
|
84
|
+
if (card.otherAccounts.length > 0) {
|
|
85
|
+
lines.push(`- **Other accounts:** ${card.otherAccounts.join(", ")}`);
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
lines.push("", "### What pi may do", "", card.permissionSummary);
|
|
89
|
+
return lines.join("\n");
|
|
90
|
+
}
|