msteams-mcp 0.27.2 → 0.28.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/README.md +20 -3
- package/dist/api/assignments-api.d.ts +124 -0
- package/dist/api/assignments-api.js +327 -0
- package/dist/api/assignments-api.test.d.ts +7 -0
- package/dist/api/assignments-api.test.js +302 -0
- package/dist/api/csa-api.js +8 -8
- package/dist/api/graph-files-api.d.ts +34 -0
- package/dist/api/graph-files-api.js +169 -0
- package/dist/api/graph-files-api.test.d.ts +1 -0
- package/dist/api/graph-files-api.test.js +152 -0
- package/dist/api/index.d.ts +10 -0
- package/dist/api/index.js +10 -0
- package/dist/auth/assignments-refresh.test.d.ts +1 -0
- package/dist/auth/assignments-refresh.test.js +43 -0
- package/dist/auth/assignments-token.test.d.ts +1 -0
- package/dist/auth/assignments-token.test.js +36 -0
- package/dist/auth/crypto.d.ts +0 -4
- package/dist/auth/crypto.js +0 -12
- package/dist/auth/crypto.test.js +1 -18
- package/dist/auth/index.d.ts +7 -0
- package/dist/auth/index.js +7 -0
- package/dist/auth/session-store.d.ts +9 -6
- package/dist/auth/session-store.js +12 -88
- package/dist/auth/token-extractor.d.ts +31 -74
- package/dist/auth/token-extractor.js +171 -432
- package/dist/auth/token-refresh-http.d.ts +3 -10
- package/dist/auth/token-refresh-http.js +155 -219
- package/dist/auth/token-refresh-http.test.js +125 -19
- package/dist/auth/token-refresh.d.ts +6 -21
- package/dist/auth/token-refresh.js +62 -103
- package/dist/auth/token-refresh.test.d.ts +1 -0
- package/dist/auth/token-refresh.test.js +31 -0
- package/dist/browser/auth.d.ts +0 -35
- package/dist/browser/auth.js +18 -57
- package/dist/browser/context.d.ts +4 -14
- package/dist/browser/context.js +7 -18
- package/dist/cli.js +1 -1
- package/dist/constants.d.ts +11 -12
- package/dist/constants.js +11 -15
- package/dist/index.js +0 -0
- package/dist/research/auth-research.d.ts +10 -0
- package/dist/research/auth-research.js +175 -0
- package/dist/research/explore.d.ts +11 -0
- package/dist/research/explore.js +287 -0
- package/dist/research/search-research.d.ts +17 -0
- package/dist/research/search-research.js +317 -0
- package/dist/server.d.ts +1 -53
- package/dist/server.js +10 -167
- package/dist/test/debug-search.d.ts +10 -0
- package/dist/test/debug-search.js +147 -0
- package/dist/test/mcp-harness.d.ts +17 -0
- package/dist/test/mcp-harness.js +476 -0
- package/dist/tools/assignment-tools.d.ts +90 -0
- package/dist/tools/assignment-tools.js +151 -0
- package/dist/tools/assignment-tools.test.d.ts +1 -0
- package/dist/tools/assignment-tools.test.js +13 -0
- package/dist/tools/auth-tools.js +36 -77
- package/dist/tools/file-tools.d.ts +24 -3
- package/dist/tools/file-tools.js +31 -4
- package/dist/tools/index.d.ts +2 -8
- package/dist/tools/index.js +1 -0
- package/dist/tools/meeting-tools.js +2 -2
- package/dist/tools/message-tools.d.ts +6 -6
- package/dist/tools/message-tools.js +21 -21
- package/dist/tools/people-tools.js +4 -4
- package/dist/tools/registry.d.ts +4 -4
- package/dist/tools/registry.js +5 -3
- package/dist/tools/registry.test.js +4 -14
- package/dist/tools/search-tools.js +4 -4
- package/dist/tools/tag-tools.js +1 -1
- package/dist/types/errors.d.ts +4 -0
- package/dist/types/errors.js +11 -0
- package/dist/utils/api-config.d.ts +40 -0
- package/dist/utils/api-config.js +50 -0
- package/dist/utils/assignments-auth.test.d.ts +1 -0
- package/dist/utils/assignments-auth.test.js +57 -0
- package/dist/utils/auth-guards-assignments.test.d.ts +5 -0
- package/dist/utils/auth-guards-assignments.test.js +89 -0
- package/dist/utils/auth-guards.d.ts +23 -16
- package/dist/utils/auth-guards.js +81 -65
- package/dist/utils/auth-guards.test.js +37 -2
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -8,14 +8,13 @@
|
|
|
8
8
|
|
|
9
9
|
Give AI access to Microsoft Teams easily with your standard Teams login.
|
|
10
10
|
|
|
11
|
-
> [!NOTE]
|
|
12
11
|
> ### No Azure setup, app registration, Graph permissions or IT support required.
|
|
13
12
|
Search messages and email, catch up on unread chats, read threads, send replies, retrieve meeting transcripts and more.
|
|
14
13
|
|
|
15
14
|
|
|
16
15
|
## Why msteams-mcp?
|
|
17
16
|
|
|
18
|
-
- **Do real work** - search, unread chats, threads, replies, people, channels, meetings, transcripts, files, reactions and
|
|
17
|
+
- **Do real work** - search, unread chats, threads, replies, people, channels, meetings, transcripts, files, reactions, and assignments on education tenants.
|
|
19
18
|
- **Sign in once** - tokens refresh automatically and the browser stays out of routine operations.
|
|
20
19
|
- **Secure** - the assistant has only your existing Teams permissions, with encrypted session data stored locally.
|
|
21
20
|
- **Use it anywhere** - every capability works through both MCP clients and the included `msteams` CLI.
|
|
@@ -201,9 +200,26 @@ See [CLI Usage](#cli-usage) for commands.
|
|
|
201
200
|
| Tool | Description |
|
|
202
201
|
| ------------------------ | ------------------------------------------------------------------ |
|
|
203
202
|
| `teams_get_shared_files` | Get files and links shared in a conversation (supports pagination) |
|
|
203
|
+
| `teams_download_file` | Download a shared file or assignment attachment to a local path |
|
|
204
204
|
|
|
205
205
|
|
|
206
|
-
|
|
206
|
+
`teams_get_shared_files` returns both files (name, extension, URL, size) and links (URL, title), along with who shared each item. Works for channels, group chats, 1:1 chats, and meeting chats.
|
|
207
|
+
|
|
208
|
+
`teams_download_file` takes a file's `webUrl` from `teams_get_shared_files`, or an attachment's `fileUrl` from `teams_get_assignment`. It downloads through Microsoft Graph using the Teams client's own access, so no extra sign-in is needed. Files stream straight to disk, never overwrite an existing file, and are removed if the download fails part-way. Links and Microsoft Forms aren't files and can't be downloaded.
|
|
209
|
+
|
|
210
|
+
### Assignments (education tenants only)
|
|
211
|
+
|
|
212
|
+
|
|
213
|
+
| Tool | Description |
|
|
214
|
+
| ------------------------- | ---------------------------------------------------------------------------------------------- |
|
|
215
|
+
| `teams_list_assignments` | List your assignments across classes (`active`, `completed` or `all`), with due dates, grades and your submission state |
|
|
216
|
+
| `teams_get_assignment` | Get one assignment's full detail, including instructions, attachments and your submission (with your own copies of files) |
|
|
217
|
+
| `teams_submission_action` | Turn in, undo turn-in, or mark viewed on your own submission |
|
|
218
|
+
|
|
219
|
+
|
|
220
|
+
Assignments is optional. It only works on education tenants that use Teams Assignments, and its token is requested separately, on demand, so other accounts pay no extra cost. If Assignments isn't available, or its authorisation fails, only these tools return an error: the rest of Teams keeps working, and no browser or re-login is triggered.
|
|
221
|
+
|
|
222
|
+
Download attachments (Word, PowerPoint and other files) with `teams_download_file`. Microsoft Forms quizzes and links are returned as URLs instead.
|
|
207
223
|
|
|
208
224
|
### Session
|
|
209
225
|
|
|
@@ -313,6 +329,7 @@ npm run cli -- send "Hi" --to "conversation-id"
|
|
|
313
329
|
- **Token expiry** - Tokens expire after ~1 hour; headless refresh is attempted or run `teams_login` again when needed
|
|
314
330
|
- **Undocumented APIs** - Uses Microsoft's internal APIs which may change without notice
|
|
315
331
|
- **Search limitations** - Full-text search only; thread replies not matching search terms won't appear (but LLM will likely use `teams_get_thread` for full context)
|
|
332
|
+
- **Assignments** - Education tenants only. Turn-in and undo turn-in act on your real account; handing in again records a new hand-in date that your teacher sees
|
|
316
333
|
|
|
317
334
|
|
|
318
335
|
|
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* EDU Assignments API client.
|
|
3
|
+
*
|
|
4
|
+
* Talks to the Teams Assignments service at `assignments.edu.cloud.microsoft`
|
|
5
|
+
* (backed by OneNote EDU). This is the API behind the Teams "Assignments" tab —
|
|
6
|
+
* not Microsoft Graph, though the data model mirrors Graph's `education` types.
|
|
7
|
+
*
|
|
8
|
+
* Auth: a Bearer token for the Assignments app (audience `8f348934-...`), obtained
|
|
9
|
+
* via `requireAssignmentsTokenAsync()`. That token is minted by the HTTP token
|
|
10
|
+
* refresh (the `EduAssignments` entry in `token-refresh-http.ts`).
|
|
11
|
+
*
|
|
12
|
+
* Read endpoints (my work, assignment detail, submissions) and the submit /
|
|
13
|
+
* unsubmit actions are verified live against an education tenant; the "mark
|
|
14
|
+
* viewed" PATCH against a captured web session.
|
|
15
|
+
*/
|
|
16
|
+
import { type Result } from '../types/result.js';
|
|
17
|
+
/** IDs are opaque single path segments; reject dot segments and URL delimiters. */
|
|
18
|
+
export declare const ASSIGNMENT_ID_PATTERN: RegExp;
|
|
19
|
+
/** Which slice of the user's assignments to list. */
|
|
20
|
+
export type AssignmentStatusFilter = 'active' | 'completed' | 'all';
|
|
21
|
+
/** A grade/feedback outcome on a submission (best-effort parse of Graph education shapes). */
|
|
22
|
+
export interface SubmissionOutcome {
|
|
23
|
+
/** Awarded points, if a points grade has been given. */
|
|
24
|
+
points?: number;
|
|
25
|
+
/** Points visible to the student (after release). */
|
|
26
|
+
publishedPoints?: number;
|
|
27
|
+
/** Feedback text, if given. */
|
|
28
|
+
feedback?: string;
|
|
29
|
+
/** Feedback text visible to the student (after release). */
|
|
30
|
+
publishedFeedback?: string;
|
|
31
|
+
}
|
|
32
|
+
/** A file, form or link attached to an assignment or submission. */
|
|
33
|
+
export interface AssignmentAttachment {
|
|
34
|
+
id?: string;
|
|
35
|
+
name?: string;
|
|
36
|
+
/** word | powerpoint | excel | file | form | link | … (from the resource's OData type). */
|
|
37
|
+
type: string;
|
|
38
|
+
/** Graph drive-item URL; pass to teams_download_file. Absent for forms and links. */
|
|
39
|
+
fileUrl?: string;
|
|
40
|
+
/** Web URL for non-file resources (e.g. a Microsoft Forms quiz or a link). */
|
|
41
|
+
url?: string;
|
|
42
|
+
/** True when each student gets their own copy (found in their submission's attachments). */
|
|
43
|
+
copiedForEachStudent?: boolean;
|
|
44
|
+
}
|
|
45
|
+
/** A compact view of the signed-in user's submission for an assignment. */
|
|
46
|
+
export interface SubmissionSummary {
|
|
47
|
+
id?: string;
|
|
48
|
+
/** working | submitted | returned | reassigned | excused (Graph education states). */
|
|
49
|
+
status?: string;
|
|
50
|
+
submittedDateTime?: string;
|
|
51
|
+
returnedDateTime?: string;
|
|
52
|
+
/** Merged grade/feedback from the submission's outcomes. */
|
|
53
|
+
outcome?: SubmissionOutcome;
|
|
54
|
+
/** The student's working files, including their copies of distributed attachments. */
|
|
55
|
+
attachments?: AssignmentAttachment[];
|
|
56
|
+
/** Files as they were when last turned in. */
|
|
57
|
+
submittedAttachments?: AssignmentAttachment[];
|
|
58
|
+
}
|
|
59
|
+
/** A single assignment as surfaced to the caller. */
|
|
60
|
+
export interface Assignment {
|
|
61
|
+
id: string;
|
|
62
|
+
classId: string;
|
|
63
|
+
displayName: string;
|
|
64
|
+
status?: string;
|
|
65
|
+
dueDateTime?: string;
|
|
66
|
+
assignedDateTime?: string;
|
|
67
|
+
closeDateTime?: string;
|
|
68
|
+
allowLateSubmissions?: boolean;
|
|
69
|
+
/** Max points, when the assignment is points-graded. */
|
|
70
|
+
maxPoints?: number;
|
|
71
|
+
/** Plain-ish instructions content (HTML as stored by the service). */
|
|
72
|
+
instructions?: string;
|
|
73
|
+
/** Deep link that opens the assignment in Teams. */
|
|
74
|
+
webUrl?: string;
|
|
75
|
+
/** The caller's own submission summary, when expanded. */
|
|
76
|
+
submission?: SubmissionSummary;
|
|
77
|
+
/** Teacher-provided attachments (teams_get_assignment only). */
|
|
78
|
+
attachments?: AssignmentAttachment[];
|
|
79
|
+
}
|
|
80
|
+
/** Result of listing the user's assignments. */
|
|
81
|
+
export interface ListAssignmentsResult {
|
|
82
|
+
statusFilter: AssignmentStatusFilter;
|
|
83
|
+
returned: number;
|
|
84
|
+
assignments: Assignment[];
|
|
85
|
+
/** Follow this link for the next page. A full page may require an empty final fetch. */
|
|
86
|
+
nextLink?: string;
|
|
87
|
+
}
|
|
88
|
+
/**
|
|
89
|
+
* Lists the signed-in user's assignments across all their classes.
|
|
90
|
+
*
|
|
91
|
+
* Uses the `/edu/me/work` endpoint with the caller's submission (and its grade
|
|
92
|
+
* outcomes) expanded inline, so a single call yields due dates, status, points,
|
|
93
|
+
* and the user's own submission state.
|
|
94
|
+
*
|
|
95
|
+
* @param options.statusFilter - active (assigned, not completed) | completed | all. Default active.
|
|
96
|
+
* @param options.top - max assignments to return (default 25).
|
|
97
|
+
*/
|
|
98
|
+
export declare function listMyAssignments(options?: {
|
|
99
|
+
statusFilter?: AssignmentStatusFilter;
|
|
100
|
+
top?: number;
|
|
101
|
+
nextLink?: string;
|
|
102
|
+
}): Promise<Result<ListAssignmentsResult>>;
|
|
103
|
+
/**
|
|
104
|
+
* Gets a single assignment's full detail.
|
|
105
|
+
*
|
|
106
|
+
* @param classId - the class (group) ID the assignment belongs to.
|
|
107
|
+
* @param assignmentId - the assignment ID.
|
|
108
|
+
*/
|
|
109
|
+
export declare function getAssignment(classId: string, assignmentId: string): Promise<Result<Assignment>>;
|
|
110
|
+
/** The submission action to perform on the caller's own submission. */
|
|
111
|
+
export type SubmissionAction = 'submit' | 'unsubmit' | 'view';
|
|
112
|
+
/**
|
|
113
|
+
* Performs an action on the caller's own submission: turn in (submit), undo
|
|
114
|
+
* turn-in (unsubmit), or mark viewed. These change state on the user's real
|
|
115
|
+
* Teams account, so callers should confirm intent before invoking.
|
|
116
|
+
*
|
|
117
|
+
* submit/unsubmit (POST, no body) are verified live; `view` (PATCH) against a
|
|
118
|
+
* captured session.
|
|
119
|
+
*/
|
|
120
|
+
export declare function actOnSubmission(action: SubmissionAction, classId: string, assignmentId: string, submissionId: string): Promise<Result<{
|
|
121
|
+
action: SubmissionAction;
|
|
122
|
+
submissionId: string;
|
|
123
|
+
status?: string;
|
|
124
|
+
}>>;
|
|
@@ -0,0 +1,327 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* EDU Assignments API client.
|
|
3
|
+
*
|
|
4
|
+
* Talks to the Teams Assignments service at `assignments.edu.cloud.microsoft`
|
|
5
|
+
* (backed by OneNote EDU). This is the API behind the Teams "Assignments" tab —
|
|
6
|
+
* not Microsoft Graph, though the data model mirrors Graph's `education` types.
|
|
7
|
+
*
|
|
8
|
+
* Auth: a Bearer token for the Assignments app (audience `8f348934-...`), obtained
|
|
9
|
+
* via `requireAssignmentsTokenAsync()`. That token is minted by the HTTP token
|
|
10
|
+
* refresh (the `EduAssignments` entry in `token-refresh-http.ts`).
|
|
11
|
+
*
|
|
12
|
+
* Read endpoints (my work, assignment detail, submissions) and the submit /
|
|
13
|
+
* unsubmit actions are verified live against an education tenant; the "mark
|
|
14
|
+
* viewed" PATCH against a captured web session.
|
|
15
|
+
*/
|
|
16
|
+
import { httpRequest } from '../utils/http.js';
|
|
17
|
+
import { ok, err } from '../types/result.js';
|
|
18
|
+
import { requireAssignmentsTokenAsync } from '../utils/auth-guards.js';
|
|
19
|
+
import { ASSIGNMENTS_API, getAssignmentsHeaders } from '../utils/api-config.js';
|
|
20
|
+
import { ErrorCode, createError } from '../types/errors.js';
|
|
21
|
+
import { invalidateAccessToken } from '../auth/token-extractor.js';
|
|
22
|
+
import { DEFAULT_PAGE_SIZE, MAX_PAGE_SIZE } from '../constants.js';
|
|
23
|
+
/** IDs are opaque single path segments; reject dot segments and URL delimiters. */
|
|
24
|
+
export const ASSIGNMENT_ID_PATTERN = /^[a-zA-Z0-9_-]+$/;
|
|
25
|
+
function validIds(...ids) {
|
|
26
|
+
return ids.every(id => ASSIGNMENT_ID_PATTERN.test(id));
|
|
27
|
+
}
|
|
28
|
+
function invalidInput(message) {
|
|
29
|
+
return err(createError(ErrorCode.INVALID_INPUT, message));
|
|
30
|
+
}
|
|
31
|
+
function handleAssignmentsError(response, token) {
|
|
32
|
+
// Never surface AUTH_EXPIRED: it would make the server re-authenticate the
|
|
33
|
+
// whole Teams session (and replay the tool) for this optional feature.
|
|
34
|
+
if (!response.ok && response.error.code === ErrorCode.AUTH_EXPIRED) {
|
|
35
|
+
invalidateAccessToken(token);
|
|
36
|
+
return err(createError(ErrorCode.API_ERROR, 'Assignments rejected its access token; it has been discarded, so a retry will request a new one.', {
|
|
37
|
+
retryable: true,
|
|
38
|
+
suggestions: ['Retry once; if it fails again, Assignments may be unavailable for this account'],
|
|
39
|
+
}));
|
|
40
|
+
}
|
|
41
|
+
if (!response.ok && response.error.code === ErrorCode.AUTH_REQUIRED) {
|
|
42
|
+
return err(createError(ErrorCode.ACCESS_DENIED, response.error.message, {
|
|
43
|
+
retryable: false,
|
|
44
|
+
suggestions: ['Check your permission to access this assignment'],
|
|
45
|
+
}));
|
|
46
|
+
}
|
|
47
|
+
return response;
|
|
48
|
+
}
|
|
49
|
+
/** Never send the bearer token to a host or endpoint supplied by a continuation link. */
|
|
50
|
+
function validateNextLink(link, base) {
|
|
51
|
+
try {
|
|
52
|
+
const url = new URL(link, base);
|
|
53
|
+
const allowed = new URL(ASSIGNMENTS_API.myWork());
|
|
54
|
+
if (url.origin !== allowed.origin || url.pathname !== allowed.pathname || url.username || url.password || url.hash)
|
|
55
|
+
return null;
|
|
56
|
+
const top = Number(url.searchParams.get('$top') ?? DEFAULT_PAGE_SIZE);
|
|
57
|
+
const skip = Number(url.searchParams.get('$skip') ?? 0);
|
|
58
|
+
if (!Number.isInteger(top) || top < 1 || top > MAX_PAGE_SIZE || !Number.isSafeInteger(skip) || skip < 0)
|
|
59
|
+
return null;
|
|
60
|
+
return url.toString();
|
|
61
|
+
}
|
|
62
|
+
catch {
|
|
63
|
+
return null;
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
67
|
+
// Parsing
|
|
68
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
69
|
+
function feedbackText(fb) {
|
|
70
|
+
if (!fb?.text)
|
|
71
|
+
return undefined;
|
|
72
|
+
return typeof fb.text === 'string' ? fb.text : fb.text.content;
|
|
73
|
+
}
|
|
74
|
+
/** Merges a submission's outcome array into a single grade/feedback object. */
|
|
75
|
+
function parseOutcomes(outcomes) {
|
|
76
|
+
if (!outcomes?.length)
|
|
77
|
+
return undefined;
|
|
78
|
+
const merged = {};
|
|
79
|
+
for (const o of outcomes) {
|
|
80
|
+
if (o.points?.points != null)
|
|
81
|
+
merged.points = o.points.points;
|
|
82
|
+
if (o.publishedPoints?.points != null)
|
|
83
|
+
merged.publishedPoints = o.publishedPoints.points;
|
|
84
|
+
const fb = feedbackText(o.feedback);
|
|
85
|
+
if (fb)
|
|
86
|
+
merged.feedback = fb;
|
|
87
|
+
const pfb = feedbackText(o.publishedFeedback);
|
|
88
|
+
if (pfb)
|
|
89
|
+
merged.publishedFeedback = pfb;
|
|
90
|
+
}
|
|
91
|
+
return Object.keys(merged).length > 0 ? merged : undefined;
|
|
92
|
+
}
|
|
93
|
+
function parseAttachments(resources) {
|
|
94
|
+
if (!resources)
|
|
95
|
+
return undefined;
|
|
96
|
+
return resources.map(({ id, distributeForStudentWork, resource }) => ({
|
|
97
|
+
id,
|
|
98
|
+
name: resource?.displayName,
|
|
99
|
+
// '#microsoft.education.assignments.api.educationPowerPointResource' -> 'powerpoint'
|
|
100
|
+
type: (resource?.['@odata.type'] ?? '').replace(/^.*\.education/, '').replace(/Resource$/, '').toLowerCase() || 'unknown',
|
|
101
|
+
fileUrl: resource?.fileUrl,
|
|
102
|
+
url: resource?.link ?? resource?.viewUrl,
|
|
103
|
+
copiedForEachStudent: distributeForStudentWork || undefined,
|
|
104
|
+
}));
|
|
105
|
+
}
|
|
106
|
+
function parseSubmission(sub) {
|
|
107
|
+
if (!sub)
|
|
108
|
+
return undefined;
|
|
109
|
+
return {
|
|
110
|
+
id: sub.id,
|
|
111
|
+
status: sub.status,
|
|
112
|
+
submittedDateTime: sub.submittedDateTime,
|
|
113
|
+
returnedDateTime: sub.returnedDateTime,
|
|
114
|
+
outcome: parseOutcomes(sub.outcomes),
|
|
115
|
+
attachments: parseAttachments(sub.resources),
|
|
116
|
+
submittedAttachments: parseAttachments(sub.submittedResources),
|
|
117
|
+
};
|
|
118
|
+
}
|
|
119
|
+
function parseAssignment(raw) {
|
|
120
|
+
return {
|
|
121
|
+
id: raw.id,
|
|
122
|
+
classId: raw.classId,
|
|
123
|
+
displayName: raw.displayName,
|
|
124
|
+
status: raw.status,
|
|
125
|
+
dueDateTime: raw.dueDateTime ?? undefined,
|
|
126
|
+
assignedDateTime: raw.assignedDateTime ?? undefined,
|
|
127
|
+
closeDateTime: raw.closeDateTime ?? undefined,
|
|
128
|
+
allowLateSubmissions: raw.allowLateSubmissions,
|
|
129
|
+
maxPoints: raw.grading?.maxPoints ?? undefined,
|
|
130
|
+
instructions: raw.instructions?.content ?? undefined,
|
|
131
|
+
webUrl: raw.webUrl ?? undefined,
|
|
132
|
+
submission: parseSubmission(raw.submissions?.[0]),
|
|
133
|
+
attachments: parseAttachments(raw.resources),
|
|
134
|
+
};
|
|
135
|
+
}
|
|
136
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
137
|
+
// $filter builders
|
|
138
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
139
|
+
const STATUS_PREFIX = "microsoft.education.assignments.api.educationAssignmentStatus";
|
|
140
|
+
function buildStatusFilter(filter) {
|
|
141
|
+
switch (filter) {
|
|
142
|
+
case 'active':
|
|
143
|
+
return `status eq ${STATUS_PREFIX}'assigned' and isCompleted eq false`;
|
|
144
|
+
case 'completed':
|
|
145
|
+
return `isCompleted eq true`;
|
|
146
|
+
case 'all':
|
|
147
|
+
return null;
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
/** Recover the slice a continuation URL actually queries, whatever the caller passed. */
|
|
151
|
+
function statusFilterFromUrl(url) {
|
|
152
|
+
const filter = new URL(url).searchParams.get('$filter');
|
|
153
|
+
return ['active', 'completed', 'all'].find(s => buildStatusFilter(s) === filter);
|
|
154
|
+
}
|
|
155
|
+
/**
|
|
156
|
+
* Offset links we synthesised, keyed to the IDs of the page that produced them.
|
|
157
|
+
* If following one returns that same page, the service ignored $skip and
|
|
158
|
+
* offering another offset link would loop forever.
|
|
159
|
+
*/
|
|
160
|
+
const syntheticPages = new Map();
|
|
161
|
+
const MAX_SYNTHETIC_PAGES = 50;
|
|
162
|
+
function rememberSyntheticPage(link, pageIds) {
|
|
163
|
+
if (syntheticPages.size >= MAX_SYNTHETIC_PAGES) {
|
|
164
|
+
syntheticPages.delete(syntheticPages.keys().next().value);
|
|
165
|
+
}
|
|
166
|
+
syntheticPages.set(link, pageIds);
|
|
167
|
+
}
|
|
168
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
169
|
+
// API functions
|
|
170
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
171
|
+
/**
|
|
172
|
+
* Lists the signed-in user's assignments across all their classes.
|
|
173
|
+
*
|
|
174
|
+
* Uses the `/edu/me/work` endpoint with the caller's submission (and its grade
|
|
175
|
+
* outcomes) expanded inline, so a single call yields due dates, status, points,
|
|
176
|
+
* and the user's own submission state.
|
|
177
|
+
*
|
|
178
|
+
* @param options.statusFilter - active (assigned, not completed) | completed | all. Default active.
|
|
179
|
+
* @param options.top - max assignments to return (default 25).
|
|
180
|
+
*/
|
|
181
|
+
export async function listMyAssignments(options = {}) {
|
|
182
|
+
let statusFilter = options.statusFilter ?? 'active';
|
|
183
|
+
const top = options.top ?? DEFAULT_PAGE_SIZE;
|
|
184
|
+
if (!Number.isInteger(top) || top < 1 || top > MAX_PAGE_SIZE) {
|
|
185
|
+
return invalidInput(`top must be an integer from 1 to ${MAX_PAGE_SIZE}`);
|
|
186
|
+
}
|
|
187
|
+
const params = new URLSearchParams();
|
|
188
|
+
const filter = buildStatusFilter(statusFilter);
|
|
189
|
+
if (filter)
|
|
190
|
+
params.set('$filter', filter);
|
|
191
|
+
params.set('$top', String(top));
|
|
192
|
+
params.set('$orderby', statusFilter === 'active' ? 'dueDateTime asc,createdDateTime asc' : 'dueDateTime desc,createdDateTime asc');
|
|
193
|
+
params.set('$expand', 'submissions($expand=outcomes)');
|
|
194
|
+
const url = options.nextLink ? validateNextLink(options.nextLink) : `${ASSIGNMENTS_API.myWork()}?${params.toString()}`;
|
|
195
|
+
if (!url)
|
|
196
|
+
return invalidInput('nextLink must be a continuation URL for the Assignments work endpoint');
|
|
197
|
+
if (options.nextLink)
|
|
198
|
+
statusFilter = statusFilterFromUrl(url) ?? statusFilter;
|
|
199
|
+
const tokenResult = await requireAssignmentsTokenAsync();
|
|
200
|
+
if (!tokenResult.ok)
|
|
201
|
+
return tokenResult;
|
|
202
|
+
const response = await httpRequest(url, {
|
|
203
|
+
method: 'GET',
|
|
204
|
+
headers: getAssignmentsHeaders(tokenResult.value),
|
|
205
|
+
});
|
|
206
|
+
if (!response.ok)
|
|
207
|
+
return handleAssignmentsError(response, tokenResult.value);
|
|
208
|
+
const data = response.value.data;
|
|
209
|
+
if (!data || !Array.isArray(data.value)) {
|
|
210
|
+
return err(createError(ErrorCode.API_ERROR, 'Invalid Assignments list response', { retryable: false }));
|
|
211
|
+
}
|
|
212
|
+
const items = data.value;
|
|
213
|
+
const pageIds = items.map(item => item.id).join(',');
|
|
214
|
+
const previousPageIds = syntheticPages.get(url);
|
|
215
|
+
syntheticPages.delete(url);
|
|
216
|
+
if (previousPageIds !== undefined && previousPageIds === pageIds) {
|
|
217
|
+
return err(createError(ErrorCode.API_ERROR, 'The Assignments service ignored the page offset and returned the previous page again; no further pages can be fetched.', { retryable: false, suggestions: ['Use a narrower statusFilter or a larger top to see more assignments'] }));
|
|
218
|
+
}
|
|
219
|
+
let nextLink = data['@odata.nextLink'];
|
|
220
|
+
if (nextLink) {
|
|
221
|
+
nextLink = validateNextLink(nextLink, url) ?? undefined;
|
|
222
|
+
if (!nextLink)
|
|
223
|
+
return err(createError(ErrorCode.API_ERROR, 'Invalid Assignments continuation URL', { retryable: false }));
|
|
224
|
+
}
|
|
225
|
+
else {
|
|
226
|
+
// Some responses omit nextLink when $top was used. Expose an offset page
|
|
227
|
+
// rather than silently cutting off all older work at the tool's size cap.
|
|
228
|
+
const next = new URL(url);
|
|
229
|
+
const pageSize = Number(next.searchParams.get('$top') ?? top);
|
|
230
|
+
if (!next.searchParams.has('$skiptoken') && items.length > 0 && items.length >= pageSize) {
|
|
231
|
+
next.searchParams.set('$skip', String(Number(next.searchParams.get('$skip') ?? 0) + items.length));
|
|
232
|
+
nextLink = next.toString();
|
|
233
|
+
rememberSyntheticPage(nextLink, pageIds);
|
|
234
|
+
}
|
|
235
|
+
}
|
|
236
|
+
return ok({ statusFilter, returned: items.length, assignments: items.map(parseAssignment), nextLink });
|
|
237
|
+
}
|
|
238
|
+
/**
|
|
239
|
+
* Gets a single assignment's full detail.
|
|
240
|
+
*
|
|
241
|
+
* @param classId - the class (group) ID the assignment belongs to.
|
|
242
|
+
* @param assignmentId - the assignment ID.
|
|
243
|
+
*/
|
|
244
|
+
export async function getAssignment(classId, assignmentId) {
|
|
245
|
+
if (!validIds(classId, assignmentId))
|
|
246
|
+
return invalidInput('Invalid classId or assignmentId');
|
|
247
|
+
const tokenResult = await requireAssignmentsTokenAsync();
|
|
248
|
+
if (!tokenResult.ok)
|
|
249
|
+
return tokenResult;
|
|
250
|
+
// Expanding submissions on the assignment itself returns empty outcomes, so
|
|
251
|
+
// grades need the submissions collection, as the Teams client requests it.
|
|
252
|
+
const headers = getAssignmentsHeaders(tokenResult.value);
|
|
253
|
+
const submissionsParams = new URLSearchParams({ '$expand': 'outcomes,resources,submittedResources' });
|
|
254
|
+
const assignmentParams = new URLSearchParams({ '$expand': 'resources' });
|
|
255
|
+
const [response, submissions] = await Promise.all([
|
|
256
|
+
httpRequest(`${ASSIGNMENTS_API.assignment(classId, assignmentId)}?${assignmentParams.toString()}`, { method: 'GET', headers }),
|
|
257
|
+
httpRequest(`${ASSIGNMENTS_API.submissions(classId, assignmentId)}?${submissionsParams.toString()}`, { method: 'GET', headers }),
|
|
258
|
+
]);
|
|
259
|
+
if (!response.ok)
|
|
260
|
+
return handleAssignmentsError(response, tokenResult.value);
|
|
261
|
+
if (!submissions.ok)
|
|
262
|
+
return handleAssignmentsError(submissions, tokenResult.value);
|
|
263
|
+
if (!response.value.data || typeof response.value.data.id !== 'string' || !Array.isArray(submissions.value.data?.value)) {
|
|
264
|
+
return err(createError(ErrorCode.API_ERROR, 'Invalid assignment detail response', { retryable: false }));
|
|
265
|
+
}
|
|
266
|
+
return ok(parseAssignment({ ...response.value.data, submissions: submissions.value.data.value }));
|
|
267
|
+
}
|
|
268
|
+
/**
|
|
269
|
+
* Performs an action on the caller's own submission: turn in (submit), undo
|
|
270
|
+
* turn-in (unsubmit), or mark viewed. These change state on the user's real
|
|
271
|
+
* Teams account, so callers should confirm intent before invoking.
|
|
272
|
+
*
|
|
273
|
+
* submit/unsubmit (POST, no body) are verified live; `view` (PATCH) against a
|
|
274
|
+
* captured session.
|
|
275
|
+
*/
|
|
276
|
+
export async function actOnSubmission(action, classId, assignmentId, submissionId) {
|
|
277
|
+
if (!validIds(classId, assignmentId, submissionId))
|
|
278
|
+
return invalidInput('Invalid assignment or submission ID');
|
|
279
|
+
const tokenResult = await requireAssignmentsTokenAsync();
|
|
280
|
+
if (!tokenResult.ok)
|
|
281
|
+
return tokenResult;
|
|
282
|
+
let url;
|
|
283
|
+
let method;
|
|
284
|
+
switch (action) {
|
|
285
|
+
case 'submit':
|
|
286
|
+
url = ASSIGNMENTS_API.submissionSubmit(classId, assignmentId, submissionId);
|
|
287
|
+
method = 'POST';
|
|
288
|
+
break;
|
|
289
|
+
case 'unsubmit':
|
|
290
|
+
url = ASSIGNMENTS_API.submissionUnsubmit(classId, assignmentId, submissionId);
|
|
291
|
+
method = 'POST';
|
|
292
|
+
break;
|
|
293
|
+
case 'view':
|
|
294
|
+
url = ASSIGNMENTS_API.submissionView(classId, assignmentId, submissionId);
|
|
295
|
+
method = 'PATCH';
|
|
296
|
+
break;
|
|
297
|
+
}
|
|
298
|
+
const response = await httpRequest(url, {
|
|
299
|
+
method,
|
|
300
|
+
headers: getAssignmentsHeaders(tokenResult.value),
|
|
301
|
+
// Do not replay a mutation after a timeout or server error.
|
|
302
|
+
maxRetries: 1,
|
|
303
|
+
// Graph parity specifies no request body; do not guess a private API payload.
|
|
304
|
+
});
|
|
305
|
+
if (!response.ok) {
|
|
306
|
+
if (response.error.retryable && response.error.code !== ErrorCode.RATE_LIMITED) {
|
|
307
|
+
return err({ ...response.error, retryable: false,
|
|
308
|
+
suggestions: ['The action may have completed. Read the submission state before repeating it'] });
|
|
309
|
+
}
|
|
310
|
+
return handleAssignmentsError(response, tokenResult.value);
|
|
311
|
+
}
|
|
312
|
+
let status = response.value.data?.status;
|
|
313
|
+
if (action !== 'view') {
|
|
314
|
+
const verification = await httpRequest(ASSIGNMENTS_API.submission(classId, assignmentId, submissionId), { method: 'GET', headers: getAssignmentsHeaders(tokenResult.value) });
|
|
315
|
+
if (!verification.ok)
|
|
316
|
+
handleAssignmentsError(verification, tokenResult.value);
|
|
317
|
+
status = verification.ok ? verification.value.data?.status : undefined;
|
|
318
|
+
const expected = action === 'submit' ? 'submitted' : 'working';
|
|
319
|
+
if (!verification.ok || verification.value.data?.id !== submissionId || status !== expected) {
|
|
320
|
+
return err(createError(ErrorCode.API_ERROR, `The ${action} request was accepted, but submission state could not be confirmed as ${expected}. Check the submission before repeating the action.`, { retryable: false, suggestions: ['Read the assignment to check the current submission state; do not automatically repeat the action'] }));
|
|
321
|
+
}
|
|
322
|
+
}
|
|
323
|
+
else if (response.value.status !== 204 && (typeof status !== 'string' || response.value.data?.id !== submissionId)) {
|
|
324
|
+
return err(createError(ErrorCode.API_ERROR, 'The view request returned an unexpected response; its result is unconfirmed.', { retryable: false }));
|
|
325
|
+
}
|
|
326
|
+
return ok({ action, submissionId, status });
|
|
327
|
+
}
|