@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,88 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* teams_read_thread — the opening post and all replies of one channel thread.
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
import { Type } from "typebox";
|
|
6
|
+
import { getChannelMessage, listChannelReplies } from "../graph/messages.ts";
|
|
7
|
+
import { formatMessage } from "../utils/formatting.ts";
|
|
8
|
+
import { requireChannel } from "./resolve.ts";
|
|
9
|
+
import {
|
|
10
|
+
AccountParam,
|
|
11
|
+
LimitParam,
|
|
12
|
+
TenantParam,
|
|
13
|
+
connectionFor,
|
|
14
|
+
errorResult,
|
|
15
|
+
run,
|
|
16
|
+
textResult,
|
|
17
|
+
type ToolContext,
|
|
18
|
+
type ToolResult,
|
|
19
|
+
} from "./shared.ts";
|
|
20
|
+
|
|
21
|
+
export const teamsReadThreadTool = {
|
|
22
|
+
name: "teams_read_thread",
|
|
23
|
+
description:
|
|
24
|
+
"Read one conversation thread in a Microsoft Teams channel: the opening post plus every reply, " +
|
|
25
|
+
"oldest first. Get the messageId from teams_read_channel or teams_search_messages.",
|
|
26
|
+
parameters: Type.Object({
|
|
27
|
+
channel: Type.String({ description: "Channel name or ID, or a 'Team/Channel' path" }),
|
|
28
|
+
messageId: Type.String({ description: "ID of the thread's opening message" }),
|
|
29
|
+
team: Type.Optional(Type.String({ description: "Team name or ID (omit when using a path)" })),
|
|
30
|
+
account: AccountParam,
|
|
31
|
+
tenant: TenantParam,
|
|
32
|
+
limit: LimitParam,
|
|
33
|
+
}),
|
|
34
|
+
promptSnippet: "Read a Teams channel thread with all replies",
|
|
35
|
+
|
|
36
|
+
async execute(
|
|
37
|
+
_toolCallId: string,
|
|
38
|
+
params: {
|
|
39
|
+
channel: string;
|
|
40
|
+
messageId: string;
|
|
41
|
+
team?: string;
|
|
42
|
+
account?: string;
|
|
43
|
+
tenant?: string;
|
|
44
|
+
limit?: number;
|
|
45
|
+
},
|
|
46
|
+
signal: AbortSignal | undefined,
|
|
47
|
+
_onUpdate: undefined,
|
|
48
|
+
ctx: ToolContext,
|
|
49
|
+
): Promise<ToolResult> {
|
|
50
|
+
return run(async () => {
|
|
51
|
+
const conn = connectionFor(ctx, params.account, params.tenant);
|
|
52
|
+
const { team, channel } = await requireChannel(conn, params.team, params.channel, "read", signal);
|
|
53
|
+
|
|
54
|
+
const root = await getChannelMessage(conn, channel, params.messageId, signal);
|
|
55
|
+
if (!root) {
|
|
56
|
+
return errorResult(
|
|
57
|
+
`Message ${params.messageId} not found in ${team.displayName}/${channel.displayName}.`,
|
|
58
|
+
);
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
const replies = await listChannelReplies(conn, channel, params.messageId, {
|
|
62
|
+
max: params.limit ?? 50,
|
|
63
|
+
signal,
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
const lines = [
|
|
67
|
+
`## Thread in ${team.displayName}/${channel.displayName}`,
|
|
68
|
+
"",
|
|
69
|
+
formatMessage(root),
|
|
70
|
+
"",
|
|
71
|
+
`### ${replies.length} repl${replies.length === 1 ? "y" : "ies"}`,
|
|
72
|
+
"",
|
|
73
|
+
];
|
|
74
|
+
|
|
75
|
+
// Graph returns replies newest first; a conversation reads the other way.
|
|
76
|
+
for (const reply of [...replies].reverse()) {
|
|
77
|
+
lines.push(formatMessage(reply), "");
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
return textResult(lines.join("\n").trimEnd(), {
|
|
81
|
+
teamId: team.id,
|
|
82
|
+
channelId: channel.id,
|
|
83
|
+
messageId: params.messageId,
|
|
84
|
+
replyCount: replies.length,
|
|
85
|
+
});
|
|
86
|
+
});
|
|
87
|
+
},
|
|
88
|
+
};
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* teams_search_messages — full-text search across the user's Teams history.
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
import { Type } from "typebox";
|
|
6
|
+
import { formatSearchHits, searchMessages } from "../graph/search.ts";
|
|
7
|
+
import { hasAccess } from "../safety/index.ts";
|
|
8
|
+
import {
|
|
9
|
+
AccountParam,
|
|
10
|
+
LimitParam,
|
|
11
|
+
TenantParam,
|
|
12
|
+
connectionFor,
|
|
13
|
+
run,
|
|
14
|
+
textResult,
|
|
15
|
+
type ToolContext,
|
|
16
|
+
type ToolResult,
|
|
17
|
+
} from "./shared.ts";
|
|
18
|
+
|
|
19
|
+
export const teamsSearchMessagesTool = {
|
|
20
|
+
name: "teams_search_messages",
|
|
21
|
+
description:
|
|
22
|
+
"Search the signed-in user's Microsoft Teams messages — chats and channels — with the Microsoft Search " +
|
|
23
|
+
"API. Plain keywords work; so do qualifiers such as from:anna@contoso.com. " +
|
|
24
|
+
"Use this to find a conversation before reading or replying to it.",
|
|
25
|
+
parameters: Type.Object({
|
|
26
|
+
query: Type.String({ description: "Search terms, e.g. 'deployment friday' or 'from:anna@contoso.com budget'" }),
|
|
27
|
+
account: AccountParam,
|
|
28
|
+
tenant: TenantParam,
|
|
29
|
+
limit: LimitParam,
|
|
30
|
+
}),
|
|
31
|
+
promptSnippet: "Search the user's Teams messages",
|
|
32
|
+
promptGuidelines: [
|
|
33
|
+
"Use teams_search_messages when the user refers to something said in Teams without naming the chat or channel.",
|
|
34
|
+
"Search hits carry messageId plus the chat or channel ID — pass those to teams_read_thread or teams_read_chat for full context.",
|
|
35
|
+
],
|
|
36
|
+
|
|
37
|
+
async execute(
|
|
38
|
+
_toolCallId: string,
|
|
39
|
+
params: { query: string; account?: string; tenant?: string; limit?: number },
|
|
40
|
+
signal: AbortSignal | undefined,
|
|
41
|
+
_onUpdate: undefined,
|
|
42
|
+
ctx: ToolContext,
|
|
43
|
+
): Promise<ToolResult> {
|
|
44
|
+
return run(async () => {
|
|
45
|
+
const conn = connectionFor(ctx, params.account, params.tenant);
|
|
46
|
+
const hits = await searchMessages(conn, params.query, { max: params.limit ?? 25, signal });
|
|
47
|
+
|
|
48
|
+
// Search bypasses the listing endpoints, so the rules are applied to the
|
|
49
|
+
// results: a denied channel must not leak through a search box.
|
|
50
|
+
const visible = hits.filter((hit) => {
|
|
51
|
+
if (hit.channelIdentity?.channelId) {
|
|
52
|
+
return hasAccess(conn, "read", "channels", [
|
|
53
|
+
hit.channelIdentity.channelId,
|
|
54
|
+
hit.channelIdentity.teamId,
|
|
55
|
+
]);
|
|
56
|
+
}
|
|
57
|
+
if (hit.chatId) return hasAccess(conn, "read", "chats", [hit.chatId]);
|
|
58
|
+
return true;
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
const hidden = hits.length - visible.length;
|
|
62
|
+
const note = hidden > 0 ? `\n\n_${hidden} hit(s) hidden by your scope rules._` : "";
|
|
63
|
+
|
|
64
|
+
return textResult(`${formatSearchHits(visible, params.query)}${note}`, {
|
|
65
|
+
query: params.query,
|
|
66
|
+
count: visible.length,
|
|
67
|
+
hidden,
|
|
68
|
+
});
|
|
69
|
+
});
|
|
70
|
+
},
|
|
71
|
+
};
|
|
@@ -0,0 +1,250 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* teams_send_channel_message — start a new post in a channel, as the user.
|
|
3
|
+
* teams_reply_channel_message — reply inside an existing thread.
|
|
4
|
+
*
|
|
5
|
+
* Both live here because they share the mention resolution and the audit
|
|
6
|
+
* shape; splitting them would mean two copies of the part that matters.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import { Type } from "typebox";
|
|
10
|
+
import { replyToChannelMessage, sendChannelMessage } from "../graph/messages.ts";
|
|
11
|
+
import { resolveUserId } from "../graph/me.ts";
|
|
12
|
+
import { auditWrite } from "../safety/audit.ts";
|
|
13
|
+
import { assertAccess } from "../safety/index.ts";
|
|
14
|
+
import { truncate } from "../utils/formatting.ts";
|
|
15
|
+
import { requireChannel } from "./resolve.ts";
|
|
16
|
+
import {
|
|
17
|
+
AccountParam,
|
|
18
|
+
TenantParam,
|
|
19
|
+
connectionFor,
|
|
20
|
+
connectionLabel,
|
|
21
|
+
currentUser,
|
|
22
|
+
errorResult,
|
|
23
|
+
run,
|
|
24
|
+
textResult,
|
|
25
|
+
type ToolContext,
|
|
26
|
+
type ToolResult,
|
|
27
|
+
} from "./shared.ts";
|
|
28
|
+
import type { PersonRef } from "../types.ts";
|
|
29
|
+
import type { TeamsConnection } from "../config/index.ts";
|
|
30
|
+
|
|
31
|
+
/** Resolve mention references and check each against the write rules. */
|
|
32
|
+
async function resolveMentions(
|
|
33
|
+
conn: TeamsConnection,
|
|
34
|
+
references: string[] | undefined,
|
|
35
|
+
signal?: AbortSignal,
|
|
36
|
+
): Promise<{ mentions: PersonRef[]; error?: string }> {
|
|
37
|
+
const mentions: PersonRef[] = [];
|
|
38
|
+
for (const reference of references ?? []) {
|
|
39
|
+
const person = await resolveUserId(conn, reference, signal);
|
|
40
|
+
if (!person) {
|
|
41
|
+
return {
|
|
42
|
+
mentions,
|
|
43
|
+
error: `Could not resolve "${reference}" to a person. Use teams_find_user to look them up.`,
|
|
44
|
+
};
|
|
45
|
+
}
|
|
46
|
+
assertAccess(conn, "write", "people", person.displayName, [
|
|
47
|
+
person.displayName,
|
|
48
|
+
person.upn,
|
|
49
|
+
person.mail,
|
|
50
|
+
person.id,
|
|
51
|
+
]);
|
|
52
|
+
mentions.push(person);
|
|
53
|
+
}
|
|
54
|
+
return { mentions };
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
const sharedParams = {
|
|
58
|
+
channel: Type.String({ description: "Channel name or ID, or a 'Team/Channel' path" }),
|
|
59
|
+
team: Type.Optional(Type.String({ description: "Team name or ID (omit when using a path)" })),
|
|
60
|
+
body: Type.String({ description: "Message text. Plain text unless 'html' is true." }),
|
|
61
|
+
account: AccountParam,
|
|
62
|
+
tenant: TenantParam,
|
|
63
|
+
mentions: Type.Optional(
|
|
64
|
+
Type.Array(Type.String(), { description: "People to @-mention, by name, UPN or e-mail" }),
|
|
65
|
+
),
|
|
66
|
+
importance: Type.Optional(Type.String({ description: "'normal' (default), 'high' or 'urgent'" })),
|
|
67
|
+
html: Type.Optional(Type.Boolean({ description: "Treat 'body' as raw HTML instead of plain text" })),
|
|
68
|
+
};
|
|
69
|
+
|
|
70
|
+
export const teamsSendChannelMessageTool = {
|
|
71
|
+
name: "teams_send_channel_message",
|
|
72
|
+
description:
|
|
73
|
+
"Post a new message in a Microsoft Teams channel as the signed-in user. It appears exactly as if the " +
|
|
74
|
+
"user posted it. Give the channel as a 'Team/Channel' path or pass 'team' separately. " +
|
|
75
|
+
"'subject' starts a titled post; use teams_reply_channel_message to answer inside an existing thread.",
|
|
76
|
+
parameters: Type.Object({
|
|
77
|
+
...sharedParams,
|
|
78
|
+
subject: Type.Optional(Type.String({ description: "Optional post title" })),
|
|
79
|
+
}),
|
|
80
|
+
promptSnippet: "Post a message in a Teams channel as the user",
|
|
81
|
+
promptGuidelines: [
|
|
82
|
+
"Write in the user's voice — the post is attributed to them, not to an assistant.",
|
|
83
|
+
"Show the exact text and the target channel before posting when there is any ambiguity.",
|
|
84
|
+
],
|
|
85
|
+
|
|
86
|
+
async execute(
|
|
87
|
+
_toolCallId: string,
|
|
88
|
+
params: {
|
|
89
|
+
channel: string;
|
|
90
|
+
team?: string;
|
|
91
|
+
body: string;
|
|
92
|
+
subject?: string;
|
|
93
|
+
account?: string;
|
|
94
|
+
tenant?: string;
|
|
95
|
+
mentions?: string[];
|
|
96
|
+
importance?: string;
|
|
97
|
+
html?: boolean;
|
|
98
|
+
},
|
|
99
|
+
signal: AbortSignal | undefined,
|
|
100
|
+
_onUpdate: undefined,
|
|
101
|
+
ctx: ToolContext,
|
|
102
|
+
): Promise<ToolResult> {
|
|
103
|
+
return run(async () => {
|
|
104
|
+
const conn = connectionFor(ctx, params.account, params.tenant);
|
|
105
|
+
if (!params.body.trim()) return errorResult("Refusing to post an empty message.");
|
|
106
|
+
|
|
107
|
+
const { team, channel } = await requireChannel(conn, params.team, params.channel, "write", signal);
|
|
108
|
+
const { mentions, error } = await resolveMentions(conn, params.mentions, signal);
|
|
109
|
+
if (error) return errorResult(error);
|
|
110
|
+
|
|
111
|
+
const me = await currentUser(conn, signal).catch(() => undefined);
|
|
112
|
+
const target = `${team.displayName}/${channel.displayName}`;
|
|
113
|
+
|
|
114
|
+
try {
|
|
115
|
+
const sent = await sendChannelMessage(
|
|
116
|
+
conn,
|
|
117
|
+
channel,
|
|
118
|
+
{
|
|
119
|
+
body: params.body,
|
|
120
|
+
html: params.html,
|
|
121
|
+
subject: params.subject,
|
|
122
|
+
importance: params.importance,
|
|
123
|
+
mentions,
|
|
124
|
+
},
|
|
125
|
+
signal,
|
|
126
|
+
);
|
|
127
|
+
|
|
128
|
+
auditWrite(conn.audit, {
|
|
129
|
+
tool: "teams_send_channel_message",
|
|
130
|
+
account: conn.account,
|
|
131
|
+
tenant: conn.tenant,
|
|
132
|
+
actor: me?.upn,
|
|
133
|
+
target: `channel:${target}`,
|
|
134
|
+
summary: truncate(params.body, 200),
|
|
135
|
+
});
|
|
136
|
+
|
|
137
|
+
return textResult(
|
|
138
|
+
[
|
|
139
|
+
`✅ Posted in **${target}** as ${me?.displayName ?? "you"} (${connectionLabel(conn)}).`,
|
|
140
|
+
"",
|
|
141
|
+
`> ${truncate(params.body, 300)}`,
|
|
142
|
+
"",
|
|
143
|
+
`messageId: ${sent.id}`,
|
|
144
|
+
].join("\n"),
|
|
145
|
+
{ teamId: team.id, channelId: channel.id, messageId: sent.id },
|
|
146
|
+
);
|
|
147
|
+
} catch (err) {
|
|
148
|
+
auditWrite(conn.audit, {
|
|
149
|
+
tool: "teams_send_channel_message",
|
|
150
|
+
account: conn.account,
|
|
151
|
+
tenant: conn.tenant,
|
|
152
|
+
actor: me?.upn,
|
|
153
|
+
target: `channel:${target}`,
|
|
154
|
+
summary: truncate(params.body, 200),
|
|
155
|
+
error: err instanceof Error ? err.message : String(err),
|
|
156
|
+
});
|
|
157
|
+
throw err;
|
|
158
|
+
}
|
|
159
|
+
});
|
|
160
|
+
},
|
|
161
|
+
};
|
|
162
|
+
|
|
163
|
+
export const teamsReplyChannelMessageTool = {
|
|
164
|
+
name: "teams_reply_channel_message",
|
|
165
|
+
description:
|
|
166
|
+
"Reply inside an existing Microsoft Teams channel thread as the signed-in user. " +
|
|
167
|
+
"Get the messageId from teams_read_channel, teams_read_thread or teams_search_messages. " +
|
|
168
|
+
"Replying keeps the conversation in one thread instead of starting a new post.",
|
|
169
|
+
parameters: Type.Object({
|
|
170
|
+
...sharedParams,
|
|
171
|
+
messageId: Type.String({ description: "ID of the thread's opening message" }),
|
|
172
|
+
}),
|
|
173
|
+
promptSnippet: "Reply in a Teams channel thread as the user",
|
|
174
|
+
|
|
175
|
+
async execute(
|
|
176
|
+
_toolCallId: string,
|
|
177
|
+
params: {
|
|
178
|
+
channel: string;
|
|
179
|
+
team?: string;
|
|
180
|
+
messageId: string;
|
|
181
|
+
body: string;
|
|
182
|
+
account?: string;
|
|
183
|
+
tenant?: string;
|
|
184
|
+
mentions?: string[];
|
|
185
|
+
importance?: string;
|
|
186
|
+
html?: boolean;
|
|
187
|
+
},
|
|
188
|
+
signal: AbortSignal | undefined,
|
|
189
|
+
_onUpdate: undefined,
|
|
190
|
+
ctx: ToolContext,
|
|
191
|
+
): Promise<ToolResult> {
|
|
192
|
+
return run(async () => {
|
|
193
|
+
const conn = connectionFor(ctx, params.account, params.tenant);
|
|
194
|
+
if (!params.body.trim()) return errorResult("Refusing to post an empty reply.");
|
|
195
|
+
|
|
196
|
+
const { team, channel } = await requireChannel(conn, params.team, params.channel, "write", signal);
|
|
197
|
+
const { mentions, error } = await resolveMentions(conn, params.mentions, signal);
|
|
198
|
+
if (error) return errorResult(error);
|
|
199
|
+
|
|
200
|
+
const me = await currentUser(conn, signal).catch(() => undefined);
|
|
201
|
+
const target = `${team.displayName}/${channel.displayName}`;
|
|
202
|
+
|
|
203
|
+
try {
|
|
204
|
+
const sent = await replyToChannelMessage(
|
|
205
|
+
conn,
|
|
206
|
+
channel,
|
|
207
|
+
params.messageId,
|
|
208
|
+
{
|
|
209
|
+
body: params.body,
|
|
210
|
+
html: params.html,
|
|
211
|
+
importance: params.importance,
|
|
212
|
+
mentions,
|
|
213
|
+
},
|
|
214
|
+
signal,
|
|
215
|
+
);
|
|
216
|
+
|
|
217
|
+
auditWrite(conn.audit, {
|
|
218
|
+
tool: "teams_reply_channel_message",
|
|
219
|
+
account: conn.account,
|
|
220
|
+
tenant: conn.tenant,
|
|
221
|
+
actor: me?.upn,
|
|
222
|
+
target: `channel:${target}#${params.messageId}`,
|
|
223
|
+
summary: truncate(params.body, 200),
|
|
224
|
+
});
|
|
225
|
+
|
|
226
|
+
return textResult(
|
|
227
|
+
[
|
|
228
|
+
`✅ Replied in **${target}** (thread ${params.messageId}) as ${me?.displayName ?? "you"}.`,
|
|
229
|
+
"",
|
|
230
|
+
`> ${truncate(params.body, 300)}`,
|
|
231
|
+
"",
|
|
232
|
+
`messageId: ${sent.id}`,
|
|
233
|
+
].join("\n"),
|
|
234
|
+
{ teamId: team.id, channelId: channel.id, messageId: sent.id, threadId: params.messageId },
|
|
235
|
+
);
|
|
236
|
+
} catch (err) {
|
|
237
|
+
auditWrite(conn.audit, {
|
|
238
|
+
tool: "teams_reply_channel_message",
|
|
239
|
+
account: conn.account,
|
|
240
|
+
tenant: conn.tenant,
|
|
241
|
+
actor: me?.upn,
|
|
242
|
+
target: `channel:${target}#${params.messageId}`,
|
|
243
|
+
summary: truncate(params.body, 200),
|
|
244
|
+
error: err instanceof Error ? err.message : String(err),
|
|
245
|
+
});
|
|
246
|
+
throw err;
|
|
247
|
+
}
|
|
248
|
+
});
|
|
249
|
+
},
|
|
250
|
+
};
|
|
@@ -0,0 +1,152 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* teams_send_chat_message — send a message in a chat, as the user.
|
|
3
|
+
*
|
|
4
|
+
* This is the sharpest tool in the box: what it writes is indistinguishable
|
|
5
|
+
* from the user typing it. Hence the order of operations — resolve the chat,
|
|
6
|
+
* check the chat rules, check every participant against the people rules,
|
|
7
|
+
* resolve the mentions, send, then record the audit entry.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
import { Type } from "typebox";
|
|
11
|
+
import { sendChatMessage } from "../graph/messages.ts";
|
|
12
|
+
import { resolveUserId } from "../graph/me.ts";
|
|
13
|
+
import { auditWrite } from "../safety/audit.ts";
|
|
14
|
+
import { assertAccess } from "../safety/index.ts";
|
|
15
|
+
import { truncate } from "../utils/formatting.ts";
|
|
16
|
+
import { requireChat } from "./resolve.ts";
|
|
17
|
+
import {
|
|
18
|
+
AccountParam,
|
|
19
|
+
TenantParam,
|
|
20
|
+
connectionFor,
|
|
21
|
+
connectionLabel,
|
|
22
|
+
currentUser,
|
|
23
|
+
errorResult,
|
|
24
|
+
run,
|
|
25
|
+
textResult,
|
|
26
|
+
type ToolContext,
|
|
27
|
+
type ToolResult,
|
|
28
|
+
} from "./shared.ts";
|
|
29
|
+
|
|
30
|
+
interface SendChatParams {
|
|
31
|
+
chat: string;
|
|
32
|
+
body: string;
|
|
33
|
+
account?: string;
|
|
34
|
+
tenant?: string;
|
|
35
|
+
mentions?: string[];
|
|
36
|
+
importance?: string;
|
|
37
|
+
html?: boolean;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export const teamsSendChatMessageTool = {
|
|
41
|
+
name: "teams_send_chat_message",
|
|
42
|
+
description:
|
|
43
|
+
"Send a message in a Microsoft Teams chat as the signed-in user. The message appears exactly as if the " +
|
|
44
|
+
"user typed it. Accepts a chat ID, group chat topic, or participant name/e-mail. " +
|
|
45
|
+
"Use 'mentions' with names or e-mail addresses to @-mention people — write '@Display Name' in the body " +
|
|
46
|
+
"where the mention should appear.",
|
|
47
|
+
parameters: Type.Object({
|
|
48
|
+
chat: Type.String({ description: "Chat ID, group chat topic, or a participant's name/e-mail" }),
|
|
49
|
+
body: Type.String({ description: "Message text. Plain text unless 'html' is true." }),
|
|
50
|
+
account: AccountParam,
|
|
51
|
+
tenant: TenantParam,
|
|
52
|
+
mentions: Type.Optional(
|
|
53
|
+
Type.Array(Type.String(), {
|
|
54
|
+
description: "People to @-mention, by name, UPN or e-mail",
|
|
55
|
+
}),
|
|
56
|
+
),
|
|
57
|
+
importance: Type.Optional(
|
|
58
|
+
Type.String({ description: "'normal' (default), 'high' or 'urgent'" }),
|
|
59
|
+
),
|
|
60
|
+
html: Type.Optional(
|
|
61
|
+
Type.Boolean({ description: "Treat 'body' as raw HTML instead of plain text" }),
|
|
62
|
+
),
|
|
63
|
+
}),
|
|
64
|
+
promptSnippet: "Send a message in a Teams chat as the user",
|
|
65
|
+
promptGuidelines: [
|
|
66
|
+
"Write the message in the user's voice — it is sent from their account, not from an assistant.",
|
|
67
|
+
"Show the user the exact text before sending when the intent is even slightly ambiguous.",
|
|
68
|
+
"Do not add signatures, disclaimers, or a note that the message was written by an AI unless the user asks.",
|
|
69
|
+
],
|
|
70
|
+
|
|
71
|
+
async execute(
|
|
72
|
+
_toolCallId: string,
|
|
73
|
+
params: SendChatParams,
|
|
74
|
+
signal: AbortSignal | undefined,
|
|
75
|
+
_onUpdate: undefined,
|
|
76
|
+
ctx: ToolContext,
|
|
77
|
+
): Promise<ToolResult> {
|
|
78
|
+
return run(async () => {
|
|
79
|
+
const conn = connectionFor(ctx, params.account, params.tenant);
|
|
80
|
+
|
|
81
|
+
if (!params.body.trim()) return errorResult("Refusing to send an empty message.");
|
|
82
|
+
|
|
83
|
+
const chat = await requireChat(conn, params.chat, "write", signal);
|
|
84
|
+
|
|
85
|
+
// Mentions address people directly, so they are checked individually
|
|
86
|
+
// even when the chat itself is allowed.
|
|
87
|
+
const mentions = [];
|
|
88
|
+
for (const reference of params.mentions ?? []) {
|
|
89
|
+
const person = await resolveUserId(conn, reference, signal);
|
|
90
|
+
if (!person) {
|
|
91
|
+
return errorResult(
|
|
92
|
+
`Could not resolve "${reference}" to a person. Use teams_find_user to look them up.`,
|
|
93
|
+
);
|
|
94
|
+
}
|
|
95
|
+
assertAccess(conn, "write", "people", person.displayName, [
|
|
96
|
+
person.displayName,
|
|
97
|
+
person.upn,
|
|
98
|
+
person.mail,
|
|
99
|
+
person.id,
|
|
100
|
+
]);
|
|
101
|
+
mentions.push(person);
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
const me = await currentUser(conn, signal).catch(() => undefined);
|
|
105
|
+
|
|
106
|
+
try {
|
|
107
|
+
const sent = await sendChatMessage(
|
|
108
|
+
conn,
|
|
109
|
+
chat.id,
|
|
110
|
+
{
|
|
111
|
+
body: params.body,
|
|
112
|
+
html: params.html,
|
|
113
|
+
importance: params.importance,
|
|
114
|
+
mentions,
|
|
115
|
+
},
|
|
116
|
+
signal,
|
|
117
|
+
);
|
|
118
|
+
|
|
119
|
+
auditWrite(conn.audit, {
|
|
120
|
+
tool: "teams_send_chat_message",
|
|
121
|
+
account: conn.account,
|
|
122
|
+
tenant: conn.tenant,
|
|
123
|
+
actor: me?.upn,
|
|
124
|
+
target: `chat:${chat.label}`,
|
|
125
|
+
summary: truncate(params.body, 200),
|
|
126
|
+
});
|
|
127
|
+
|
|
128
|
+
return textResult(
|
|
129
|
+
[
|
|
130
|
+
`✅ Message sent to **${chat.label}** as ${me?.displayName ?? "you"} (${connectionLabel(conn)}).`,
|
|
131
|
+
"",
|
|
132
|
+
`> ${truncate(params.body, 300)}`,
|
|
133
|
+
"",
|
|
134
|
+
`messageId: ${sent.id}`,
|
|
135
|
+
].join("\n"),
|
|
136
|
+
{ chatId: chat.id, messageId: sent.id },
|
|
137
|
+
);
|
|
138
|
+
} catch (err) {
|
|
139
|
+
auditWrite(conn.audit, {
|
|
140
|
+
tool: "teams_send_chat_message",
|
|
141
|
+
account: conn.account,
|
|
142
|
+
tenant: conn.tenant,
|
|
143
|
+
actor: me?.upn,
|
|
144
|
+
target: `chat:${chat.label}`,
|
|
145
|
+
summary: truncate(params.body, 200),
|
|
146
|
+
error: err instanceof Error ? err.message : String(err),
|
|
147
|
+
});
|
|
148
|
+
throw err;
|
|
149
|
+
}
|
|
150
|
+
});
|
|
151
|
+
},
|
|
152
|
+
};
|