@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.
Files changed (63) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +412 -0
  3. package/index.ts +1 -0
  4. package/package.json +61 -0
  5. package/prompts/teams-catch-up.md +24 -0
  6. package/prompts/teams-channel-digest.md +23 -0
  7. package/prompts/teams-doctor.md +21 -0
  8. package/prompts/teams-meeting-prep.md +22 -0
  9. package/prompts/teams-standup.md +21 -0
  10. package/prompts/teams-triage.md +22 -0
  11. package/skills/teams-collaboration/SKILL.md +143 -0
  12. package/src/auth/client-credentials.ts +72 -0
  13. package/src/auth/device-code.ts +276 -0
  14. package/src/auth/index.ts +105 -0
  15. package/src/auth/jwt.ts +50 -0
  16. package/src/auth/token-store.ts +156 -0
  17. package/src/config/index.ts +685 -0
  18. package/src/config/scope.ts +272 -0
  19. package/src/extension/index.ts +336 -0
  20. package/src/graph/calendar.ts +165 -0
  21. package/src/graph/chats.ts +159 -0
  22. package/src/graph/client.ts +234 -0
  23. package/src/graph/mappers.ts +268 -0
  24. package/src/graph/me.ts +80 -0
  25. package/src/graph/messages.ts +383 -0
  26. package/src/graph/presence.ts +160 -0
  27. package/src/graph/search.ts +111 -0
  28. package/src/graph/teams.ts +271 -0
  29. package/src/safety/audit.ts +57 -0
  30. package/src/safety/index.ts +186 -0
  31. package/src/status.ts +90 -0
  32. package/src/tools/resolve.ts +138 -0
  33. package/src/tools/shared.ts +124 -0
  34. package/src/tools/teams-accounts.ts +169 -0
  35. package/src/tools/teams-create-channel.ts +89 -0
  36. package/src/tools/teams-create-chat.ts +112 -0
  37. package/src/tools/teams-delete-message.ts +114 -0
  38. package/src/tools/teams-doctor.ts +182 -0
  39. package/src/tools/teams-find-user.ts +64 -0
  40. package/src/tools/teams-inbox.ts +142 -0
  41. package/src/tools/teams-list-channels.ts +62 -0
  42. package/src/tools/teams-list-chats.ts +74 -0
  43. package/src/tools/teams-list-files.ts +54 -0
  44. package/src/tools/teams-list-members.ts +72 -0
  45. package/src/tools/teams-list-teams.ts +65 -0
  46. package/src/tools/teams-login.ts +199 -0
  47. package/src/tools/teams-meetings.ts +342 -0
  48. package/src/tools/teams-permissions.ts +169 -0
  49. package/src/tools/teams-presence.ts +216 -0
  50. package/src/tools/teams-react.ts +109 -0
  51. package/src/tools/teams-read-channel.ts +75 -0
  52. package/src/tools/teams-read-chat.ts +73 -0
  53. package/src/tools/teams-read-thread.ts +88 -0
  54. package/src/tools/teams-search-messages.ts +71 -0
  55. package/src/tools/teams-send-channel-message.ts +250 -0
  56. package/src/tools/teams-send-chat-message.ts +152 -0
  57. package/src/tools/teams-setup.ts +245 -0
  58. package/src/tools/teams-status.ts +73 -0
  59. package/src/tools/tool-names.ts +30 -0
  60. package/src/types.ts +192 -0
  61. package/src/utils/errors.ts +133 -0
  62. package/src/utils/formatting.ts +356 -0
  63. package/tsconfig.json +16 -0
@@ -0,0 +1,356 @@
1
+ /**
2
+ * Pure transformation functions: domain data → display strings.
3
+ *
4
+ * No side effects, no I/O. Everything the agent reads about Teams goes through
5
+ * here, so the rules are: never invent content, never silently drop a sender
6
+ * or timestamp, and keep IDs visible so follow-up tool calls have something to
7
+ * quote.
8
+ */
9
+
10
+ import type {
11
+ ChannelSummary,
12
+ ChatSummary,
13
+ DriveItemSummary,
14
+ EventSummary,
15
+ MemberSummary,
16
+ MessageSummary,
17
+ PresenceInfo,
18
+ TeamSummary,
19
+ } from "../types.ts";
20
+
21
+ // ---------------------------------------------------------------------------
22
+ // HTML → text
23
+ // ---------------------------------------------------------------------------
24
+
25
+ const ENTITIES: Record<string, string> = {
26
+ "&nbsp;": " ",
27
+ "&amp;": "&",
28
+ "&lt;": "<",
29
+ "&gt;": ">",
30
+ "&quot;": '"',
31
+ "&#39;": "'",
32
+ "&apos;": "'",
33
+ };
34
+
35
+ /**
36
+ * Flatten Teams message HTML into readable plain text.
37
+ *
38
+ * Teams sends most messages as HTML even when the user typed plain text, and
39
+ * an agent reading raw `<div>`s wastes tokens and misreads content. This keeps
40
+ * line structure, mention text and link targets, and drops the rest.
41
+ */
42
+ export function htmlToText(content: string, contentType = "html"): string {
43
+ if (!content) return "";
44
+ if (contentType.toLowerCase() !== "html") return content.trim();
45
+
46
+ let text = content;
47
+
48
+ // Keep the target of real links: "text (https://…)"
49
+ text = text.replace(
50
+ /<a\b[^>]*href=["']([^"']+)["'][^>]*>([\s\S]*?)<\/a>/gi,
51
+ (_m, href: string, label: string) => {
52
+ const clean = label.replace(/<[^>]+>/g, "").trim();
53
+ if (!clean) return href;
54
+ return clean === href ? href : `${clean} (${href})`;
55
+ },
56
+ );
57
+
58
+ // Images carry no text; name them so a reader knows something was there.
59
+ text = text.replace(/<img\b[^>]*alt=["']([^"']*)["'][^>]*>/gi, (_m, alt: string) =>
60
+ alt ? `[image: ${alt}]` : "[image]",
61
+ );
62
+ text = text.replace(/<img\b[^>]*>/gi, "[image]");
63
+
64
+ // Block elements become line breaks.
65
+ text = text.replace(/<\/(p|div|li|tr|h[1-6])>/gi, "\n");
66
+ text = text.replace(/<br\s*\/?>/gi, "\n");
67
+ text = text.replace(/<li\b[^>]*>/gi, "- ");
68
+
69
+ // Everything else goes.
70
+ text = text.replace(/<[^>]+>/g, "");
71
+
72
+ for (const [entity, replacement] of Object.entries(ENTITIES)) {
73
+ text = text.split(entity).join(replacement);
74
+ }
75
+ text = text.replace(/&#(\d+);/g, (_m, code: string) => String.fromCharCode(Number(code)));
76
+
77
+ return text
78
+ .split("\n")
79
+ .map((line) => line.replace(/[ \t]+/g, " ").trimEnd())
80
+ .join("\n")
81
+ .replace(/\n{3,}/g, "\n\n")
82
+ .trim();
83
+ }
84
+
85
+ /** Convert plain text to the minimal HTML Teams expects for a message body. */
86
+ export function textToHtml(text: string): string {
87
+ const escaped = text
88
+ .replace(/&/g, "&amp;")
89
+ .replace(/</g, "&lt;")
90
+ .replace(/>/g, "&gt;");
91
+ return escaped
92
+ .split(/\n{2,}/)
93
+ .map((para) => `<p>${para.replace(/\n/g, "<br>")}</p>`)
94
+ .join("");
95
+ }
96
+
97
+ // ---------------------------------------------------------------------------
98
+ // Dates
99
+ // ---------------------------------------------------------------------------
100
+
101
+ /** ISO timestamp → "2026-09-14 08:31" in the local timezone. */
102
+ export function formatDate(iso: string | undefined): string {
103
+ if (!iso) return "";
104
+ const date = new Date(iso);
105
+ if (Number.isNaN(date.getTime())) return iso;
106
+ const pad = (n: number) => String(n).padStart(2, "0");
107
+ return (
108
+ `${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())} ` +
109
+ `${pad(date.getHours())}:${pad(date.getMinutes())}`
110
+ );
111
+ }
112
+
113
+ /** "3 min ago", "2 h ago", "5 d ago" — for scanning a list quickly. */
114
+ export function formatRelative(iso: string | undefined): string {
115
+ if (!iso) return "";
116
+ const then = new Date(iso).getTime();
117
+ if (Number.isNaN(then)) return "";
118
+ const diffMs = Date.now() - then;
119
+ const minutes = Math.round(diffMs / 60000);
120
+ if (minutes < 1) return "just now";
121
+ if (minutes < 60) return `${minutes} min ago`;
122
+ const hours = Math.round(minutes / 60);
123
+ if (hours < 24) return `${hours} h ago`;
124
+ const days = Math.round(hours / 24);
125
+ if (days < 30) return `${days} d ago`;
126
+ return formatDate(iso);
127
+ }
128
+
129
+ /** Shorten a string for previews, without cutting mid-escape. */
130
+ export function truncate(text: string, max: number): string {
131
+ const oneLine = text.replace(/\s*\n\s*/g, " ").trim();
132
+ if (oneLine.length <= max) return oneLine;
133
+ return `${oneLine.slice(0, Math.max(0, max - 1))}…`;
134
+ }
135
+
136
+ export function formatBytes(bytes: number | undefined): string {
137
+ if (bytes === undefined) return "";
138
+ const units = ["B", "KB", "MB", "GB"];
139
+ let value = bytes;
140
+ let unit = 0;
141
+ while (value >= 1024 && unit < units.length - 1) {
142
+ value /= 1024;
143
+ unit += 1;
144
+ }
145
+ return `${value.toFixed(value < 10 && unit > 0 ? 1 : 0)} ${units[unit]}`;
146
+ }
147
+
148
+ // ---------------------------------------------------------------------------
149
+ // Teams & channels
150
+ // ---------------------------------------------------------------------------
151
+
152
+ export function formatTeamList(teams: readonly TeamSummary[]): string {
153
+ if (teams.length === 0) return "No teams found (or none allowed by your configuration).";
154
+ const lines = [`Found ${teams.length} team(s):`, ""];
155
+ for (const team of teams) {
156
+ const flags = [team.visibility, team.isArchived ? "archived" : undefined]
157
+ .filter(Boolean)
158
+ .join(", ");
159
+ lines.push(`- **${team.displayName}**${flags ? ` (${flags})` : ""}`);
160
+ if (team.description) lines.push(` ${truncate(team.description, 120)}`);
161
+ lines.push(` id: ${team.id}`);
162
+ }
163
+ return lines.join("\n");
164
+ }
165
+
166
+ export function formatChannelList(channels: readonly ChannelSummary[]): string {
167
+ if (channels.length === 0) return "No channels found (or none allowed by your configuration).";
168
+ const lines = [`Found ${channels.length} channel(s):`, ""];
169
+ for (const channel of channels) {
170
+ const path = channel.teamName ? `${channel.teamName}/${channel.displayName}` : channel.displayName;
171
+ lines.push(`- **${path}**${channel.membershipType ? ` (${channel.membershipType})` : ""}`);
172
+ if (channel.description) lines.push(` ${truncate(channel.description, 120)}`);
173
+ lines.push(` channelId: ${channel.id}${channel.teamId ? ` · teamId: ${channel.teamId}` : ""}`);
174
+ }
175
+ return lines.join("\n");
176
+ }
177
+
178
+ export function formatMemberList(members: readonly MemberSummary[], title: string): string {
179
+ if (members.length === 0) return `${title}: no members visible.`;
180
+ const lines = [`${title} — ${members.length} member(s):`, ""];
181
+ for (const member of members) {
182
+ const roles = member.roles.length > 0 ? ` [${member.roles.join(", ")}]` : "";
183
+ const contact = member.upn ?? member.mail;
184
+ lines.push(`- ${member.displayName}${contact ? ` <${contact}>` : ""}${roles}`);
185
+ }
186
+ return lines.join("\n");
187
+ }
188
+
189
+ // ---------------------------------------------------------------------------
190
+ // Chats
191
+ // ---------------------------------------------------------------------------
192
+
193
+ export function formatChatList(chats: readonly ChatSummary[]): string {
194
+ if (chats.length === 0) return "No chats found (or none allowed by your configuration).";
195
+ const lines = [`Found ${chats.length} chat(s):`, ""];
196
+ for (const chat of chats) {
197
+ lines.push(`- **${chat.label}** (${chat.chatType})`);
198
+ if (chat.lastMessagePreview) {
199
+ const who = chat.lastMessageFrom ? `${chat.lastMessageFrom}: ` : "";
200
+ lines.push(` ${who}${truncate(chat.lastMessagePreview, 120)}`);
201
+ }
202
+ const when = chat.lastUpdated ? ` · ${formatRelative(chat.lastUpdated)}` : "";
203
+ lines.push(` chatId: ${chat.id}${when}`);
204
+ }
205
+ return lines.join("\n");
206
+ }
207
+
208
+ export function formatChatDetail(chat: ChatSummary): string {
209
+ const lines = [`## ${chat.label}`, ""];
210
+ lines.push(`- **Type:** ${chat.chatType}`);
211
+ if (chat.topic) lines.push(`- **Topic:** ${chat.topic}`);
212
+ lines.push(`- **Members:** ${chat.members.map((m) => m.displayName).join(", ") || "(none visible)"}`);
213
+ if (chat.lastUpdated) lines.push(`- **Last activity:** ${formatDate(chat.lastUpdated)}`);
214
+ lines.push(`- **chatId:** ${chat.id}`);
215
+ if (chat.webUrl) lines.push(`- **Open in Teams:** ${chat.webUrl}`);
216
+ return lines.join("\n");
217
+ }
218
+
219
+ // ---------------------------------------------------------------------------
220
+ // Messages
221
+ // ---------------------------------------------------------------------------
222
+
223
+ /** One message, rendered for reading. */
224
+ export function formatMessage(message: MessageSummary, options: { showId?: boolean } = {}): string {
225
+ const who = message.from?.displayName ?? "(system)";
226
+ const when = message.createdDateTime ? formatDate(message.createdDateTime) : "";
227
+ const header = `**${who}**${when ? ` · ${when}` : ""}`;
228
+
229
+ const lines = [header];
230
+ if (message.subject) lines.push(`_${message.subject}_`);
231
+
232
+ if (message.deletedDateTime) {
233
+ lines.push("(message deleted)");
234
+ } else {
235
+ lines.push(message.text || "(empty message)");
236
+ }
237
+
238
+ const meta: string[] = [];
239
+ if (message.reactions.length > 0) {
240
+ const grouped = new Map<string, number>();
241
+ for (const reaction of message.reactions) {
242
+ grouped.set(reaction.type, (grouped.get(reaction.type) ?? 0) + 1);
243
+ }
244
+ meta.push(
245
+ [...grouped.entries()].map(([type, count]) => `${type}${count > 1 ? `×${count}` : ""}`).join(" "),
246
+ );
247
+ }
248
+ if (message.attachments.length > 0) {
249
+ meta.push(`attachments: ${message.attachments.map((a) => a.name ?? "file").join(", ")}`);
250
+ }
251
+ if (message.replyCount !== undefined && message.replyCount > 0) {
252
+ meta.push(`${message.replyCount} repl${message.replyCount === 1 ? "y" : "ies"}`);
253
+ }
254
+ if (options.showId !== false) meta.push(`messageId: ${message.id}`);
255
+ if (meta.length > 0) lines.push(` _${meta.join(" · ")}_`);
256
+
257
+ return lines.join("\n");
258
+ }
259
+
260
+ export function formatMessageList(
261
+ messages: readonly MessageSummary[],
262
+ title: string,
263
+ options: { oldestFirst?: boolean } = {},
264
+ ): string {
265
+ if (messages.length === 0) return `${title}: no messages.`;
266
+ const ordered = options.oldestFirst === false ? [...messages] : [...messages].reverse();
267
+ const lines = [`## ${title}`, "", `${messages.length} message(s), oldest first:`, ""];
268
+ for (const message of ordered) {
269
+ lines.push(formatMessage(message));
270
+ lines.push("");
271
+ }
272
+ return lines.join("\n").trimEnd();
273
+ }
274
+
275
+ // ---------------------------------------------------------------------------
276
+ // Presence
277
+ // ---------------------------------------------------------------------------
278
+
279
+ const PRESENCE_ICONS: Record<string, string> = {
280
+ Available: "🟢",
281
+ AvailableIdle: "🟢",
282
+ Away: "🟡",
283
+ BeRightBack: "🟡",
284
+ Busy: "🔴",
285
+ BusyIdle: "🔴",
286
+ DoNotDisturb: "⛔",
287
+ Offline: "⚪",
288
+ PresenceUnknown: "⚪",
289
+ };
290
+
291
+ export function formatPresence(presence: PresenceInfo): string {
292
+ const icon = PRESENCE_ICONS[presence.availability] ?? "⚪";
293
+ const name = presence.displayName ? `${presence.displayName}: ` : "";
294
+ const status = presence.statusMessage ? ` — "${truncate(presence.statusMessage, 100)}"` : "";
295
+ return `${icon} ${name}${presence.availability} (${presence.activity})${status}`;
296
+ }
297
+
298
+ export function formatPresenceList(entries: readonly PresenceInfo[]): string {
299
+ if (entries.length === 0) return "No presence information available.";
300
+ return entries.map((entry) => `- ${formatPresence(entry)}`).join("\n");
301
+ }
302
+
303
+ // ---------------------------------------------------------------------------
304
+ // Calendar
305
+ // ---------------------------------------------------------------------------
306
+
307
+ export function formatEventList(events: readonly EventSummary[]): string {
308
+ if (events.length === 0) return "No events in that range.";
309
+ const lines = [`${events.length} event(s):`, ""];
310
+ for (const event of events) {
311
+ const when = event.isAllDay
312
+ ? `${formatDate(event.start)} (all day)`
313
+ : `${formatDate(event.start)} – ${formatDate(event.end)?.slice(-5)}`;
314
+ lines.push(`- **${event.subject}** · ${when}`);
315
+ if (event.organizer) lines.push(` organizer: ${event.organizer.displayName}`);
316
+ if (event.attendees.length > 0) {
317
+ lines.push(` attendees: ${truncate(event.attendees.map((a) => a.displayName).join(", "), 140)}`);
318
+ }
319
+ if (event.joinUrl) lines.push(` join: ${event.joinUrl}`);
320
+ lines.push(` eventId: ${event.id}`);
321
+ }
322
+ return lines.join("\n");
323
+ }
324
+
325
+ export function formatEventDetail(event: EventSummary): string {
326
+ const lines = [`## ${event.subject}`, ""];
327
+ lines.push(`- **When:** ${formatDate(event.start)} – ${formatDate(event.end)}${event.timeZone ? ` (${event.timeZone})` : ""}`);
328
+ if (event.location) lines.push(`- **Location:** ${event.location}`);
329
+ if (event.organizer) lines.push(`- **Organizer:** ${event.organizer.displayName}`);
330
+ if (event.attendees.length > 0) {
331
+ lines.push(`- **Attendees:** ${event.attendees.map((a) => a.displayName).join(", ")}`);
332
+ }
333
+ if (event.joinUrl) lines.push(`- **Join:** ${event.joinUrl}`);
334
+ lines.push(`- **eventId:** ${event.id}`);
335
+ if (event.bodyPreview) {
336
+ lines.push("", "### Notes", "", truncate(event.bodyPreview, 600));
337
+ }
338
+ return lines.join("\n");
339
+ }
340
+
341
+ // ---------------------------------------------------------------------------
342
+ // Files
343
+ // ---------------------------------------------------------------------------
344
+
345
+ export function formatFileList(items: readonly DriveItemSummary[], title: string): string {
346
+ if (items.length === 0) return `${title}: no files.`;
347
+ const lines = [`## ${title}`, ""];
348
+ for (const item of items) {
349
+ const icon = item.isFolder ? "📁" : "📄";
350
+ const size = item.isFolder ? "" : ` · ${formatBytes(item.size)}`;
351
+ const modified = item.lastModifiedDateTime ? ` · ${formatRelative(item.lastModifiedDateTime)}` : "";
352
+ lines.push(`- ${icon} **${item.name}**${size}${modified}`);
353
+ if (item.webUrl) lines.push(` ${item.webUrl}`);
354
+ }
355
+ return lines.join("\n");
356
+ }
package/tsconfig.json ADDED
@@ -0,0 +1,16 @@
1
+ {
2
+ "compilerOptions": {
3
+ "target": "es2024",
4
+ "lib": ["es2024"],
5
+ "module": "nodenext",
6
+ "moduleResolution": "nodenext",
7
+ "types": ["node"],
8
+ "strict": true,
9
+ "noEmit": true,
10
+ "allowImportingTsExtensions": true,
11
+ "verbatimModuleSyntax": true,
12
+ "erasableSyntaxOnly": true,
13
+ "skipLibCheck": true
14
+ },
15
+ "include": ["index.ts", "src/**/*.ts", "test/**/*.ts"]
16
+ }