@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,138 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Target resolution with scope enforcement.
|
|
3
|
+
*
|
|
4
|
+
* Every tool that touches a specific channel or chat goes through here, so the
|
|
5
|
+
* "resolve the name, then check the rules" pair can never be half-done: the
|
|
6
|
+
* functions below either return an in-scope target or throw.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import type { TeamsConnection } from "../config/index.ts";
|
|
10
|
+
import type { ChannelSummary, ChatSummary, TeamSummary } from "../types.ts";
|
|
11
|
+
import { chatCandidates, channelCandidates, teamCandidates } from "../graph/mappers.ts";
|
|
12
|
+
import { findChats } from "../graph/chats.ts";
|
|
13
|
+
import { resolveTeam, resolveTeamAndChannel } from "../graph/teams.ts";
|
|
14
|
+
import { assertAccess } from "../safety/index.ts";
|
|
15
|
+
import type { ScopeMode } from "../config/scope.ts";
|
|
16
|
+
import { currentUser } from "./shared.ts";
|
|
17
|
+
|
|
18
|
+
/** Raised when a name matches nothing, or too much to be safe. */
|
|
19
|
+
export class TargetError extends Error {
|
|
20
|
+
constructor(message: string) {
|
|
21
|
+
super(message);
|
|
22
|
+
this.name = "TargetError";
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* Resolve a team by name or ID and check it against the rules.
|
|
28
|
+
*
|
|
29
|
+
* @throws {TargetError} when it does not exist
|
|
30
|
+
* @throws {ScopeDeniedError} when the rules forbid it
|
|
31
|
+
*/
|
|
32
|
+
export async function requireTeam(
|
|
33
|
+
conn: TeamsConnection,
|
|
34
|
+
reference: string,
|
|
35
|
+
mode: ScopeMode,
|
|
36
|
+
signal?: AbortSignal,
|
|
37
|
+
): Promise<TeamSummary> {
|
|
38
|
+
const team = await resolveTeam(conn, reference, signal);
|
|
39
|
+
if (!team) {
|
|
40
|
+
throw new TargetError(
|
|
41
|
+
`Team "${reference}" not found among the teams you are a member of. ` +
|
|
42
|
+
`Use teams_list_teams to see the available names.`,
|
|
43
|
+
);
|
|
44
|
+
}
|
|
45
|
+
assertAccess(conn, mode, "teams", team.displayName, teamCandidates(team));
|
|
46
|
+
return team;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* Resolve a channel (optionally via "Team/Channel") and check both the team
|
|
51
|
+
* and the channel against the rules.
|
|
52
|
+
*/
|
|
53
|
+
export async function requireChannel(
|
|
54
|
+
conn: TeamsConnection,
|
|
55
|
+
teamRef: string | undefined,
|
|
56
|
+
channelRef: string,
|
|
57
|
+
mode: ScopeMode,
|
|
58
|
+
signal?: AbortSignal,
|
|
59
|
+
): Promise<{ team: TeamSummary; channel: ChannelSummary }> {
|
|
60
|
+
if (!teamRef && !channelRef.includes("/")) {
|
|
61
|
+
throw new TargetError(
|
|
62
|
+
`Specify the team as well: pass 'team' explicitly, or use a "Team/Channel" path for 'channel'.`,
|
|
63
|
+
);
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
const { team, channel } = await resolveTeamAndChannel(conn, teamRef, channelRef, signal);
|
|
67
|
+
|
|
68
|
+
if (!team) {
|
|
69
|
+
throw new TargetError(
|
|
70
|
+
`Team "${teamRef ?? channelRef.split("/")[0]}" not found. Use teams_list_teams to see the available names.`,
|
|
71
|
+
);
|
|
72
|
+
}
|
|
73
|
+
assertAccess(conn, mode, "teams", team.displayName, teamCandidates(team));
|
|
74
|
+
|
|
75
|
+
if (!channel) {
|
|
76
|
+
throw new TargetError(
|
|
77
|
+
`Channel "${channelRef}" not found in team "${team.displayName}". ` +
|
|
78
|
+
`Use teams_list_channels to see the available names.`,
|
|
79
|
+
);
|
|
80
|
+
}
|
|
81
|
+
assertAccess(
|
|
82
|
+
conn,
|
|
83
|
+
mode,
|
|
84
|
+
"channels",
|
|
85
|
+
`${team.displayName}/${channel.displayName}`,
|
|
86
|
+
channelCandidates(channel),
|
|
87
|
+
);
|
|
88
|
+
|
|
89
|
+
return { team, channel };
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/**
|
|
93
|
+
* Resolve a chat by ID, topic or participant and check it against the rules.
|
|
94
|
+
*
|
|
95
|
+
* Refuses ambiguous matches: sending "the usual update" to the wrong Anna is
|
|
96
|
+
* not something a confirmation dialog can undo.
|
|
97
|
+
*/
|
|
98
|
+
export async function requireChat(
|
|
99
|
+
conn: TeamsConnection,
|
|
100
|
+
reference: string,
|
|
101
|
+
mode: ScopeMode,
|
|
102
|
+
signal?: AbortSignal,
|
|
103
|
+
): Promise<ChatSummary> {
|
|
104
|
+
const me = await currentUser(conn, signal).catch(() => undefined);
|
|
105
|
+
const matches = await findChats(conn, reference, { meId: me?.id, signal });
|
|
106
|
+
|
|
107
|
+
if (matches.length === 0) {
|
|
108
|
+
throw new TargetError(
|
|
109
|
+
`No chat matching "${reference}". Use teams_list_chats to see recent chats, ` +
|
|
110
|
+
`or teams_create_chat to start a new one.`,
|
|
111
|
+
);
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
if (matches.length > 1) {
|
|
115
|
+
const options = matches.slice(0, 8).map((chat) => `"${chat.label}" (${chat.id})`);
|
|
116
|
+
throw new TargetError(
|
|
117
|
+
`"${reference}" matches ${matches.length} chats: ${options.join(", ")}. ` +
|
|
118
|
+
`Pass the chat ID to be unambiguous.`,
|
|
119
|
+
);
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
const chat = matches[0]!;
|
|
123
|
+
assertAccess(conn, mode, "chats", chat.label, chatCandidates(chat));
|
|
124
|
+
|
|
125
|
+
// A chat is also the people in it: a deny rule on a person must stop a
|
|
126
|
+
// message to any chat they are part of, not just their 1:1.
|
|
127
|
+
for (const member of chat.members) {
|
|
128
|
+
if (me && member.id === me.id) continue;
|
|
129
|
+
assertAccess(conn, mode, "people", member.displayName, [
|
|
130
|
+
member.displayName,
|
|
131
|
+
member.upn,
|
|
132
|
+
member.mail,
|
|
133
|
+
member.id,
|
|
134
|
+
]);
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
return chat;
|
|
138
|
+
}
|
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Shared tool plumbing — parameter schemas, result helpers, connection
|
|
3
|
+
* resolution and the guards every tool runs before touching Graph.
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import { Type } from "typebox";
|
|
7
|
+
import {
|
|
8
|
+
resolveConnection,
|
|
9
|
+
type TeamsConnection,
|
|
10
|
+
} from "../config/index.ts";
|
|
11
|
+
import { getMe } from "../graph/me.ts";
|
|
12
|
+
import { formatGraphError } from "../utils/errors.ts";
|
|
13
|
+
import type { SignedInUser } from "../types.ts";
|
|
14
|
+
|
|
15
|
+
// ---------------------------------------------------------------------------
|
|
16
|
+
// Common parameters
|
|
17
|
+
// ---------------------------------------------------------------------------
|
|
18
|
+
|
|
19
|
+
/** Which configured account to act as. */
|
|
20
|
+
export const AccountParam = Type.Optional(
|
|
21
|
+
Type.String({
|
|
22
|
+
description:
|
|
23
|
+
"Configured account name to act as (see teams_accounts). Uses the default account if omitted.",
|
|
24
|
+
}),
|
|
25
|
+
);
|
|
26
|
+
|
|
27
|
+
/** Which tenant beneath that account — for guest access and customer tenants. */
|
|
28
|
+
export const TenantParam = Type.Optional(
|
|
29
|
+
Type.String({
|
|
30
|
+
description:
|
|
31
|
+
"Tenant name configured beneath the account (guest or customer tenant). Uses the account's home tenant if omitted.",
|
|
32
|
+
}),
|
|
33
|
+
);
|
|
34
|
+
|
|
35
|
+
export const LimitParam = Type.Optional(
|
|
36
|
+
Type.Number({ description: "Maximum number of items to return" }),
|
|
37
|
+
);
|
|
38
|
+
|
|
39
|
+
// ---------------------------------------------------------------------------
|
|
40
|
+
// Results
|
|
41
|
+
// ---------------------------------------------------------------------------
|
|
42
|
+
|
|
43
|
+
export interface ToolResult {
|
|
44
|
+
content: Array<{ type: "text"; text: string }>;
|
|
45
|
+
details: Record<string, unknown>;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
export function textResult(text: string, details: Record<string, unknown> = {}): ToolResult {
|
|
49
|
+
return { content: [{ type: "text", text }], details };
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
export function errorResult(message: string): ToolResult {
|
|
53
|
+
return { content: [{ type: "text", text: `❌ ${message}` }], details: { error: true } };
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/** Wrap a tool body so every failure comes back as a readable message. */
|
|
57
|
+
export async function run(fn: () => Promise<ToolResult>): Promise<ToolResult> {
|
|
58
|
+
try {
|
|
59
|
+
return await fn();
|
|
60
|
+
} catch (err) {
|
|
61
|
+
return errorResult(formatGraphError(err));
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
// ---------------------------------------------------------------------------
|
|
66
|
+
// Connection
|
|
67
|
+
// ---------------------------------------------------------------------------
|
|
68
|
+
|
|
69
|
+
/** The context every tool receives from the extension. */
|
|
70
|
+
export interface ToolContext {
|
|
71
|
+
cwd: string;
|
|
72
|
+
connection?: TeamsConnection;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/**
|
|
76
|
+
* Resolve the connection for this call.
|
|
77
|
+
*
|
|
78
|
+
* The session-level connection is reused when no override is given, so the
|
|
79
|
+
* common case costs nothing; naming an account or tenant re-resolves against
|
|
80
|
+
* the config file, which also picks up edits made during the session.
|
|
81
|
+
*/
|
|
82
|
+
export function connectionFor(
|
|
83
|
+
ctx: ToolContext,
|
|
84
|
+
account?: string,
|
|
85
|
+
tenant?: string,
|
|
86
|
+
): TeamsConnection {
|
|
87
|
+
if (!account && !tenant && ctx.connection) return ctx.connection;
|
|
88
|
+
return resolveConnection(account, tenant);
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
// ---------------------------------------------------------------------------
|
|
92
|
+
// Identity cache
|
|
93
|
+
// ---------------------------------------------------------------------------
|
|
94
|
+
|
|
95
|
+
const meCache = new Map<string, SignedInUser>();
|
|
96
|
+
|
|
97
|
+
/**
|
|
98
|
+
* The signed-in user for a connection, cached for the session.
|
|
99
|
+
*
|
|
100
|
+
* Several tools need the user's own ID — to label chats from their point of
|
|
101
|
+
* view, to check "is this my message" — and none of them should pay for a
|
|
102
|
+
* `/me` round-trip each time.
|
|
103
|
+
*/
|
|
104
|
+
export async function currentUser(
|
|
105
|
+
conn: TeamsConnection,
|
|
106
|
+
signal?: AbortSignal,
|
|
107
|
+
): Promise<SignedInUser> {
|
|
108
|
+
const key = `${conn.account}::${conn.tenantId}`;
|
|
109
|
+
const cached = meCache.get(key);
|
|
110
|
+
if (cached) return cached;
|
|
111
|
+
const me = await getMe(conn, signal);
|
|
112
|
+
meCache.set(key, me);
|
|
113
|
+
return me;
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
/** Forget cached identities (after a logout or account switch). */
|
|
117
|
+
export function clearUserCache(): void {
|
|
118
|
+
meCache.clear();
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
/** Short label for a connection, e.g. "work" or "work/customer-alpha". */
|
|
122
|
+
export function connectionLabel(conn: TeamsConnection): string {
|
|
123
|
+
return conn.tenant === conn.account ? conn.account : `${conn.account}/${conn.tenant}`;
|
|
124
|
+
}
|
|
@@ -0,0 +1,169 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* teams_accounts — list, switch between, and remove accounts and tenants.
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
import { Type } from "typebox";
|
|
6
|
+
import {
|
|
7
|
+
getConfigPath,
|
|
8
|
+
readRootConfig,
|
|
9
|
+
removeAccount,
|
|
10
|
+
removeTenant,
|
|
11
|
+
setDefaultAccount,
|
|
12
|
+
setSafetyLevel,
|
|
13
|
+
validateSafetyLevel,
|
|
14
|
+
} from "../config/index.ts";
|
|
15
|
+
import { deleteToken, getToken, isFresh } from "../auth/token-store.ts";
|
|
16
|
+
import { clearUserCache, errorResult, run, textResult, type ToolResult } from "./shared.ts";
|
|
17
|
+
|
|
18
|
+
interface AccountsParams {
|
|
19
|
+
action?: string;
|
|
20
|
+
account?: string;
|
|
21
|
+
tenant?: string;
|
|
22
|
+
safetyLevel?: string;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export const teamsAccountsTool = {
|
|
26
|
+
name: "teams_accounts",
|
|
27
|
+
description:
|
|
28
|
+
"Manage configured Teams accounts and their tenants: list them, switch the default, change a safety level, " +
|
|
29
|
+
"or delete one. Use this for multi-account and multi-company setups. " +
|
|
30
|
+
"Actions: 'list' (default), 'use', 'delete', 'safety'.",
|
|
31
|
+
parameters: Type.Object({
|
|
32
|
+
action: Type.Optional(
|
|
33
|
+
Type.String({ description: "'list' (default), 'use', 'delete', or 'safety'" }),
|
|
34
|
+
),
|
|
35
|
+
account: Type.Optional(Type.String({ description: "Account name the action applies to" })),
|
|
36
|
+
tenant: Type.Optional(
|
|
37
|
+
Type.String({ description: "Tenant beneath that account; omit to target the account itself" }),
|
|
38
|
+
),
|
|
39
|
+
safetyLevel: Type.Optional(
|
|
40
|
+
Type.String({ description: "For action 'safety': 'open', 'confirm' or 'readonly'" }),
|
|
41
|
+
),
|
|
42
|
+
}),
|
|
43
|
+
promptSnippet: "List or switch Microsoft Teams accounts",
|
|
44
|
+
promptGuidelines: [
|
|
45
|
+
"Use teams_accounts when the user mentions a different company, tenant, or Teams identity.",
|
|
46
|
+
"Prefer passing account/tenant directly to the tool you need instead of switching the default.",
|
|
47
|
+
],
|
|
48
|
+
|
|
49
|
+
async execute(
|
|
50
|
+
_toolCallId: string,
|
|
51
|
+
params: AccountsParams,
|
|
52
|
+
_signal: AbortSignal | undefined,
|
|
53
|
+
_onUpdate: undefined,
|
|
54
|
+
_ctx: unknown,
|
|
55
|
+
): Promise<ToolResult> {
|
|
56
|
+
return run(async () => {
|
|
57
|
+
const action = (params.action ?? "list").toLowerCase();
|
|
58
|
+
const config = readRootConfig();
|
|
59
|
+
|
|
60
|
+
if (config.accounts.length === 0) {
|
|
61
|
+
return textResult(
|
|
62
|
+
`No Teams accounts configured yet. Run teams_setup, or edit ${getConfigPath()}.`,
|
|
63
|
+
{ accounts: [] },
|
|
64
|
+
);
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
switch (action) {
|
|
68
|
+
case "list": {
|
|
69
|
+
const lines = ["## Configured Teams accounts", ""];
|
|
70
|
+
for (const account of config.accounts) {
|
|
71
|
+
const isDefault = config.defaultAccount === account.name;
|
|
72
|
+
const token = getToken(account.name, account.tenantId);
|
|
73
|
+
const state = isFresh(token)
|
|
74
|
+
? "signed in"
|
|
75
|
+
: token?.refreshToken
|
|
76
|
+
? "session saved"
|
|
77
|
+
: "not signed in";
|
|
78
|
+
|
|
79
|
+
lines.push(
|
|
80
|
+
`### ${account.displayName ?? account.name}${isDefault ? " (default)" : ""}`,
|
|
81
|
+
"",
|
|
82
|
+
`- name: \`${account.name}\``,
|
|
83
|
+
`- tenant: ${account.tenantId}`,
|
|
84
|
+
`- auth mode: ${account.authMode ?? "device-code"}`,
|
|
85
|
+
`- safety: ${account.safetyLevel ?? config.safetyLevel ?? "confirm"}`,
|
|
86
|
+
`- status: ${state}${token?.user?.upn ? ` as ${token.user.upn}` : ""}`,
|
|
87
|
+
);
|
|
88
|
+
|
|
89
|
+
for (const tenant of account.tenants ?? []) {
|
|
90
|
+
const tenantToken = getToken(account.name, tenant.tenantId);
|
|
91
|
+
const tenantState = isFresh(tenantToken)
|
|
92
|
+
? "signed in"
|
|
93
|
+
: tenantToken?.refreshToken
|
|
94
|
+
? "session saved"
|
|
95
|
+
: "not signed in";
|
|
96
|
+
lines.push(
|
|
97
|
+
` - tenant \`${tenant.name}\` (${tenant.tenantId}) — ${tenantState}` +
|
|
98
|
+
`${tenant.safetyLevel ? `, safety: ${tenant.safetyLevel}` : ""}`,
|
|
99
|
+
);
|
|
100
|
+
}
|
|
101
|
+
lines.push("");
|
|
102
|
+
}
|
|
103
|
+
lines.push(`Config: ${getConfigPath()}`);
|
|
104
|
+
return textResult(lines.join("\n"), {
|
|
105
|
+
accounts: config.accounts.map((a) => a.name),
|
|
106
|
+
defaultAccount: config.defaultAccount,
|
|
107
|
+
});
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
case "use": {
|
|
111
|
+
if (!params.account) return errorResult("action 'use' requires an account name.");
|
|
112
|
+
setDefaultAccount(params.account, params.tenant);
|
|
113
|
+
clearUserCache();
|
|
114
|
+
return textResult(
|
|
115
|
+
`✅ Default Teams account is now "${params.account}"${params.tenant ? ` (tenant ${params.tenant})` : ""}.`,
|
|
116
|
+
{ defaultAccount: params.account, defaultTenant: params.tenant },
|
|
117
|
+
);
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
case "delete": {
|
|
121
|
+
if (!params.account) return errorResult("action 'delete' requires an account name.");
|
|
122
|
+
|
|
123
|
+
if (params.tenant) {
|
|
124
|
+
const tenantConfig = config.accounts
|
|
125
|
+
.find((a) => a.name === params.account)
|
|
126
|
+
?.tenants?.find((t) => t.name === params.tenant);
|
|
127
|
+
const removed = removeTenant(params.account, params.tenant);
|
|
128
|
+
if (removed && tenantConfig) deleteToken(params.account, tenantConfig.tenantId);
|
|
129
|
+
return removed
|
|
130
|
+
? textResult(`✅ Removed tenant "${params.tenant}" from account "${params.account}".`, {})
|
|
131
|
+
: errorResult(`Tenant "${params.tenant}" not found in account "${params.account}".`);
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
const accountConfig = config.accounts.find((a) => a.name === params.account);
|
|
135
|
+
const removed = removeAccount(params.account);
|
|
136
|
+
if (removed && accountConfig) {
|
|
137
|
+
deleteToken(params.account, accountConfig.tenantId);
|
|
138
|
+
for (const tenant of accountConfig.tenants ?? []) {
|
|
139
|
+
deleteToken(params.account, tenant.tenantId);
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
clearUserCache();
|
|
143
|
+
return removed
|
|
144
|
+
? textResult(`✅ Removed account "${params.account}" and its cached sessions.`, {})
|
|
145
|
+
: errorResult(`Account "${params.account}" not found.`);
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
case "safety": {
|
|
149
|
+
const level = validateSafetyLevel(params.safetyLevel);
|
|
150
|
+
if (!level) {
|
|
151
|
+
return errorResult(
|
|
152
|
+
`action 'safety' requires safetyLevel to be "open", "confirm" or "readonly".`,
|
|
153
|
+
);
|
|
154
|
+
}
|
|
155
|
+
setSafetyLevel(level, params.account, params.tenant);
|
|
156
|
+
const target = params.account
|
|
157
|
+
? params.tenant
|
|
158
|
+
? `${params.account}/${params.tenant}`
|
|
159
|
+
: params.account
|
|
160
|
+
: "globally";
|
|
161
|
+
return textResult(`✅ Safety level set to "${level}" ${target}.`, { safetyLevel: level });
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
default:
|
|
165
|
+
return errorResult(`Unknown action "${action}". Use 'list', 'use', 'delete' or 'safety'.`);
|
|
166
|
+
}
|
|
167
|
+
});
|
|
168
|
+
},
|
|
169
|
+
};
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* teams_create_channel — add a channel to a team.
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
import { Type } from "typebox";
|
|
6
|
+
import { createChannel } from "../graph/teams.ts";
|
|
7
|
+
import { auditWrite } from "../safety/audit.ts";
|
|
8
|
+
import { requireTeam } from "./resolve.ts";
|
|
9
|
+
import {
|
|
10
|
+
AccountParam,
|
|
11
|
+
TenantParam,
|
|
12
|
+
connectionFor,
|
|
13
|
+
currentUser,
|
|
14
|
+
run,
|
|
15
|
+
textResult,
|
|
16
|
+
type ToolContext,
|
|
17
|
+
type ToolResult,
|
|
18
|
+
} from "./shared.ts";
|
|
19
|
+
|
|
20
|
+
export const teamsCreateChannelTool = {
|
|
21
|
+
name: "teams_create_channel",
|
|
22
|
+
description:
|
|
23
|
+
"Create a new channel in a Microsoft Teams team. membershipType 'standard' (default) is visible to the " +
|
|
24
|
+
"whole team, 'private' only to the members you add afterwards.",
|
|
25
|
+
parameters: Type.Object({
|
|
26
|
+
team: Type.String({ description: "Team name or ID" }),
|
|
27
|
+
name: Type.String({ description: "Channel display name" }),
|
|
28
|
+
description: Type.Optional(Type.String({ description: "Channel description" })),
|
|
29
|
+
membershipType: Type.Optional(
|
|
30
|
+
Type.String({ description: "'standard' (default) or 'private'" }),
|
|
31
|
+
),
|
|
32
|
+
account: AccountParam,
|
|
33
|
+
tenant: TenantParam,
|
|
34
|
+
}),
|
|
35
|
+
promptSnippet: "Create a channel in a Teams team",
|
|
36
|
+
|
|
37
|
+
async execute(
|
|
38
|
+
_toolCallId: string,
|
|
39
|
+
params: {
|
|
40
|
+
team: string;
|
|
41
|
+
name: string;
|
|
42
|
+
description?: string;
|
|
43
|
+
membershipType?: string;
|
|
44
|
+
account?: string;
|
|
45
|
+
tenant?: string;
|
|
46
|
+
},
|
|
47
|
+
signal: AbortSignal | undefined,
|
|
48
|
+
_onUpdate: undefined,
|
|
49
|
+
ctx: ToolContext,
|
|
50
|
+
): Promise<ToolResult> {
|
|
51
|
+
return run(async () => {
|
|
52
|
+
const conn = connectionFor(ctx, params.account, params.tenant);
|
|
53
|
+
const team = await requireTeam(conn, params.team, "write", signal);
|
|
54
|
+
|
|
55
|
+
const channel = await createChannel(
|
|
56
|
+
conn,
|
|
57
|
+
team,
|
|
58
|
+
{
|
|
59
|
+
displayName: params.name,
|
|
60
|
+
description: params.description,
|
|
61
|
+
membershipType: params.membershipType,
|
|
62
|
+
},
|
|
63
|
+
signal,
|
|
64
|
+
);
|
|
65
|
+
|
|
66
|
+
const me = await currentUser(conn, signal).catch(() => undefined);
|
|
67
|
+
auditWrite(conn.audit, {
|
|
68
|
+
tool: "teams_create_channel",
|
|
69
|
+
account: conn.account,
|
|
70
|
+
tenant: conn.tenant,
|
|
71
|
+
actor: me?.upn,
|
|
72
|
+
target: `team:${team.displayName}`,
|
|
73
|
+
summary: `created channel "${params.name}" (${params.membershipType ?? "standard"})`,
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
return textResult(
|
|
77
|
+
[
|
|
78
|
+
`✅ Channel **${team.displayName}/${channel.displayName}** created.`,
|
|
79
|
+
"",
|
|
80
|
+
`channelId: ${channel.id}`,
|
|
81
|
+
channel.webUrl ? `Open in Teams: ${channel.webUrl}` : "",
|
|
82
|
+
]
|
|
83
|
+
.filter(Boolean)
|
|
84
|
+
.join("\n"),
|
|
85
|
+
{ teamId: team.id, channelId: channel.id },
|
|
86
|
+
);
|
|
87
|
+
});
|
|
88
|
+
},
|
|
89
|
+
};
|
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* teams_create_chat — start a new 1:1 or group chat.
|
|
3
|
+
*
|
|
4
|
+
* Teams reuses an existing 1:1 conversation, so calling this for "message
|
|
5
|
+
* Anna" does not litter the user's chat list with duplicates.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import { Type } from "typebox";
|
|
9
|
+
import { createChat, resolveParticipants } from "../graph/chats.ts";
|
|
10
|
+
import { sendChatMessage } from "../graph/messages.ts";
|
|
11
|
+
import { auditWrite } from "../safety/audit.ts";
|
|
12
|
+
import { assertAccess } from "../safety/index.ts";
|
|
13
|
+
import { truncate } from "../utils/formatting.ts";
|
|
14
|
+
import {
|
|
15
|
+
AccountParam,
|
|
16
|
+
TenantParam,
|
|
17
|
+
connectionFor,
|
|
18
|
+
currentUser,
|
|
19
|
+
errorResult,
|
|
20
|
+
run,
|
|
21
|
+
textResult,
|
|
22
|
+
type ToolContext,
|
|
23
|
+
type ToolResult,
|
|
24
|
+
} from "./shared.ts";
|
|
25
|
+
|
|
26
|
+
interface CreateChatParams {
|
|
27
|
+
participants: string[];
|
|
28
|
+
topic?: string;
|
|
29
|
+
message?: string;
|
|
30
|
+
account?: string;
|
|
31
|
+
tenant?: string;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export const teamsCreateChatTool = {
|
|
35
|
+
name: "teams_create_chat",
|
|
36
|
+
description:
|
|
37
|
+
"Start a new Microsoft Teams chat with one or more people, optionally sending the first message right " +
|
|
38
|
+
"away. Participants are given as names, e-mail addresses or user principal names. " +
|
|
39
|
+
"For one participant, Teams reuses the existing 1:1 chat if there is one.",
|
|
40
|
+
parameters: Type.Object({
|
|
41
|
+
participants: Type.Array(Type.String(), {
|
|
42
|
+
description: "People to include, by name, e-mail or UPN",
|
|
43
|
+
}),
|
|
44
|
+
topic: Type.Optional(Type.String({ description: "Group chat topic (group chats only)" })),
|
|
45
|
+
message: Type.Optional(Type.String({ description: "First message to send into the new chat" })),
|
|
46
|
+
account: AccountParam,
|
|
47
|
+
tenant: TenantParam,
|
|
48
|
+
}),
|
|
49
|
+
promptSnippet: "Start a new Teams chat",
|
|
50
|
+
|
|
51
|
+
async execute(
|
|
52
|
+
_toolCallId: string,
|
|
53
|
+
params: CreateChatParams,
|
|
54
|
+
signal: AbortSignal | undefined,
|
|
55
|
+
_onUpdate: undefined,
|
|
56
|
+
ctx: ToolContext,
|
|
57
|
+
): Promise<ToolResult> {
|
|
58
|
+
return run(async () => {
|
|
59
|
+
const conn = connectionFor(ctx, params.account, params.tenant);
|
|
60
|
+
|
|
61
|
+
if (!params.participants?.length) {
|
|
62
|
+
return errorResult("At least one participant is required.");
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
const { resolved, unresolved } = await resolveParticipants(conn, params.participants, signal);
|
|
66
|
+
if (unresolved.length > 0) {
|
|
67
|
+
return errorResult(
|
|
68
|
+
`Could not resolve: ${unresolved.join(", ")}. Use teams_find_user to look them up.`,
|
|
69
|
+
);
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
for (const person of resolved) {
|
|
73
|
+
assertAccess(conn, "write", "people", person.displayName, [
|
|
74
|
+
person.displayName,
|
|
75
|
+
person.upn,
|
|
76
|
+
person.mail,
|
|
77
|
+
person.id,
|
|
78
|
+
]);
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
const chat = await createChat(conn, resolved, { topic: params.topic, signal });
|
|
82
|
+
const me = await currentUser(conn, signal).catch(() => undefined);
|
|
83
|
+
const label = params.topic ?? resolved.map((p) => p.displayName).join(", ");
|
|
84
|
+
|
|
85
|
+
auditWrite(conn.audit, {
|
|
86
|
+
tool: "teams_create_chat",
|
|
87
|
+
account: conn.account,
|
|
88
|
+
tenant: conn.tenant,
|
|
89
|
+
actor: me?.upn,
|
|
90
|
+
target: `chat:${label}`,
|
|
91
|
+
summary: `created chat with ${resolved.map((p) => p.upn ?? p.displayName).join(", ")}`,
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
const lines = [`✅ Chat with **${label}** ready.`, "", `chatId: ${chat.id}`];
|
|
95
|
+
|
|
96
|
+
if (params.message?.trim()) {
|
|
97
|
+
const sent = await sendChatMessage(conn, chat.id, { body: params.message }, signal);
|
|
98
|
+
auditWrite(conn.audit, {
|
|
99
|
+
tool: "teams_create_chat",
|
|
100
|
+
account: conn.account,
|
|
101
|
+
tenant: conn.tenant,
|
|
102
|
+
actor: me?.upn,
|
|
103
|
+
target: `chat:${label}`,
|
|
104
|
+
summary: truncate(params.message, 200),
|
|
105
|
+
});
|
|
106
|
+
lines.push("", `Sent as ${me?.displayName ?? "you"}:`, "", `> ${truncate(params.message, 300)}`, "", `messageId: ${sent.id}`);
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
return textResult(lines.join("\n"), { chatId: chat.id, participants: resolved.length });
|
|
110
|
+
});
|
|
111
|
+
},
|
|
112
|
+
};
|