msteams-mcp 0.29.0 → 0.29.2
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 +2 -2
- 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/chatsvc-messaging.d.ts +15 -0
- package/dist/api/chatsvc-messaging.js +32 -6
- package/dist/api/chatsvc-messaging.test.js +24 -1
- 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/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/session-store.d.ts +9 -6
- package/dist/auth/session-store.js +16 -97
- 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 +4 -25
- package/dist/browser/auth.js +28 -95
- package/dist/browser/auth.test.d.ts +1 -0
- package/dist/browser/auth.test.js +45 -0
- 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 +20 -12
- package/dist/constants.js +26 -15
- package/dist/server.d.ts +1 -53
- package/dist/server.js +10 -167
- 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 +64 -76
- package/dist/tools/auth-tools.test.d.ts +1 -0
- package/dist/tools/auth-tools.test.js +67 -0
- 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.d.ts +10 -0
- package/dist/tools/search-tools.js +12 -5
- 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 +3 -2
package/README.md
CHANGED
|
@@ -4,7 +4,7 @@
|
|
|
4
4
|
[](https://www.npmjs.com/package/msteams-mcp)
|
|
5
5
|
[](https://www.npmjs.com/package/msteams-mcp)
|
|
6
6
|
[](https://opensource.org/licenses/MIT)
|
|
7
|
-
[](https://nodejs.org/)
|
|
8
8
|
|
|
9
9
|
Give AI access to Microsoft Teams easily with your standard Teams login.
|
|
10
10
|
|
|
@@ -27,7 +27,7 @@ Search messages and email, catch up on unread chats, read threads, send replies,
|
|
|
27
27
|
|
|
28
28
|
### Prerequisites
|
|
29
29
|
|
|
30
|
-
- Node.js
|
|
30
|
+
- Node.js 20+
|
|
31
31
|
- A Microsoft account with Teams access
|
|
32
32
|
- Google Chrome, Microsoft Edge, or Chromium browser installed
|
|
33
33
|
|
|
@@ -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
|
+
}
|