@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,245 @@
1
+ /**
2
+ * teams_setup — write an account (or a tenant beneath one) to pi-teams.json.
3
+ *
4
+ * Exists so the extension can be configured by talking to pi, instead of
5
+ * hand-editing JSON. Everything it writes can equally be written by hand; this
6
+ * is a convenience, not a separate source of truth.
7
+ */
8
+
9
+ import { Type } from "typebox";
10
+ import {
11
+ getConfigPath,
12
+ upsertAccount,
13
+ upsertTenant,
14
+ setDefaultAccount,
15
+ validateSafetyLevel,
16
+ type AccountConfig,
17
+ type TenantConfig,
18
+ } from "../config/index.ts";
19
+ import type { PermissionBlock, ScopeList } from "../config/scope.ts";
20
+ import { formatPermissions, resolvePermissions } from "../config/scope.ts";
21
+ import { errorResult, run, textResult, type ToolResult } from "./shared.ts";
22
+
23
+ interface SetupParams {
24
+ name: string;
25
+ tenantId: string;
26
+ clientId?: string;
27
+ displayName?: string;
28
+ clientSecret?: string;
29
+ authMode?: string;
30
+ safetyLevel?: string;
31
+ parentAccount?: string;
32
+ setDefault?: boolean;
33
+ scopes?: string[];
34
+ allowTeams?: string[];
35
+ denyTeams?: string[];
36
+ allowChannels?: string[];
37
+ denyChannels?: string[];
38
+ allowChats?: string[];
39
+ denyChats?: string[];
40
+ allowPeople?: string[];
41
+ denyPeople?: string[];
42
+ writeAllowChannels?: string[];
43
+ writeDenyChannels?: string[];
44
+ writeAllowChats?: string[];
45
+ writeDenyChats?: string[];
46
+ writeDenyPeople?: string[];
47
+ }
48
+
49
+ const StringList = (description: string) =>
50
+ Type.Optional(Type.Array(Type.String(), { description }));
51
+
52
+ function list(allow?: string[], deny?: string[]): ScopeList | undefined {
53
+ if (!allow?.length && !deny?.length) return undefined;
54
+ const result: ScopeList = {};
55
+ if (allow?.length) result.allow = allow;
56
+ if (deny?.length) result.deny = deny;
57
+ return result;
58
+ }
59
+
60
+ /** Turn the flat parameters into the nested permission block stored in JSON. */
61
+ export function buildPermissionBlock(params: SetupParams): PermissionBlock | undefined {
62
+ const block: PermissionBlock = {};
63
+
64
+ const teams = list(params.allowTeams, params.denyTeams);
65
+ const channels = list(params.allowChannels, params.denyChannels);
66
+ const chats = list(params.allowChats, params.denyChats);
67
+ const people = list(params.allowPeople, params.denyPeople);
68
+
69
+ if (teams) block.teams = teams;
70
+ if (channels) block.channels = channels;
71
+ if (chats) block.chats = chats;
72
+ if (people) block.people = people;
73
+
74
+ const writeChannels = list(params.writeAllowChannels, params.writeDenyChannels);
75
+ const writeChats = list(params.writeAllowChats, params.writeDenyChats);
76
+ const writePeople = list(undefined, params.writeDenyPeople);
77
+
78
+ if (writeChannels || writeChats || writePeople) {
79
+ block.write = {};
80
+ if (writeChannels) block.write.channels = writeChannels;
81
+ if (writeChats) block.write.chats = writeChats;
82
+ if (writePeople) block.write.people = writePeople;
83
+ }
84
+
85
+ return Object.keys(block).length > 0 ? block : undefined;
86
+ }
87
+
88
+ export const teamsSetupTool = {
89
+ name: "teams_setup",
90
+ description:
91
+ "Configure a Microsoft Teams account (or an additional tenant beneath an existing account) and write it to " +
92
+ "~/.pi/agent/pi-teams.json. Set clientId and tenantId from your Entra ID app registration. " +
93
+ "Use parentAccount to add a guest/customer tenant to an account that already exists. " +
94
+ "Allow/deny lists restrict which teams, channels, chats and people pi may touch. " +
95
+ "After setup, run teams_login to sign in.",
96
+ parameters: Type.Object({
97
+ name: Type.String({
98
+ description: "Short account (or tenant) name used in every other tool, e.g. 'work', 'customer-alpha'",
99
+ }),
100
+ tenantId: Type.String({
101
+ description: "Entra ID tenant ID or domain, e.g. 'contoso.onmicrosoft.com' or a GUID",
102
+ }),
103
+ clientId: Type.Optional(
104
+ Type.String({
105
+ description:
106
+ "Application (client) ID of the Entra ID app registration. Required for a new account; a tenant inherits the account's.",
107
+ }),
108
+ ),
109
+ displayName: Type.Optional(Type.String({ description: "Friendly name shown in status output" })),
110
+ clientSecret: Type.Optional(
111
+ Type.String({
112
+ description:
113
+ "Client secret — only for app-only (client-credentials) access. Not needed, and not recommended, for acting as the user.",
114
+ }),
115
+ ),
116
+ authMode: Type.Optional(
117
+ Type.String({
118
+ description:
119
+ "'device-code' (act as the signed-in user, default), 'client-credentials' (app-only, cannot post as a person), or 'auto'",
120
+ }),
121
+ ),
122
+ safetyLevel: Type.Optional(
123
+ Type.String({
124
+ description: "'open' (no prompts), 'confirm' (ask before every write, default), or 'readonly' (never write)",
125
+ }),
126
+ ),
127
+ parentAccount: Type.Optional(
128
+ Type.String({
129
+ description: "Add this as a tenant beneath an existing account instead of creating a new account",
130
+ }),
131
+ ),
132
+ setDefault: Type.Optional(
133
+ Type.Boolean({ description: "Make this the default account for tools that omit 'account'" }),
134
+ ),
135
+ scopes: StringList("Override the requested Microsoft Graph scopes (advanced)"),
136
+ allowTeams: StringList("Teams pi may see at all. Glob patterns, e.g. ['Engineering', 'Project *']"),
137
+ denyTeams: StringList("Teams pi must never touch"),
138
+ allowChannels: StringList("Channels pi may see. Use 'Team/Channel' paths, e.g. ['Engineering/*']"),
139
+ denyChannels: StringList("Channels pi must never touch, e.g. ['*/Announcements']"),
140
+ allowChats: StringList("Chats pi may see — match on chat topic or participant name/e-mail"),
141
+ denyChats: StringList("Chats pi must never touch"),
142
+ allowPeople: StringList("People pi may interact with (name, UPN or e-mail patterns)"),
143
+ denyPeople: StringList("People pi must never message, e.g. ['ceo@contoso.com']"),
144
+ writeAllowChannels: StringList("Channels pi may POST in (narrower than allowChannels)"),
145
+ writeDenyChannels: StringList("Channels pi may read but never post in"),
146
+ writeAllowChats: StringList("Chats pi may send messages to"),
147
+ writeDenyChats: StringList("Chats pi may read but never send to"),
148
+ writeDenyPeople: StringList("People pi may read about but never message"),
149
+ }),
150
+ promptSnippet: "Configure a Microsoft Teams account for pi",
151
+ promptGuidelines: [
152
+ "Use teams_setup when the user wants to connect a Teams account, add a second company/tenant, or change which channels pi may use.",
153
+ "Ask for the clientId and tenantId of their Entra ID app registration if they have not provided them.",
154
+ "After teams_setup, tell the user to run teams_login (or /teams-login) to sign in as themselves.",
155
+ ],
156
+
157
+ async execute(
158
+ _toolCallId: string,
159
+ params: SetupParams,
160
+ _signal: AbortSignal | undefined,
161
+ _onUpdate: undefined,
162
+ _ctx: unknown,
163
+ ): Promise<ToolResult> {
164
+ return run(async () => {
165
+ const safetyLevel = params.safetyLevel ? validateSafetyLevel(params.safetyLevel) : undefined;
166
+ if (params.safetyLevel && !safetyLevel) {
167
+ return errorResult(
168
+ `Invalid safetyLevel "${params.safetyLevel}". Use "open", "confirm" or "readonly".`,
169
+ );
170
+ }
171
+
172
+ const permissions = buildPermissionBlock(params);
173
+
174
+ if (params.parentAccount) {
175
+ const tenant: TenantConfig = {
176
+ name: params.name,
177
+ tenantId: params.tenantId,
178
+ clientId: params.clientId,
179
+ clientSecret: params.clientSecret,
180
+ authMode: params.authMode as TenantConfig["authMode"],
181
+ scopes: params.scopes,
182
+ safetyLevel,
183
+ permissions,
184
+ };
185
+ upsertTenant(params.parentAccount, tenant);
186
+
187
+ return textResult(
188
+ [
189
+ `✅ Tenant "${params.name}" added to account "${params.parentAccount}".`,
190
+ "",
191
+ `Config: ${getConfigPath()}`,
192
+ "",
193
+ permissions ? formatPermissions(resolvePermissions([permissions])) : "No scope restrictions configured — pi may use everything this identity can reach.",
194
+ "",
195
+ `Next: run teams_login with account "${params.parentAccount}" and tenant "${params.name}".`,
196
+ ].join("\n"),
197
+ { account: params.parentAccount, tenant: params.name },
198
+ );
199
+ }
200
+
201
+ if (!params.clientId) {
202
+ return errorResult(
203
+ "clientId is required for a new account. Create an Entra ID app registration " +
204
+ "(public client, 'Allow public client flows' enabled) and pass its Application (client) ID.",
205
+ );
206
+ }
207
+
208
+ const account: AccountConfig = {
209
+ name: params.name,
210
+ displayName: params.displayName,
211
+ tenantId: params.tenantId,
212
+ clientId: params.clientId,
213
+ clientSecret: params.clientSecret,
214
+ authMode: (params.authMode as AccountConfig["authMode"]) ?? "device-code",
215
+ scopes: params.scopes,
216
+ safetyLevel: safetyLevel ?? "confirm",
217
+ permissions,
218
+ };
219
+
220
+ upsertAccount(account);
221
+ if (params.setDefault) setDefaultAccount(params.name);
222
+
223
+ return textResult(
224
+ [
225
+ `✅ Teams account "${params.name}" saved.`,
226
+ "",
227
+ `- Tenant: ${params.tenantId}`,
228
+ `- Client: ${params.clientId}`,
229
+ `- Auth mode: ${account.authMode}`,
230
+ `- Safety level: ${account.safetyLevel}`,
231
+ `- Config file: ${getConfigPath()} (mode 0600)`,
232
+ "",
233
+ "### Effective scope rules",
234
+ "",
235
+ permissions
236
+ ? formatPermissions(resolvePermissions([permissions]))
237
+ : "No restrictions configured — pi may use everything this identity can reach.",
238
+ "",
239
+ `Next: run teams_login${params.setDefault ? "" : ` with account "${params.name}"`} to sign in as yourself.`,
240
+ ].join("\n"),
241
+ { account: params.name, configPath: getConfigPath() },
242
+ );
243
+ });
244
+ },
245
+ };
@@ -0,0 +1,73 @@
1
+ /**
2
+ * teams_status — who pi is acting as, and what it is allowed to do.
3
+ */
4
+
5
+ import { Type } from "typebox";
6
+ import { buildConnectionCard, formatStatusText } from "../status.ts";
7
+ import { getToken, isFresh } from "../auth/token-store.ts";
8
+ import { readRootConfig } from "../config/index.ts";
9
+ import {
10
+ AccountParam,
11
+ TenantParam,
12
+ connectionFor,
13
+ run,
14
+ textResult,
15
+ type ToolContext,
16
+ type ToolResult,
17
+ } from "./shared.ts";
18
+
19
+ export const teamsStatusTool = {
20
+ name: "teams_status",
21
+ description:
22
+ "Show the current Microsoft Teams connection: which account and tenant pi is acting as, whether it is " +
23
+ "signed in, the safety level, and the allow/deny rules in force. Call this when the user asks what pi " +
24
+ "can do in Teams, or to check a connection before a write.",
25
+ parameters: Type.Object({
26
+ account: AccountParam,
27
+ tenant: TenantParam,
28
+ }),
29
+ promptSnippet: "Show the Teams connection and permissions",
30
+
31
+ async execute(
32
+ _toolCallId: string,
33
+ params: { account?: string; tenant?: string },
34
+ _signal: AbortSignal | undefined,
35
+ _onUpdate: undefined,
36
+ ctx: ToolContext,
37
+ ): Promise<ToolResult> {
38
+ return run(async () => {
39
+ const conn = connectionFor(ctx, params.account, params.tenant);
40
+ const card = buildConnectionCard(conn);
41
+ const root = readRootConfig();
42
+
43
+ const others: string[] = [];
44
+ for (const account of root.accounts) {
45
+ const tenants = [
46
+ { name: account.name, tenantId: account.tenantId },
47
+ ...(account.tenants ?? []).map((t) => ({ name: `${account.name}/${t.name}`, tenantId: t.tenantId })),
48
+ ];
49
+ for (const entry of tenants) {
50
+ const token = getToken(account.name, entry.tenantId);
51
+ const state = isFresh(token)
52
+ ? "signed in"
53
+ : token?.refreshToken
54
+ ? "session saved (will refresh)"
55
+ : "not signed in";
56
+ const who = token?.user?.upn ? ` — ${token.user.upn}` : "";
57
+ others.push(`- ${entry.name}: ${state}${who}`);
58
+ }
59
+ }
60
+
61
+ return textResult(
62
+ [
63
+ formatStatusText(card),
64
+ "",
65
+ "### All configured accounts",
66
+ "",
67
+ others.join("\n") || "(none)",
68
+ ].join("\n"),
69
+ { account: conn.account, tenant: conn.tenant, signedIn: card?.signedIn ?? false },
70
+ );
71
+ });
72
+ },
73
+ };
@@ -0,0 +1,30 @@
1
+ /**
2
+ * Tool names in one place.
3
+ *
4
+ * The safety interceptor and the tool modules both need to agree on what
5
+ * counts as a mutation; keeping the set here (instead of in shared.ts) avoids
6
+ * an import cycle between safety/ and tools/.
7
+ */
8
+
9
+ /** Every tool that changes something in Teams, Outlook, or the local config. */
10
+ export const MUTATION_TOOLS = new Set<string>([
11
+ // Messaging
12
+ "teams_send_chat_message",
13
+ "teams_send_channel_message",
14
+ "teams_reply_channel_message",
15
+ "teams_create_chat",
16
+ "teams_delete_message",
17
+ "teams_react",
18
+ // Structure
19
+ "teams_create_channel",
20
+ // Presence
21
+ "teams_set_presence",
22
+ "teams_set_status_message",
23
+ // Calendar
24
+ "teams_create_meeting",
25
+ "teams_update_meeting",
26
+ "teams_cancel_meeting",
27
+ // Local configuration and credentials
28
+ "teams_setup",
29
+ "teams_logout",
30
+ ]);
package/src/types.ts ADDED
@@ -0,0 +1,192 @@
1
+ /**
2
+ * Domain data types.
3
+ *
4
+ * Plain, immutable interfaces. No behavior, no classes, no inheritance.
5
+ * Everything the Graph layer returns is normalized into these shapes before
6
+ * it reaches a formatter or a tool.
7
+ */
8
+
9
+ // ---------------------------------------------------------------------------
10
+ // Identity
11
+ // ---------------------------------------------------------------------------
12
+
13
+ /** The signed-in user behind an access token. */
14
+ export interface SignedInUser {
15
+ /** Object ID (oid claim) */
16
+ id: string;
17
+ /** Display name */
18
+ displayName: string;
19
+ /** User principal name / login */
20
+ upn: string;
21
+ /** Primary SMTP address, when known */
22
+ mail?: string;
23
+ /** Tenant ID (tid claim) */
24
+ tenantId: string;
25
+ /** Job title, when known */
26
+ jobTitle?: string;
27
+ }
28
+
29
+ /** A person referenced in a message, member list, or search result. */
30
+ export interface PersonRef {
31
+ id?: string;
32
+ displayName: string;
33
+ upn?: string;
34
+ mail?: string;
35
+ }
36
+
37
+ // ---------------------------------------------------------------------------
38
+ // Teams & channels
39
+ // ---------------------------------------------------------------------------
40
+
41
+ export interface TeamSummary {
42
+ id: string;
43
+ displayName: string;
44
+ description?: string;
45
+ visibility?: string;
46
+ isArchived?: boolean;
47
+ webUrl?: string;
48
+ }
49
+
50
+ export interface ChannelSummary {
51
+ id: string;
52
+ displayName: string;
53
+ description?: string;
54
+ /** "standard" | "private" | "shared" */
55
+ membershipType?: string;
56
+ webUrl?: string;
57
+ /** Owning team, filled in by the caller for display + scope checks */
58
+ teamId?: string;
59
+ teamName?: string;
60
+ }
61
+
62
+ export interface MemberSummary {
63
+ id: string;
64
+ displayName: string;
65
+ upn?: string;
66
+ mail?: string;
67
+ roles: string[];
68
+ }
69
+
70
+ // ---------------------------------------------------------------------------
71
+ // Chats
72
+ // ---------------------------------------------------------------------------
73
+
74
+ export interface ChatSummary {
75
+ id: string;
76
+ /** "oneOnOne" | "group" | "meeting" */
77
+ chatType: string;
78
+ /** Explicit topic for group chats, otherwise derived from members */
79
+ topic?: string;
80
+ /** Human label used for display and scope matching */
81
+ label: string;
82
+ members: PersonRef[];
83
+ lastUpdated?: string;
84
+ lastMessagePreview?: string;
85
+ lastMessageFrom?: string;
86
+ webUrl?: string;
87
+ }
88
+
89
+ // ---------------------------------------------------------------------------
90
+ // Messages
91
+ // ---------------------------------------------------------------------------
92
+
93
+ export interface MessageSummary {
94
+ id: string;
95
+ /** Plain-text rendering of the message body */
96
+ text: string;
97
+ /** Original content type: "text" | "html" */
98
+ contentType: string;
99
+ from?: PersonRef;
100
+ createdDateTime?: string;
101
+ lastModifiedDateTime?: string;
102
+ /** Set when the message was soft-deleted */
103
+ deletedDateTime?: string;
104
+ subject?: string;
105
+ importance?: string;
106
+ /** Parent message id for channel replies */
107
+ replyToId?: string;
108
+ /** Number of replies, when the caller resolved them */
109
+ replyCount?: number;
110
+ mentions: PersonRef[];
111
+ reactions: MessageReaction[];
112
+ attachments: MessageAttachment[];
113
+ webUrl?: string;
114
+ /** Where the message lives — used for follow-up calls and scope checks */
115
+ location?: MessageLocation;
116
+ }
117
+
118
+ export interface MessageReaction {
119
+ type: string;
120
+ user?: string;
121
+ createdDateTime?: string;
122
+ }
123
+
124
+ export interface MessageAttachment {
125
+ id?: string;
126
+ name?: string;
127
+ contentType?: string;
128
+ contentUrl?: string;
129
+ }
130
+
131
+ export interface MessageLocation {
132
+ kind: "chat" | "channel";
133
+ chatId?: string;
134
+ teamId?: string;
135
+ teamName?: string;
136
+ channelId?: string;
137
+ channelName?: string;
138
+ }
139
+
140
+ // ---------------------------------------------------------------------------
141
+ // Presence
142
+ // ---------------------------------------------------------------------------
143
+
144
+ export interface PresenceInfo {
145
+ id: string;
146
+ displayName?: string;
147
+ availability: string;
148
+ activity: string;
149
+ statusMessage?: string;
150
+ }
151
+
152
+ // ---------------------------------------------------------------------------
153
+ // Calendar & meetings
154
+ // ---------------------------------------------------------------------------
155
+
156
+ export interface EventSummary {
157
+ id: string;
158
+ subject: string;
159
+ start?: string;
160
+ end?: string;
161
+ timeZone?: string;
162
+ isAllDay?: boolean;
163
+ isOnlineMeeting?: boolean;
164
+ joinUrl?: string;
165
+ organizer?: PersonRef;
166
+ attendees: PersonRef[];
167
+ location?: string;
168
+ bodyPreview?: string;
169
+ webLink?: string;
170
+ }
171
+
172
+ export interface OnlineMeetingSummary {
173
+ id: string;
174
+ subject?: string;
175
+ joinUrl: string;
176
+ start?: string;
177
+ end?: string;
178
+ }
179
+
180
+ // ---------------------------------------------------------------------------
181
+ // Files
182
+ // ---------------------------------------------------------------------------
183
+
184
+ export interface DriveItemSummary {
185
+ id: string;
186
+ name: string;
187
+ size?: number;
188
+ lastModifiedDateTime?: string;
189
+ lastModifiedBy?: string;
190
+ webUrl?: string;
191
+ isFolder: boolean;
192
+ }
@@ -0,0 +1,133 @@
1
+ /**
2
+ * Microsoft Graph errors → messages a person can act on.
3
+ *
4
+ * Graph is unusually good at returning precise error codes and unusually bad
5
+ * at explaining what to do about them. The mapping below turns the common ones
6
+ * into the next step ("consent to X", "run teams_login", "ask an admin").
7
+ */
8
+
9
+ /** An error returned by the Graph API, with its code preserved. */
10
+ export class GraphError extends Error {
11
+ readonly status: number;
12
+ readonly code: string;
13
+ readonly requestId?: string;
14
+ readonly raw?: unknown;
15
+
16
+ constructor(status: number, code: string, message: string, requestId?: string, raw?: unknown) {
17
+ super(message);
18
+ this.name = "GraphError";
19
+ this.status = status;
20
+ this.code = code;
21
+ this.requestId = requestId;
22
+ this.raw = raw;
23
+ }
24
+ }
25
+
26
+ /** Raised when a scope rule blocks an operation. */
27
+ export class ScopeDeniedError extends Error {
28
+ readonly target: string;
29
+ readonly reason: string;
30
+ constructor(target: string, reason: string) {
31
+ super(`Blocked by configuration: ${target} — ${reason}.`);
32
+ this.name = "ScopeDeniedError";
33
+ this.target = target;
34
+ this.reason = reason;
35
+ }
36
+ }
37
+
38
+ const CONSENT_HINTS: Record<string, string> = {
39
+ "ChannelMessage.Read.All": "reading channel messages",
40
+ "ChannelMessage.Send": "posting in channels",
41
+ "Chat.ReadWrite": "reading and sending chat messages",
42
+ "ChatMessage.Send": "sending chat messages",
43
+ "Presence.ReadWrite": "reading and setting your presence",
44
+ "Calendars.ReadWrite": "reading and creating calendar events",
45
+ "OnlineMeetings.ReadWrite": "creating Teams meetings",
46
+ };
47
+
48
+ /** Normalize any thrown value into a readable, actionable message. */
49
+ export function formatGraphError(error: unknown): string {
50
+ if (error instanceof ScopeDeniedError) return error.message;
51
+
52
+ if (error instanceof GraphError) {
53
+ switch (error.code) {
54
+ case "InvalidAuthenticationToken":
55
+ return "The access token was rejected. Run teams_login to sign in again.";
56
+ case "Unauthorized":
57
+ return "Authentication failed. Run teams_login to sign in again.";
58
+ case "Forbidden":
59
+ case "AccessDenied":
60
+ case "Authorization_RequestDenied":
61
+ return (
62
+ "Permission denied. The signed-in user (or the app registration) lacks access to this " +
63
+ "resource. Run teams_doctor to see which Graph scopes were actually granted."
64
+ );
65
+ case "NotFound":
66
+ case "itemNotFound":
67
+ case "ResourceNotFound":
68
+ return "Not found. Check the team, channel, chat or message ID.";
69
+ case "ErrorItemNotFound":
70
+ return "Not found — the item may have been deleted or you no longer have access.";
71
+ case "activityLimitReached":
72
+ case "TooManyRequests":
73
+ return "Throttled by Microsoft Graph. Wait a moment and try again.";
74
+ case "BadRequest":
75
+ return `Graph rejected the request: ${error.message}`;
76
+ case "UnknownError":
77
+ return `Graph returned an unspecified error: ${error.message}`;
78
+ default:
79
+ return `${error.code}: ${error.message}`;
80
+ }
81
+ }
82
+
83
+ if (error instanceof Error) {
84
+ const msg = error.message;
85
+ if (msg.includes("ENOTFOUND")) return "DNS error: could not resolve the Graph endpoint.";
86
+ if (msg.includes("ECONNREFUSED") || msg.includes("ETIMEDOUT")) {
87
+ return "Network error: could not reach Microsoft Graph. Check your connection or proxy.";
88
+ }
89
+ if (msg.includes("AADSTS7000218")) {
90
+ return (
91
+ "The app registration rejected the device code flow. Enable 'Allow public client flows' " +
92
+ "in Entra ID → App registrations → Authentication."
93
+ );
94
+ }
95
+ if (msg.includes("AADSTS65001")) {
96
+ return "Consent is missing for one or more scopes. Sign in again, or ask an admin to grant consent.";
97
+ }
98
+ if (msg.includes("AADSTS50076") || msg.includes("AADSTS50079")) {
99
+ return "Multi-factor authentication is required. Complete the sign-in in the browser and retry.";
100
+ }
101
+ if (msg.includes("AADSTS50020")) {
102
+ return (
103
+ "The signed-in user does not exist in that tenant. For a guest/customer tenant, make sure the " +
104
+ "account is invited there and the app registration is multi-tenant."
105
+ );
106
+ }
107
+ return msg.split("\n")[0] ?? "Unknown error";
108
+ }
109
+
110
+ return String(error);
111
+ }
112
+
113
+ export function isAuthError(error: unknown): boolean {
114
+ if (error instanceof GraphError) {
115
+ return error.status === 401 || error.code === "InvalidAuthenticationToken";
116
+ }
117
+ return false;
118
+ }
119
+
120
+ export function isNotFoundError(error: unknown): boolean {
121
+ if (error instanceof GraphError) return error.status === 404;
122
+ return false;
123
+ }
124
+
125
+ export function isThrottled(error: unknown): boolean {
126
+ if (error instanceof GraphError) return error.status === 429 || error.status === 503;
127
+ return false;
128
+ }
129
+
130
+ /** Human-readable purpose of a Graph scope, for doctor output. */
131
+ export function describeScope(scope: string): string | undefined {
132
+ return CONSENT_HINTS[scope];
133
+ }