@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,383 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Reading and writing messages — chats, channels, replies, reactions.
|
|
3
|
+
*
|
|
4
|
+
* This is the module that speaks in the user's name, so the shapes it builds
|
|
5
|
+
* are deliberately explicit: a mention is only sent when the caller resolved a
|
|
6
|
+
* real person for it, and an edit or delete always targets one message ID that
|
|
7
|
+
* the caller obtained from a read.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
import type { TeamsConnection } from "../config/index.ts";
|
|
11
|
+
import type { ChannelSummary, MessageLocation, MessageSummary, PersonRef } from "../types.ts";
|
|
12
|
+
import { graphGetOptional, graphList, graphPost } from "./client.ts";
|
|
13
|
+
import { mapMessage } from "./mappers.ts";
|
|
14
|
+
import { textToHtml } from "../utils/formatting.ts";
|
|
15
|
+
|
|
16
|
+
/* eslint-disable @typescript-eslint/no-explicit-any */
|
|
17
|
+
type Raw = Record<string, any>;
|
|
18
|
+
|
|
19
|
+
// ---------------------------------------------------------------------------
|
|
20
|
+
// Message bodies
|
|
21
|
+
// ---------------------------------------------------------------------------
|
|
22
|
+
|
|
23
|
+
export interface MessageInput {
|
|
24
|
+
/** Message text — plain text unless `html` is true */
|
|
25
|
+
body: string;
|
|
26
|
+
/** Treat `body` as raw HTML instead of escaping it */
|
|
27
|
+
html?: boolean;
|
|
28
|
+
/** Channel messages only */
|
|
29
|
+
subject?: string;
|
|
30
|
+
/** "normal" | "high" | "urgent" */
|
|
31
|
+
importance?: string;
|
|
32
|
+
/** People to @-mention; each must appear in the body as @DisplayName */
|
|
33
|
+
mentions?: PersonRef[];
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* Build the Graph message body.
|
|
38
|
+
*
|
|
39
|
+
* Mentions are the fiddly part: Teams requires an `<at id="N">` span in the
|
|
40
|
+
* HTML *and* a matching entry in the `mentions` array. The display name is
|
|
41
|
+
* replaced in the text so the caller can just write "@Anna Schmidt".
|
|
42
|
+
*/
|
|
43
|
+
export function buildMessageBody(input: MessageInput): Record<string, unknown> {
|
|
44
|
+
const mentions = input.mentions ?? [];
|
|
45
|
+
let content = input.html ? input.body : textToHtml(input.body);
|
|
46
|
+
|
|
47
|
+
const mentionEntries: Record<string, unknown>[] = [];
|
|
48
|
+
mentions.forEach((person, index) => {
|
|
49
|
+
const tag = `<at id="${index}">${escapeHtml(person.displayName)}</at>`;
|
|
50
|
+
const needle = new RegExp(`@${escapeRegExp(person.displayName)}`, "g");
|
|
51
|
+
if (needle.test(content)) {
|
|
52
|
+
content = content.replace(needle, tag);
|
|
53
|
+
} else {
|
|
54
|
+
// Not referenced in the text — prepend it so the mention still fires.
|
|
55
|
+
content = `${tag} ${content}`;
|
|
56
|
+
}
|
|
57
|
+
mentionEntries.push({
|
|
58
|
+
id: index,
|
|
59
|
+
mentionText: person.displayName,
|
|
60
|
+
mentioned: {
|
|
61
|
+
user: {
|
|
62
|
+
displayName: person.displayName,
|
|
63
|
+
id: person.id,
|
|
64
|
+
userIdentityType: "aadUser",
|
|
65
|
+
},
|
|
66
|
+
},
|
|
67
|
+
});
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
const body: Record<string, unknown> = {
|
|
71
|
+
body: { contentType: "html", content },
|
|
72
|
+
};
|
|
73
|
+
if (input.subject) body.subject = input.subject;
|
|
74
|
+
if (input.importance) body.importance = input.importance;
|
|
75
|
+
if (mentionEntries.length > 0) body.mentions = mentionEntries;
|
|
76
|
+
|
|
77
|
+
return body;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
function escapeHtml(value: string): string {
|
|
81
|
+
return value.replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">");
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
function escapeRegExp(value: string): string {
|
|
85
|
+
return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
// ---------------------------------------------------------------------------
|
|
89
|
+
// Paths
|
|
90
|
+
// ---------------------------------------------------------------------------
|
|
91
|
+
|
|
92
|
+
function chatPath(chatId: string): string {
|
|
93
|
+
return `/chats/${encodeURIComponent(chatId)}/messages`;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
function channelPath(teamId: string, channelId: string): string {
|
|
97
|
+
return `/teams/${encodeURIComponent(teamId)}/channels/${encodeURIComponent(channelId)}/messages`;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
// ---------------------------------------------------------------------------
|
|
101
|
+
// Reading — chats
|
|
102
|
+
// ---------------------------------------------------------------------------
|
|
103
|
+
|
|
104
|
+
export async function listChatMessages(
|
|
105
|
+
conn: TeamsConnection,
|
|
106
|
+
chatId: string,
|
|
107
|
+
options: { max?: number; signal?: AbortSignal } = {},
|
|
108
|
+
): Promise<MessageSummary[]> {
|
|
109
|
+
const raw = await graphList<Raw>(conn, chatPath(chatId), {
|
|
110
|
+
query: { $top: Math.min(options.max ?? 25, 50) },
|
|
111
|
+
max: options.max ?? 25,
|
|
112
|
+
signal: options.signal,
|
|
113
|
+
});
|
|
114
|
+
const location: MessageLocation = { kind: "chat", chatId };
|
|
115
|
+
return raw.map((entry) => mapMessage(entry, location));
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
export async function getChatMessage(
|
|
119
|
+
conn: TeamsConnection,
|
|
120
|
+
chatId: string,
|
|
121
|
+
messageId: string,
|
|
122
|
+
signal?: AbortSignal,
|
|
123
|
+
): Promise<MessageSummary | undefined> {
|
|
124
|
+
const raw = await graphGetOptional<Raw>(
|
|
125
|
+
conn,
|
|
126
|
+
`${chatPath(chatId)}/${encodeURIComponent(messageId)}`,
|
|
127
|
+
{ signal },
|
|
128
|
+
);
|
|
129
|
+
return raw ? mapMessage(raw, { kind: "chat", chatId }) : undefined;
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
// ---------------------------------------------------------------------------
|
|
133
|
+
// Reading — channels
|
|
134
|
+
// ---------------------------------------------------------------------------
|
|
135
|
+
|
|
136
|
+
export async function listChannelMessages(
|
|
137
|
+
conn: TeamsConnection,
|
|
138
|
+
channel: ChannelSummary,
|
|
139
|
+
options: { max?: number; withReplies?: boolean; signal?: AbortSignal } = {},
|
|
140
|
+
): Promise<MessageSummary[]> {
|
|
141
|
+
if (!channel.teamId) throw new Error("Channel is missing its teamId.");
|
|
142
|
+
|
|
143
|
+
const raw = await graphList<Raw>(conn, channelPath(channel.teamId, channel.id), {
|
|
144
|
+
query: { $top: Math.min(options.max ?? 25, 50) },
|
|
145
|
+
max: options.max ?? 25,
|
|
146
|
+
signal: options.signal,
|
|
147
|
+
});
|
|
148
|
+
|
|
149
|
+
const location: MessageLocation = {
|
|
150
|
+
kind: "channel",
|
|
151
|
+
teamId: channel.teamId,
|
|
152
|
+
teamName: channel.teamName,
|
|
153
|
+
channelId: channel.id,
|
|
154
|
+
channelName: channel.displayName,
|
|
155
|
+
};
|
|
156
|
+
|
|
157
|
+
const messages = raw.map((entry) => mapMessage(entry, location));
|
|
158
|
+
|
|
159
|
+
if (!options.withReplies) return messages;
|
|
160
|
+
|
|
161
|
+
// Threads are what a channel conversation actually is, so resolve them —
|
|
162
|
+
// sequentially, because a burst of parallel calls is the fastest way to get
|
|
163
|
+
// throttled by Graph.
|
|
164
|
+
for (const message of messages) {
|
|
165
|
+
const replies = await listChannelReplies(conn, channel, message.id, {
|
|
166
|
+
max: 20,
|
|
167
|
+
signal: options.signal,
|
|
168
|
+
});
|
|
169
|
+
message.replyCount = replies.length;
|
|
170
|
+
}
|
|
171
|
+
return messages;
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
export async function listChannelReplies(
|
|
175
|
+
conn: TeamsConnection,
|
|
176
|
+
channel: ChannelSummary,
|
|
177
|
+
messageId: string,
|
|
178
|
+
options: { max?: number; signal?: AbortSignal } = {},
|
|
179
|
+
): Promise<MessageSummary[]> {
|
|
180
|
+
if (!channel.teamId) throw new Error("Channel is missing its teamId.");
|
|
181
|
+
|
|
182
|
+
const raw = await graphList<Raw>(
|
|
183
|
+
conn,
|
|
184
|
+
`${channelPath(channel.teamId, channel.id)}/${encodeURIComponent(messageId)}/replies`,
|
|
185
|
+
{
|
|
186
|
+
query: { $top: Math.min(options.max ?? 30, 50) },
|
|
187
|
+
max: options.max ?? 30,
|
|
188
|
+
signal: options.signal,
|
|
189
|
+
},
|
|
190
|
+
);
|
|
191
|
+
|
|
192
|
+
const location: MessageLocation = {
|
|
193
|
+
kind: "channel",
|
|
194
|
+
teamId: channel.teamId,
|
|
195
|
+
teamName: channel.teamName,
|
|
196
|
+
channelId: channel.id,
|
|
197
|
+
channelName: channel.displayName,
|
|
198
|
+
};
|
|
199
|
+
return raw.map((entry) => mapMessage(entry, location));
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
export async function getChannelMessage(
|
|
203
|
+
conn: TeamsConnection,
|
|
204
|
+
channel: ChannelSummary,
|
|
205
|
+
messageId: string,
|
|
206
|
+
signal?: AbortSignal,
|
|
207
|
+
): Promise<MessageSummary | undefined> {
|
|
208
|
+
if (!channel.teamId) throw new Error("Channel is missing its teamId.");
|
|
209
|
+
const raw = await graphGetOptional<Raw>(
|
|
210
|
+
conn,
|
|
211
|
+
`${channelPath(channel.teamId, channel.id)}/${encodeURIComponent(messageId)}`,
|
|
212
|
+
{ signal },
|
|
213
|
+
);
|
|
214
|
+
return raw
|
|
215
|
+
? mapMessage(raw, {
|
|
216
|
+
kind: "channel",
|
|
217
|
+
teamId: channel.teamId,
|
|
218
|
+
teamName: channel.teamName,
|
|
219
|
+
channelId: channel.id,
|
|
220
|
+
channelName: channel.displayName,
|
|
221
|
+
})
|
|
222
|
+
: undefined;
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
// ---------------------------------------------------------------------------
|
|
226
|
+
// Writing
|
|
227
|
+
// ---------------------------------------------------------------------------
|
|
228
|
+
|
|
229
|
+
export async function sendChatMessage(
|
|
230
|
+
conn: TeamsConnection,
|
|
231
|
+
chatId: string,
|
|
232
|
+
input: MessageInput,
|
|
233
|
+
signal?: AbortSignal,
|
|
234
|
+
): Promise<MessageSummary> {
|
|
235
|
+
const raw = await graphPost<Raw>(conn, chatPath(chatId), buildMessageBody(input), { signal });
|
|
236
|
+
return mapMessage(raw ?? {}, { kind: "chat", chatId });
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
export async function sendChannelMessage(
|
|
240
|
+
conn: TeamsConnection,
|
|
241
|
+
channel: ChannelSummary,
|
|
242
|
+
input: MessageInput,
|
|
243
|
+
signal?: AbortSignal,
|
|
244
|
+
): Promise<MessageSummary> {
|
|
245
|
+
if (!channel.teamId) throw new Error("Channel is missing its teamId.");
|
|
246
|
+
const raw = await graphPost<Raw>(
|
|
247
|
+
conn,
|
|
248
|
+
channelPath(channel.teamId, channel.id),
|
|
249
|
+
buildMessageBody(input),
|
|
250
|
+
{ signal },
|
|
251
|
+
);
|
|
252
|
+
return mapMessage(raw ?? {}, {
|
|
253
|
+
kind: "channel",
|
|
254
|
+
teamId: channel.teamId,
|
|
255
|
+
teamName: channel.teamName,
|
|
256
|
+
channelId: channel.id,
|
|
257
|
+
channelName: channel.displayName,
|
|
258
|
+
});
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
export async function replyToChannelMessage(
|
|
262
|
+
conn: TeamsConnection,
|
|
263
|
+
channel: ChannelSummary,
|
|
264
|
+
messageId: string,
|
|
265
|
+
input: MessageInput,
|
|
266
|
+
signal?: AbortSignal,
|
|
267
|
+
): Promise<MessageSummary> {
|
|
268
|
+
if (!channel.teamId) throw new Error("Channel is missing its teamId.");
|
|
269
|
+
const raw = await graphPost<Raw>(
|
|
270
|
+
conn,
|
|
271
|
+
`${channelPath(channel.teamId, channel.id)}/${encodeURIComponent(messageId)}/replies`,
|
|
272
|
+
buildMessageBody(input),
|
|
273
|
+
{ signal },
|
|
274
|
+
);
|
|
275
|
+
return mapMessage(raw ?? {}, {
|
|
276
|
+
kind: "channel",
|
|
277
|
+
teamId: channel.teamId,
|
|
278
|
+
teamName: channel.teamName,
|
|
279
|
+
channelId: channel.id,
|
|
280
|
+
channelName: channel.displayName,
|
|
281
|
+
});
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
/**
|
|
285
|
+
* Path for the softDelete / undoSoftDelete actions.
|
|
286
|
+
*
|
|
287
|
+
* The chat form takes an explicit user id — Graph does not accept `/me` here —
|
|
288
|
+
* which is why every caller has to resolve the signed-in user first.
|
|
289
|
+
*/
|
|
290
|
+
export function deletePath(
|
|
291
|
+
location: MessageLocation,
|
|
292
|
+
messageId: string,
|
|
293
|
+
userId: string,
|
|
294
|
+
replyId?: string,
|
|
295
|
+
): string {
|
|
296
|
+
if (location.kind === "chat") {
|
|
297
|
+
return (
|
|
298
|
+
`/users/${encodeURIComponent(userId)}` +
|
|
299
|
+
`/chats/${encodeURIComponent(location.chatId!)}` +
|
|
300
|
+
`/messages/${encodeURIComponent(messageId)}`
|
|
301
|
+
);
|
|
302
|
+
}
|
|
303
|
+
const base = `${channelPath(location.teamId!, location.channelId!)}/${encodeURIComponent(messageId)}`;
|
|
304
|
+
return replyId ? `${base}/replies/${encodeURIComponent(replyId)}` : base;
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
/**
|
|
308
|
+
* Delete one of the signed-in user's own messages (soft delete, as in the app).
|
|
309
|
+
*
|
|
310
|
+
* Graph exposes this as an action, not an HTTP DELETE, and only for the sender.
|
|
311
|
+
*/
|
|
312
|
+
export async function softDeleteMessage(
|
|
313
|
+
conn: TeamsConnection,
|
|
314
|
+
location: MessageLocation,
|
|
315
|
+
messageId: string,
|
|
316
|
+
userId: string,
|
|
317
|
+
options: { replyId?: string; signal?: AbortSignal } = {},
|
|
318
|
+
): Promise<void> {
|
|
319
|
+
await graphPost(
|
|
320
|
+
conn,
|
|
321
|
+
`${deletePath(location, messageId, userId, options.replyId)}/softDelete`,
|
|
322
|
+
undefined,
|
|
323
|
+
{ signal: options.signal },
|
|
324
|
+
);
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
/** Restore a message that was soft-deleted. */
|
|
328
|
+
export async function undoSoftDeleteMessage(
|
|
329
|
+
conn: TeamsConnection,
|
|
330
|
+
location: MessageLocation,
|
|
331
|
+
messageId: string,
|
|
332
|
+
userId: string,
|
|
333
|
+
options: { replyId?: string; signal?: AbortSignal } = {},
|
|
334
|
+
): Promise<void> {
|
|
335
|
+
await graphPost(
|
|
336
|
+
conn,
|
|
337
|
+
`${deletePath(location, messageId, userId, options.replyId)}/undoSoftDelete`,
|
|
338
|
+
undefined,
|
|
339
|
+
{ signal: options.signal },
|
|
340
|
+
);
|
|
341
|
+
}
|
|
342
|
+
|
|
343
|
+
// ---------------------------------------------------------------------------
|
|
344
|
+
// Reactions
|
|
345
|
+
// ---------------------------------------------------------------------------
|
|
346
|
+
|
|
347
|
+
function reactionBase(location: MessageLocation, messageId: string, replyId?: string): string {
|
|
348
|
+
if (location.kind === "chat") {
|
|
349
|
+
return `/chats/${encodeURIComponent(location.chatId!)}/messages/${encodeURIComponent(messageId)}`;
|
|
350
|
+
}
|
|
351
|
+
const channelBase = `${channelPath(location.teamId!, location.channelId!)}/${encodeURIComponent(messageId)}`;
|
|
352
|
+
return replyId ? `${channelBase}/replies/${encodeURIComponent(replyId)}` : channelBase;
|
|
353
|
+
}
|
|
354
|
+
|
|
355
|
+
export async function setReaction(
|
|
356
|
+
conn: TeamsConnection,
|
|
357
|
+
location: MessageLocation,
|
|
358
|
+
messageId: string,
|
|
359
|
+
reactionType: string,
|
|
360
|
+
options: { replyId?: string; signal?: AbortSignal } = {},
|
|
361
|
+
): Promise<void> {
|
|
362
|
+
await graphPost(
|
|
363
|
+
conn,
|
|
364
|
+
`${reactionBase(location, messageId, options.replyId)}/setReaction`,
|
|
365
|
+
{ reactionType },
|
|
366
|
+
{ signal: options.signal },
|
|
367
|
+
);
|
|
368
|
+
}
|
|
369
|
+
|
|
370
|
+
export async function unsetReaction(
|
|
371
|
+
conn: TeamsConnection,
|
|
372
|
+
location: MessageLocation,
|
|
373
|
+
messageId: string,
|
|
374
|
+
reactionType: string,
|
|
375
|
+
options: { replyId?: string; signal?: AbortSignal } = {},
|
|
376
|
+
): Promise<void> {
|
|
377
|
+
await graphPost(
|
|
378
|
+
conn,
|
|
379
|
+
`${reactionBase(location, messageId, options.replyId)}/unsetReaction`,
|
|
380
|
+
{ reactionType },
|
|
381
|
+
{ signal: options.signal },
|
|
382
|
+
);
|
|
383
|
+
}
|
|
@@ -0,0 +1,160 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Presence — reading who is available and setting the user's own status.
|
|
3
|
+
*
|
|
4
|
+
* Setting presence uses `setUserPreferredPresence`, not `setPresence`: the
|
|
5
|
+
* latter is tied to an application session and gets overwritten by the Teams
|
|
6
|
+
* client within seconds. Preferred presence is what the user themselves sets
|
|
7
|
+
* in the app, so it behaves the way they expect — including the expiry.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
import type { TeamsConnection } from "../config/index.ts";
|
|
11
|
+
import type { PresenceInfo } from "../types.ts";
|
|
12
|
+
import { graphList, graphPost, graphRequest } from "./client.ts";
|
|
13
|
+
import { mapPresence } from "./mappers.ts";
|
|
14
|
+
|
|
15
|
+
/* eslint-disable @typescript-eslint/no-explicit-any */
|
|
16
|
+
type Raw = Record<string, any>;
|
|
17
|
+
|
|
18
|
+
/** Availability values Graph accepts for preferred presence. */
|
|
19
|
+
export const AVAILABILITY_VALUES = [
|
|
20
|
+
"Available",
|
|
21
|
+
"Busy",
|
|
22
|
+
"DoNotDisturb",
|
|
23
|
+
"BeRightBack",
|
|
24
|
+
"Away",
|
|
25
|
+
"Offline",
|
|
26
|
+
] as const;
|
|
27
|
+
|
|
28
|
+
export type Availability = (typeof AVAILABILITY_VALUES)[number];
|
|
29
|
+
|
|
30
|
+
/** Graph pairs each availability with a specific activity. */
|
|
31
|
+
const ACTIVITY_FOR: Record<Availability, string> = {
|
|
32
|
+
Available: "Available",
|
|
33
|
+
Busy: "Busy",
|
|
34
|
+
DoNotDisturb: "DoNotDisturb",
|
|
35
|
+
BeRightBack: "BeRightBack",
|
|
36
|
+
Away: "Away",
|
|
37
|
+
Offline: "OffWork",
|
|
38
|
+
};
|
|
39
|
+
|
|
40
|
+
export function activityFor(availability: Availability): string {
|
|
41
|
+
return ACTIVITY_FOR[availability];
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export async function getMyPresence(
|
|
45
|
+
conn: TeamsConnection,
|
|
46
|
+
signal?: AbortSignal,
|
|
47
|
+
): Promise<PresenceInfo> {
|
|
48
|
+
const raw = await graphRequest<Raw>(conn, "GET", "/me/presence", { signal });
|
|
49
|
+
return mapPresence(raw ?? {}, "you");
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
export async function getPresenceForUser(
|
|
53
|
+
conn: TeamsConnection,
|
|
54
|
+
userId: string,
|
|
55
|
+
displayName?: string,
|
|
56
|
+
signal?: AbortSignal,
|
|
57
|
+
): Promise<PresenceInfo> {
|
|
58
|
+
const raw = await graphRequest<Raw>(
|
|
59
|
+
conn,
|
|
60
|
+
"GET",
|
|
61
|
+
`/users/${encodeURIComponent(userId)}/presence`,
|
|
62
|
+
{ signal },
|
|
63
|
+
);
|
|
64
|
+
return mapPresence(raw ?? {}, displayName);
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* Presence for several people at once.
|
|
69
|
+
*
|
|
70
|
+
* Graph caps this action at 650 IDs per call; batching in 100s keeps request
|
|
71
|
+
* bodies small and the throttling risk low.
|
|
72
|
+
*/
|
|
73
|
+
export async function getPresenceForUsers(
|
|
74
|
+
conn: TeamsConnection,
|
|
75
|
+
userIds: string[],
|
|
76
|
+
names: Map<string, string>,
|
|
77
|
+
signal?: AbortSignal,
|
|
78
|
+
): Promise<PresenceInfo[]> {
|
|
79
|
+
const results: PresenceInfo[] = [];
|
|
80
|
+
|
|
81
|
+
for (let i = 0; i < userIds.length; i += 100) {
|
|
82
|
+
const batch = userIds.slice(i, i + 100);
|
|
83
|
+
const response = await graphPost<Raw>(
|
|
84
|
+
conn,
|
|
85
|
+
"/communications/getPresencesByUserId",
|
|
86
|
+
{ ids: batch },
|
|
87
|
+
{ signal },
|
|
88
|
+
);
|
|
89
|
+
for (const entry of response?.value ?? []) {
|
|
90
|
+
results.push(mapPresence(entry, names.get(entry.id)));
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
return results;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/**
|
|
98
|
+
* Set the signed-in user's preferred presence.
|
|
99
|
+
*
|
|
100
|
+
* @param expirationDuration - ISO 8601 duration, e.g. "PT2H". Omit to let
|
|
101
|
+
* Microsoft apply its default (1 day for Busy/DoNotDisturb, 7 days otherwise).
|
|
102
|
+
*/
|
|
103
|
+
export async function setPreferredPresence(
|
|
104
|
+
conn: TeamsConnection,
|
|
105
|
+
availability: Availability,
|
|
106
|
+
options: { expirationDuration?: string; signal?: AbortSignal } = {},
|
|
107
|
+
): Promise<void> {
|
|
108
|
+
const body: Record<string, unknown> = {
|
|
109
|
+
availability,
|
|
110
|
+
activity: activityFor(availability),
|
|
111
|
+
};
|
|
112
|
+
if (options.expirationDuration) body.expirationDuration = options.expirationDuration;
|
|
113
|
+
|
|
114
|
+
await graphPost(conn, "/me/presence/setUserPreferredPresence", body, { signal: options.signal });
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
/** Hand presence back to Teams' automatic calculation. */
|
|
118
|
+
export async function clearPreferredPresence(
|
|
119
|
+
conn: TeamsConnection,
|
|
120
|
+
signal?: AbortSignal,
|
|
121
|
+
): Promise<void> {
|
|
122
|
+
await graphPost(conn, "/me/presence/clearUserPreferredPresence", undefined, { signal });
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
/** Set or clear the status message shown under the user's name. */
|
|
126
|
+
export async function setStatusMessage(
|
|
127
|
+
conn: TeamsConnection,
|
|
128
|
+
message: string,
|
|
129
|
+
options: { expiresAt?: string; signal?: AbortSignal } = {},
|
|
130
|
+
): Promise<void> {
|
|
131
|
+
const statusMessage: Record<string, unknown> = {
|
|
132
|
+
message: { content: message, contentType: "text" },
|
|
133
|
+
};
|
|
134
|
+
if (options.expiresAt) {
|
|
135
|
+
statusMessage.expiryDateTime = { dateTime: options.expiresAt, timeZone: "UTC" };
|
|
136
|
+
}
|
|
137
|
+
await graphPost(conn, "/me/presence/setStatusMessage", { statusMessage }, { signal: options.signal });
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
/** Presence of everyone in a chat — handy before pinging a group. */
|
|
141
|
+
export async function getChatPresence(
|
|
142
|
+
conn: TeamsConnection,
|
|
143
|
+
chatId: string,
|
|
144
|
+
signal?: AbortSignal,
|
|
145
|
+
): Promise<PresenceInfo[]> {
|
|
146
|
+
const members = await graphList<Raw>(conn, `/chats/${encodeURIComponent(chatId)}/members`, {
|
|
147
|
+
max: 50,
|
|
148
|
+
signal,
|
|
149
|
+
});
|
|
150
|
+
const names = new Map<string, string>();
|
|
151
|
+
const ids: string[] = [];
|
|
152
|
+
for (const member of members) {
|
|
153
|
+
if (member.userId) {
|
|
154
|
+
ids.push(member.userId);
|
|
155
|
+
names.set(member.userId, member.displayName ?? "(unknown)");
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
if (ids.length === 0) return [];
|
|
159
|
+
return getPresenceForUsers(conn, ids, names, signal);
|
|
160
|
+
}
|
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Message search across chats and channels.
|
|
3
|
+
*
|
|
4
|
+
* Uses the Microsoft Search API (`POST /search/query`, entityType
|
|
5
|
+
* `chatMessage`), which is the only endpoint that searches a user's whole
|
|
6
|
+
* Teams history in one call. Results come back as search hits with a summary
|
|
7
|
+
* and a partial resource, so they are normalized here into MessageSummary.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
import type { TeamsConnection } from "../config/index.ts";
|
|
11
|
+
import type { MessageLocation, MessageSummary } from "../types.ts";
|
|
12
|
+
import { graphPost } from "./client.ts";
|
|
13
|
+
import { mapMessage } from "./mappers.ts";
|
|
14
|
+
|
|
15
|
+
/* eslint-disable @typescript-eslint/no-explicit-any */
|
|
16
|
+
type Raw = Record<string, any>;
|
|
17
|
+
|
|
18
|
+
export interface SearchHit {
|
|
19
|
+
message: MessageSummary;
|
|
20
|
+
/** Search summary with <c0> highlight markers stripped */
|
|
21
|
+
summary?: string;
|
|
22
|
+
/** Where the hit lives, when the resource carried enough information */
|
|
23
|
+
channelIdentity?: { teamId?: string; channelId?: string };
|
|
24
|
+
chatId?: string;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* Search Teams messages.
|
|
29
|
+
*
|
|
30
|
+
* @param query - KQL-ish query string; plain words work, as do `from:` filters
|
|
31
|
+
* @param max - hits to return (Graph pages in blocks of 25)
|
|
32
|
+
*/
|
|
33
|
+
export async function searchMessages(
|
|
34
|
+
conn: TeamsConnection,
|
|
35
|
+
query: string,
|
|
36
|
+
options: { max?: number; from?: number; signal?: AbortSignal } = {},
|
|
37
|
+
): Promise<SearchHit[]> {
|
|
38
|
+
const size = Math.min(options.max ?? 25, 25);
|
|
39
|
+
|
|
40
|
+
const response = await graphPost<Raw>(
|
|
41
|
+
conn,
|
|
42
|
+
"/search/query",
|
|
43
|
+
{
|
|
44
|
+
requests: [
|
|
45
|
+
{
|
|
46
|
+
entityTypes: ["chatMessage"],
|
|
47
|
+
query: { queryString: query },
|
|
48
|
+
from: options.from ?? 0,
|
|
49
|
+
size,
|
|
50
|
+
},
|
|
51
|
+
],
|
|
52
|
+
},
|
|
53
|
+
{ signal: options.signal },
|
|
54
|
+
);
|
|
55
|
+
|
|
56
|
+
const containers = response?.value?.[0]?.hitsContainers ?? [];
|
|
57
|
+
const hits: SearchHit[] = [];
|
|
58
|
+
|
|
59
|
+
for (const container of containers) {
|
|
60
|
+
for (const hit of container.hits ?? []) {
|
|
61
|
+
const resource = hit.resource ?? {};
|
|
62
|
+
const channelIdentity = resource.channelIdentity as
|
|
63
|
+
| { teamId?: string; channelId?: string }
|
|
64
|
+
| undefined;
|
|
65
|
+
|
|
66
|
+
const location: MessageLocation | undefined = channelIdentity?.channelId
|
|
67
|
+
? {
|
|
68
|
+
kind: "channel",
|
|
69
|
+
teamId: channelIdentity.teamId,
|
|
70
|
+
channelId: channelIdentity.channelId,
|
|
71
|
+
}
|
|
72
|
+
: resource.chatId
|
|
73
|
+
? { kind: "chat", chatId: resource.chatId }
|
|
74
|
+
: undefined;
|
|
75
|
+
|
|
76
|
+
hits.push({
|
|
77
|
+
message: mapMessage(resource, location),
|
|
78
|
+
summary: typeof hit.summary === "string"
|
|
79
|
+
? hit.summary.replace(/<\/?c\d+>/g, "").trim()
|
|
80
|
+
: undefined,
|
|
81
|
+
channelIdentity,
|
|
82
|
+
chatId: resource.chatId,
|
|
83
|
+
});
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
return hits.slice(0, options.max ?? 25);
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/** Render search hits for the agent, keeping the IDs needed for follow-ups. */
|
|
91
|
+
export function formatSearchHits(hits: readonly SearchHit[], query: string): string {
|
|
92
|
+
if (hits.length === 0) return `No Teams messages found for "${query}".`;
|
|
93
|
+
|
|
94
|
+
const lines = [`## Search: "${query}"`, "", `${hits.length} hit(s):`, ""];
|
|
95
|
+
for (const hit of hits) {
|
|
96
|
+
const who = hit.message.from?.displayName ?? "(unknown)";
|
|
97
|
+
const when = hit.message.createdDateTime
|
|
98
|
+
? new Date(hit.message.createdDateTime).toISOString().slice(0, 16).replace("T", " ")
|
|
99
|
+
: "";
|
|
100
|
+
const where = hit.channelIdentity?.channelId
|
|
101
|
+
? `channel ${hit.channelIdentity.channelId}`
|
|
102
|
+
: hit.chatId
|
|
103
|
+
? `chat ${hit.chatId}`
|
|
104
|
+
: "unknown location";
|
|
105
|
+
|
|
106
|
+
lines.push(`- **${who}**${when ? ` · ${when}` : ""} · ${where}`);
|
|
107
|
+
lines.push(` ${hit.summary || hit.message.text.slice(0, 200)}`);
|
|
108
|
+
lines.push(` messageId: ${hit.message.id}`);
|
|
109
|
+
}
|
|
110
|
+
return lines.join("\n");
|
|
111
|
+
}
|