brightspace-d2l-mcp 1.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +24 -0
- package/README.md +269 -0
- package/build/api/cache.js +49 -0
- package/build/api/client.js +345 -0
- package/build/api/errors.js +48 -0
- package/build/api/index.js +12 -0
- package/build/api/paginate.js +70 -0
- package/build/api/rate-limiter.js +52 -0
- package/build/api/retry.js +39 -0
- package/build/api/types.js +11 -0
- package/build/api/version-discovery.js +43 -0
- package/build/auth/authenticator.js +146 -0
- package/build/auth/browser.js +82 -0
- package/build/auth/index.js +6 -0
- package/build/auth/login.js +227 -0
- package/build/auth/session-store.js +46 -0
- package/build/auth/token-manager.js +150 -0
- package/build/auth/token-mint.js +75 -0
- package/build/cli/clients.js +344 -0
- package/build/cli/main.js +162 -0
- package/build/cli/school.js +74 -0
- package/build/cli/setup.js +189 -0
- package/build/cli/ui.js +39 -0
- package/build/index.js +13 -0
- package/build/server.js +175 -0
- package/build/tools/download-file.js +167 -0
- package/build/tools/get-announcements.js +116 -0
- package/build/tools/get-assignment-files.js +159 -0
- package/build/tools/get-assignments.js +343 -0
- package/build/tools/get-classlist-emails.js +38 -0
- package/build/tools/get-course-content.js +175 -0
- package/build/tools/get-discussions.js +192 -0
- package/build/tools/get-my-courses.js +42 -0
- package/build/tools/get-my-grades.js +93 -0
- package/build/tools/get-roster.js +99 -0
- package/build/tools/get-syllabus.js +154 -0
- package/build/tools/get-todo.js +61 -0
- package/build/tools/get-upcoming-due-dates.js +138 -0
- package/build/tools/index.js +18 -0
- package/build/tools/schemas.js +91 -0
- package/build/tools/submission-status.js +71 -0
- package/build/tools/tool-helpers.js +62 -0
- package/build/types/index.js +2 -0
- package/build/utils/atomic-write.js +66 -0
- package/build/utils/config.js +60 -0
- package/build/utils/course-filter.js +24 -0
- package/build/utils/deep-links.js +13 -0
- package/build/utils/download-errors.js +14 -0
- package/build/utils/download-helpers.js +73 -0
- package/build/utils/errors.js +31 -0
- package/build/utils/file-validator.js +121 -0
- package/build/utils/html-converter.js +20 -0
- package/build/utils/logger.js +69 -0
- package/build/utils/paths.js +15 -0
- package/build/utils/pdf-extractor.js +18 -0
- package/build/utils/version.js +18 -0
- package/build/utils/zip-extract.js +139 -0
- package/package.json +104 -0
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
import { DEFAULT_CACHE_TTLS } from "../api/index.js";
|
|
2
|
+
import { GetAnnouncementsSchema, } from "./schemas.js";
|
|
3
|
+
import { toolResponse, sanitizeError } from "./tool-helpers.js";
|
|
4
|
+
import { log } from "../utils/logger.js";
|
|
5
|
+
import { applyCourseFilter } from "../utils/course-filter.js";
|
|
6
|
+
function readableDate(raw) {
|
|
7
|
+
if (typeof raw !== "string")
|
|
8
|
+
return null;
|
|
9
|
+
return Number.isNaN(new Date(raw).getTime()) ? null : raw;
|
|
10
|
+
}
|
|
11
|
+
export function effectiveDate(item) {
|
|
12
|
+
return readableDate(item.StartDate) ?? readableDate(item.CreatedDate);
|
|
13
|
+
}
|
|
14
|
+
export function isPublishedNewsItem(item) {
|
|
15
|
+
return item.IsPublished !== false;
|
|
16
|
+
}
|
|
17
|
+
export function newestFirst(a, b) {
|
|
18
|
+
if (a.date === b.date)
|
|
19
|
+
return 0;
|
|
20
|
+
if (a.date === null)
|
|
21
|
+
return 1;
|
|
22
|
+
if (b.date === null)
|
|
23
|
+
return -1;
|
|
24
|
+
return new Date(b.date).getTime() - new Date(a.date).getTime();
|
|
25
|
+
}
|
|
26
|
+
export function mapNewsItem(item) {
|
|
27
|
+
return {
|
|
28
|
+
id: item.Id,
|
|
29
|
+
title: item.Title,
|
|
30
|
+
body: item.Body?.Text ?? "",
|
|
31
|
+
createdBy: item.CreatedBy?.DisplayName ?? "Unknown",
|
|
32
|
+
createdDate: item.CreatedDate,
|
|
33
|
+
startDate: item.StartDate,
|
|
34
|
+
date: effectiveDate(item),
|
|
35
|
+
isPinned: item.IsPinned,
|
|
36
|
+
};
|
|
37
|
+
}
|
|
38
|
+
export function registerGetAnnouncements(server, apiClient, config) {
|
|
39
|
+
server.registerTool("get_announcements", {
|
|
40
|
+
title: "Get Announcements",
|
|
41
|
+
description: "Fetch recent announcements from your courses. Can filter to a specific course or get announcements across all courses. Use this when the user asks about announcements, news, updates from instructors, recent posts, or what professors said.",
|
|
42
|
+
inputSchema: GetAnnouncementsSchema,
|
|
43
|
+
}, async (args) => {
|
|
44
|
+
try {
|
|
45
|
+
log("DEBUG", "get_announcements tool called", { args });
|
|
46
|
+
// Parse and validate input
|
|
47
|
+
const { courseId, count } = GetAnnouncementsSchema.parse(args);
|
|
48
|
+
// Single course case
|
|
49
|
+
if (courseId) {
|
|
50
|
+
const path = apiClient.le(courseId, "/news/");
|
|
51
|
+
const newsItems = await apiClient.get(path, {
|
|
52
|
+
ttl: DEFAULT_CACHE_TTLS.announcements,
|
|
53
|
+
});
|
|
54
|
+
// Drop drafts, then map to clean objects
|
|
55
|
+
const announcements = newsItems
|
|
56
|
+
.filter(isPublishedNewsItem)
|
|
57
|
+
.map(mapNewsItem)
|
|
58
|
+
.sort(newestFirst)
|
|
59
|
+
.slice(0, count);
|
|
60
|
+
log("INFO", `get_announcements: Retrieved ${announcements.length} announcements for course ${courseId}`);
|
|
61
|
+
return toolResponse(announcements);
|
|
62
|
+
}
|
|
63
|
+
// All courses case
|
|
64
|
+
// First, fetch enrolled courses
|
|
65
|
+
const enrollmentPath = apiClient.lp("/enrollments/myenrollments/?orgUnitTypeId=3&isActive=true");
|
|
66
|
+
const enrollmentResponse = await apiClient.get(enrollmentPath, { ttl: DEFAULT_CACHE_TTLS.enrollments });
|
|
67
|
+
// Apply course filter
|
|
68
|
+
const filteredEnrollments = applyCourseFilter(enrollmentResponse.Items.map(item => ({
|
|
69
|
+
id: item.OrgUnit.Id,
|
|
70
|
+
name: item.OrgUnit.Name,
|
|
71
|
+
code: item.OrgUnit.Code,
|
|
72
|
+
isActive: item.Access.IsActive,
|
|
73
|
+
canAccess: item.Access.CanAccess,
|
|
74
|
+
...item,
|
|
75
|
+
})), config.courseFilter);
|
|
76
|
+
// Fetch announcements for each course (handle 403s gracefully)
|
|
77
|
+
const announcementPromises = filteredEnrollments.map(async (item) => {
|
|
78
|
+
try {
|
|
79
|
+
const path = apiClient.le(item.OrgUnit.Id, "/news/");
|
|
80
|
+
const newsItems = await apiClient.get(path, {
|
|
81
|
+
ttl: DEFAULT_CACHE_TTLS.announcements,
|
|
82
|
+
});
|
|
83
|
+
return newsItems
|
|
84
|
+
.filter(isPublishedNewsItem)
|
|
85
|
+
.map((newsItem) => ({
|
|
86
|
+
...mapNewsItem(newsItem),
|
|
87
|
+
courseId: item.OrgUnit.Id,
|
|
88
|
+
courseName: item.OrgUnit.Name,
|
|
89
|
+
}));
|
|
90
|
+
}
|
|
91
|
+
catch (error) {
|
|
92
|
+
// 403 means no access (past course, etc) - log and skip
|
|
93
|
+
if (error?.status === 403) {
|
|
94
|
+
log("DEBUG", `get_announcements: 403 Forbidden for course ${item.OrgUnit.Id} (${item.OrgUnit.Name}) - skipping`);
|
|
95
|
+
return [];
|
|
96
|
+
}
|
|
97
|
+
throw error; // Re-throw other errors
|
|
98
|
+
}
|
|
99
|
+
});
|
|
100
|
+
const results = await Promise.allSettled(announcementPromises);
|
|
101
|
+
const allAnnouncements = results
|
|
102
|
+
.filter((r) => r.status === "fulfilled")
|
|
103
|
+
.flatMap((r) => r.value);
|
|
104
|
+
// Sort by the scheduled date and slice to count
|
|
105
|
+
const announcements = allAnnouncements
|
|
106
|
+
.sort(newestFirst)
|
|
107
|
+
.slice(0, count);
|
|
108
|
+
log("INFO", `get_announcements: Retrieved ${announcements.length} announcements (out of ${allAnnouncements.length} total across ${enrollmentResponse.Items.length} courses)`);
|
|
109
|
+
return toolResponse(announcements);
|
|
110
|
+
}
|
|
111
|
+
catch (error) {
|
|
112
|
+
return sanitizeError(error);
|
|
113
|
+
}
|
|
114
|
+
});
|
|
115
|
+
}
|
|
116
|
+
//# sourceMappingURL=get-announcements.js.map
|
|
@@ -0,0 +1,159 @@
|
|
|
1
|
+
import { DEFAULT_CACHE_TTLS } from "../api/index.js";
|
|
2
|
+
import { GetAssignmentFilesSchema } from "./schemas.js";
|
|
3
|
+
import { toolResponse, sanitizeError } from "./tool-helpers.js";
|
|
4
|
+
import { extractPdfText } from "../utils/pdf-extractor.js";
|
|
5
|
+
import { officeDocumentText } from "../utils/zip-extract.js";
|
|
6
|
+
import { assignmentUrl } from "../utils/deep-links.js";
|
|
7
|
+
import { log } from "../utils/logger.js";
|
|
8
|
+
function unwrapList(raw) {
|
|
9
|
+
return Array.isArray(raw) ? raw : (raw?.Objects ?? []);
|
|
10
|
+
}
|
|
11
|
+
const KIND_BY_EXTENSION = {
|
|
12
|
+
pdf: "pdf",
|
|
13
|
+
docx: "docx",
|
|
14
|
+
doc: "other",
|
|
15
|
+
xlsx: "xlsx",
|
|
16
|
+
xls: "other",
|
|
17
|
+
pptx: "pptx",
|
|
18
|
+
ppt: "other",
|
|
19
|
+
png: "image",
|
|
20
|
+
jpg: "image",
|
|
21
|
+
jpeg: "image",
|
|
22
|
+
gif: "image",
|
|
23
|
+
webp: "image",
|
|
24
|
+
txt: "text",
|
|
25
|
+
md: "text",
|
|
26
|
+
csv: "text",
|
|
27
|
+
json: "text",
|
|
28
|
+
};
|
|
29
|
+
export function fileKind(fileName) {
|
|
30
|
+
const extension = fileName.split(".").pop()?.toLowerCase() ?? "";
|
|
31
|
+
return KIND_BY_EXTENSION[extension] ?? "other";
|
|
32
|
+
}
|
|
33
|
+
function describeAttachment(attachment) {
|
|
34
|
+
return {
|
|
35
|
+
fileId: attachment.FileId,
|
|
36
|
+
fileName: attachment.FileName,
|
|
37
|
+
size: attachment.Size,
|
|
38
|
+
kind: fileKind(attachment.FileName),
|
|
39
|
+
};
|
|
40
|
+
}
|
|
41
|
+
async function listFolders(apiClient, courseId, folderId) {
|
|
42
|
+
const raw = await apiClient.get(apiClient.le(courseId, "/dropbox/folders/"), {
|
|
43
|
+
ttl: DEFAULT_CACHE_TTLS.assignments,
|
|
44
|
+
});
|
|
45
|
+
return unwrapList(raw)
|
|
46
|
+
.filter((folder) => folder.IsHidden !== true)
|
|
47
|
+
.filter((folder) => (folderId === undefined ? true : folder.Id === folderId));
|
|
48
|
+
}
|
|
49
|
+
async function readAttachment(apiClient, courseId, folderId, attachment, extract, maxChars) {
|
|
50
|
+
const base = describeAttachment(attachment);
|
|
51
|
+
if (!extract)
|
|
52
|
+
return { ...base, text: null, note: "Text extraction was not requested." };
|
|
53
|
+
const response = await apiClient.getRaw(apiClient.le(courseId, `/dropbox/folders/${folderId}/attachments/${attachment.FileId}`));
|
|
54
|
+
const buffer = Buffer.from(await response.arrayBuffer());
|
|
55
|
+
let text = null;
|
|
56
|
+
let note;
|
|
57
|
+
switch (base.kind) {
|
|
58
|
+
case "pdf": {
|
|
59
|
+
const extracted = await extractPdfText(buffer);
|
|
60
|
+
text = extracted?.text?.trim() || null;
|
|
61
|
+
if (!text)
|
|
62
|
+
note = "No text layer in this PDF. It may be a scan.";
|
|
63
|
+
break;
|
|
64
|
+
}
|
|
65
|
+
case "docx":
|
|
66
|
+
case "xlsx":
|
|
67
|
+
case "pptx": {
|
|
68
|
+
text = officeDocumentText(buffer);
|
|
69
|
+
if (!text)
|
|
70
|
+
note = "No readable text found in this Office document.";
|
|
71
|
+
break;
|
|
72
|
+
}
|
|
73
|
+
case "text": {
|
|
74
|
+
text = buffer.toString("utf-8").trim() || null;
|
|
75
|
+
break;
|
|
76
|
+
}
|
|
77
|
+
default: {
|
|
78
|
+
note = `Cannot extract text from a ${base.kind} file. Use download_file to save it.`;
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
const truncated = text !== null && text.length > maxChars;
|
|
82
|
+
return {
|
|
83
|
+
...base,
|
|
84
|
+
bytes: buffer.length,
|
|
85
|
+
text: truncated ? text.slice(0, maxChars) : text,
|
|
86
|
+
truncated,
|
|
87
|
+
...(note ? { note } : {}),
|
|
88
|
+
};
|
|
89
|
+
}
|
|
90
|
+
export function registerGetAssignmentFiles(server, apiClient, baseUrl) {
|
|
91
|
+
server.registerTool("get_assignment_files", {
|
|
92
|
+
title: "Get Assignment Files",
|
|
93
|
+
description: "Read the files an instructor attached to an assignment: the spec or instructions PDF, a starter workbook, a rubric document. Call it with just courseId to see which assignments have attachments, then with folderId and fileId to read one. Use this when the user asks what an assignment requires, what the instructions say, or to summarize a handout. Returns the text itself. Use download_file instead when the user wants the file saved to disk.",
|
|
94
|
+
inputSchema: GetAssignmentFilesSchema,
|
|
95
|
+
}, async (args) => {
|
|
96
|
+
try {
|
|
97
|
+
log("DEBUG", "get_assignment_files tool called", { args });
|
|
98
|
+
const { courseId, folderId, fileId, extractText, maxChars } = GetAssignmentFilesSchema.parse(args);
|
|
99
|
+
const folders = await listFolders(apiClient, courseId, folderId);
|
|
100
|
+
if (folderId !== undefined && folders.length === 0) {
|
|
101
|
+
return toolResponse({
|
|
102
|
+
courseId,
|
|
103
|
+
folderId,
|
|
104
|
+
error: `No visible assignment with id ${folderId} in course ${courseId}.`,
|
|
105
|
+
});
|
|
106
|
+
}
|
|
107
|
+
// Read one file.
|
|
108
|
+
if (fileId !== undefined) {
|
|
109
|
+
if (folderId === undefined) {
|
|
110
|
+
return toolResponse({
|
|
111
|
+
courseId,
|
|
112
|
+
error: "folderId is required when fileId is given.",
|
|
113
|
+
});
|
|
114
|
+
}
|
|
115
|
+
const folder = folders[0];
|
|
116
|
+
const attachment = (folder.Attachments ?? []).find((a) => a.FileId === fileId);
|
|
117
|
+
if (!attachment) {
|
|
118
|
+
return toolResponse({
|
|
119
|
+
courseId,
|
|
120
|
+
folderId,
|
|
121
|
+
fileId,
|
|
122
|
+
error: `No attachment with id ${fileId} on assignment "${folder.Name}".`,
|
|
123
|
+
available: (folder.Attachments ?? []).map(describeAttachment),
|
|
124
|
+
});
|
|
125
|
+
}
|
|
126
|
+
const file = await readAttachment(apiClient, courseId, folderId, attachment, extractText, maxChars);
|
|
127
|
+
return toolResponse({
|
|
128
|
+
courseId,
|
|
129
|
+
folderId,
|
|
130
|
+
folderName: folder.Name,
|
|
131
|
+
url: baseUrl ? assignmentUrl(baseUrl, courseId, folderId) : null,
|
|
132
|
+
file,
|
|
133
|
+
});
|
|
134
|
+
}
|
|
135
|
+
// Discovery: which assignments have files, without downloading any.
|
|
136
|
+
const withFiles = folders
|
|
137
|
+
.filter((folder) => (folder.Attachments ?? []).length > 0)
|
|
138
|
+
.map((folder) => ({
|
|
139
|
+
folderId: folder.Id,
|
|
140
|
+
folderName: folder.Name,
|
|
141
|
+
dueDate: folder.DueDate,
|
|
142
|
+
url: baseUrl ? assignmentUrl(baseUrl, courseId, folder.Id) : null,
|
|
143
|
+
attachments: (folder.Attachments ?? []).map(describeAttachment),
|
|
144
|
+
}));
|
|
145
|
+
log("INFO", `get_assignment_files: ${withFiles.length} assignments with attachments in course ${courseId}`);
|
|
146
|
+
return toolResponse({
|
|
147
|
+
courseId,
|
|
148
|
+
assignments: withFiles,
|
|
149
|
+
...(withFiles.length === 0
|
|
150
|
+
? { note: "No assignment in this course has an attached file." }
|
|
151
|
+
: {}),
|
|
152
|
+
});
|
|
153
|
+
}
|
|
154
|
+
catch (error) {
|
|
155
|
+
return sanitizeError(error);
|
|
156
|
+
}
|
|
157
|
+
});
|
|
158
|
+
}
|
|
159
|
+
//# sourceMappingURL=get-assignment-files.js.map
|
|
@@ -0,0 +1,343 @@
|
|
|
1
|
+
import { fetchSubmissionStatus } from "./submission-status.js";
|
|
2
|
+
import { DEFAULT_CACHE_TTLS } from "../api/index.js";
|
|
3
|
+
import { GetAssignmentsSchema } from "./schemas.js";
|
|
4
|
+
import { toolResponse, sanitizeError } from "./tool-helpers.js";
|
|
5
|
+
import { convertHtmlToMarkdown } from "../utils/html-converter.js";
|
|
6
|
+
import { log } from "../utils/logger.js";
|
|
7
|
+
import { applyCourseFilter } from "../utils/course-filter.js";
|
|
8
|
+
import { assignmentUrl, gradebookUrl, quizUrl } from "../utils/deep-links.js";
|
|
9
|
+
function richTextHtml(field) {
|
|
10
|
+
if (!field)
|
|
11
|
+
return null;
|
|
12
|
+
const nested = typeof field.Text === "object" && field.Text !== null ? field.Text.Html : null;
|
|
13
|
+
return nested ?? field.Html ?? null;
|
|
14
|
+
}
|
|
15
|
+
const STUDENT_SCORED = new Set([1, 2, 3, 4]);
|
|
16
|
+
function missingContentQuizTopics(raw, listedIds) {
|
|
17
|
+
if (!raw || typeof raw !== "object")
|
|
18
|
+
return [];
|
|
19
|
+
const found = new Map();
|
|
20
|
+
const visit = (modules) => {
|
|
21
|
+
if (!Array.isArray(modules))
|
|
22
|
+
return;
|
|
23
|
+
for (const module of modules) {
|
|
24
|
+
for (const topic of module.Topics ?? []) {
|
|
25
|
+
const isQuiz = topic.ActivityType === 4
|
|
26
|
+
|| topic.TypeIdentifier?.toLowerCase() === "quiz"
|
|
27
|
+
|| /(?:[?&]|&)type=quiz(?:[&#]|$)/i.test(topic.Url ?? "");
|
|
28
|
+
if (!isQuiz || topic.IsHidden || topic.IsBroken || topic.IsExempt)
|
|
29
|
+
continue;
|
|
30
|
+
const activityId = topic.ToolItemId ?? Number(topic.ActivityId);
|
|
31
|
+
if (!Number.isSafeInteger(activityId) || activityId <= 0 || listedIds.has(activityId))
|
|
32
|
+
continue;
|
|
33
|
+
if (!found.has(activityId))
|
|
34
|
+
found.set(activityId, topic);
|
|
35
|
+
}
|
|
36
|
+
visit(module.Modules);
|
|
37
|
+
}
|
|
38
|
+
};
|
|
39
|
+
visit(raw.Modules);
|
|
40
|
+
return [...found.values()];
|
|
41
|
+
}
|
|
42
|
+
async function recoverContentQuizzes(apiClient, courseId, toc, listedIds) {
|
|
43
|
+
return Promise.all(missingContentQuizTopics(toc, listedIds).map(async (topic) => {
|
|
44
|
+
const quizId = topic.ToolItemId ?? Number(topic.ActivityId);
|
|
45
|
+
try {
|
|
46
|
+
return await apiClient.get(apiClient.le(courseId, `/quizzes/${quizId}`), {
|
|
47
|
+
ttl: DEFAULT_CACHE_TTLS.assignments,
|
|
48
|
+
});
|
|
49
|
+
}
|
|
50
|
+
catch (error) {
|
|
51
|
+
log("DEBUG", `Failed to fetch content-linked quiz ${quizId}: using content metadata`, error);
|
|
52
|
+
}
|
|
53
|
+
let detail = topic;
|
|
54
|
+
try {
|
|
55
|
+
detail = await apiClient.get(apiClient.le(courseId, `/content/topics/${topic.TopicId}`), { ttl: DEFAULT_CACHE_TTLS.courseContent });
|
|
56
|
+
}
|
|
57
|
+
catch (error) {
|
|
58
|
+
log("DEBUG", `Failed to fetch content topic ${topic.TopicId}: using table-of-contents metadata`, error);
|
|
59
|
+
}
|
|
60
|
+
return {
|
|
61
|
+
QuizId: quizId,
|
|
62
|
+
Name: detail.Title || topic.Title,
|
|
63
|
+
Description: detail.Description ?? null,
|
|
64
|
+
StartDate: detail.StartDate ?? detail.StartDateTime ?? topic.StartDateTime ?? null,
|
|
65
|
+
EndDate: detail.EndDate ?? detail.EndDateTime ?? topic.EndDateTime ?? null,
|
|
66
|
+
DueDate: detail.DueDate ?? null,
|
|
67
|
+
IsActive: true,
|
|
68
|
+
AttemptsAllowed: null,
|
|
69
|
+
ContentMetadataOnly: true,
|
|
70
|
+
};
|
|
71
|
+
}));
|
|
72
|
+
}
|
|
73
|
+
export async function fetchCourseAssignments(apiClient, courseId, baseUrl) {
|
|
74
|
+
const assignments = [];
|
|
75
|
+
const [dropboxResult, quizResult, gradebookResult, contentResult] = await Promise.allSettled([
|
|
76
|
+
apiClient.get(apiClient.le(courseId, "/dropbox/folders/"), { ttl: DEFAULT_CACHE_TTLS.assignments }),
|
|
77
|
+
apiClient.get(apiClient.le(courseId, "/quizzes/"), { ttl: DEFAULT_CACHE_TTLS.assignments }),
|
|
78
|
+
apiClient.get(apiClient.le(courseId, "/grades/"), {
|
|
79
|
+
ttl: DEFAULT_CACHE_TTLS.assignments,
|
|
80
|
+
}),
|
|
81
|
+
apiClient.get(apiClient.le(courseId, "/content/toc"), {
|
|
82
|
+
ttl: DEFAULT_CACHE_TTLS.courseContent,
|
|
83
|
+
}),
|
|
84
|
+
]);
|
|
85
|
+
// Process Dropbox folders
|
|
86
|
+
if (dropboxResult.status === "fulfilled") {
|
|
87
|
+
// D2L dropbox endpoint may return paged { Objects: [...] } or flat array
|
|
88
|
+
const dropboxRaw = dropboxResult.value;
|
|
89
|
+
const folders = Array.isArray(dropboxRaw) ? dropboxRaw : dropboxRaw.Objects ?? [];
|
|
90
|
+
for (const folder of folders) {
|
|
91
|
+
// Skip hidden folders
|
|
92
|
+
if (folder.IsHidden)
|
|
93
|
+
continue;
|
|
94
|
+
const submissionStatus = await fetchSubmissionStatus(apiClient, courseId, folder);
|
|
95
|
+
// Fetch feedback independently of submissions
|
|
96
|
+
let feedback = null;
|
|
97
|
+
try {
|
|
98
|
+
feedback = await apiClient.get(apiClient.le(courseId, `/dropbox/folders/${folder.Id}/feedback/myFeedback/`), { ttl: DEFAULT_CACHE_TTLS.assignments });
|
|
99
|
+
}
|
|
100
|
+
catch (error) {
|
|
101
|
+
// 404/403 means no feedback available (or no access) - that's fine
|
|
102
|
+
if (error?.status !== 404 && error?.status !== 403) {
|
|
103
|
+
log("DEBUG", `Failed to fetch feedback for folder ${folder.Id}`, error);
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
// Build assignment object
|
|
107
|
+
const assignment = {
|
|
108
|
+
type: "assignment",
|
|
109
|
+
id: folder.Id,
|
|
110
|
+
name: folder.Name,
|
|
111
|
+
url: baseUrl ? assignmentUrl(baseUrl, courseId, folder.Id) : null,
|
|
112
|
+
instructions: folder.CustomInstructions?.Html
|
|
113
|
+
? convertHtmlToMarkdown(folder.CustomInstructions.Html)
|
|
114
|
+
: { markdown: "", html: "" },
|
|
115
|
+
dueDate: folder.DueDate,
|
|
116
|
+
points: folder.Assessment?.ScoreDenominator ?? null,
|
|
117
|
+
isGroup: folder.GroupTypeId !== null,
|
|
118
|
+
rubric: folder.Assessment?.Rubrics?.map((r) => ({
|
|
119
|
+
name: r.Name,
|
|
120
|
+
criteria: r.Criteria?.map((c) => ({
|
|
121
|
+
name: c.Name,
|
|
122
|
+
levels: c.Levels?.map((l) => ({
|
|
123
|
+
name: l.Name,
|
|
124
|
+
points: l.Points,
|
|
125
|
+
description: l.Description?.Text ?? null,
|
|
126
|
+
})) ?? [],
|
|
127
|
+
})) ?? [],
|
|
128
|
+
})) ?? null,
|
|
129
|
+
startDate: folder.Availability?.StartDate ?? null,
|
|
130
|
+
endDate: folder.Availability?.EndDate ?? null,
|
|
131
|
+
...submissionStatus,
|
|
132
|
+
feedback: feedback
|
|
133
|
+
? {
|
|
134
|
+
score: feedback.Score,
|
|
135
|
+
feedback: feedback.Feedback?.Html
|
|
136
|
+
? convertHtmlToMarkdown(feedback.Feedback.Html)
|
|
137
|
+
: null,
|
|
138
|
+
}
|
|
139
|
+
: null,
|
|
140
|
+
};
|
|
141
|
+
assignments.push(assignment);
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
else {
|
|
145
|
+
// Log dropbox fetch failure but don't throw
|
|
146
|
+
log("DEBUG", `Failed to fetch dropbox folders for course ${courseId}`, dropboxResult.reason);
|
|
147
|
+
}
|
|
148
|
+
// Process quizzes. Content discovery remains useful when the list route is
|
|
149
|
+
// forbidden or unavailable, so a failed list starts from an empty set.
|
|
150
|
+
let quizzes = [];
|
|
151
|
+
if (quizResult.status === "fulfilled") {
|
|
152
|
+
const quizResponse = quizResult.value;
|
|
153
|
+
// D2L quizzes API returns paged result { Objects: [...] } or a plain array
|
|
154
|
+
quizzes = Array.isArray(quizResponse)
|
|
155
|
+
? quizResponse
|
|
156
|
+
: quizResponse?.Objects ?? [];
|
|
157
|
+
}
|
|
158
|
+
else {
|
|
159
|
+
log("DEBUG", `Failed to fetch quizzes for course ${courseId}`, quizResult.reason);
|
|
160
|
+
}
|
|
161
|
+
if (contentResult.status === "fulfilled") {
|
|
162
|
+
const listedIds = new Set(quizzes.map((quiz) => quiz.QuizId));
|
|
163
|
+
quizzes.push(...await recoverContentQuizzes(apiClient, courseId, contentResult.value, listedIds));
|
|
164
|
+
}
|
|
165
|
+
else {
|
|
166
|
+
log("DEBUG", `Failed to fetch course content for quiz discovery in course ${courseId}`, contentResult.reason);
|
|
167
|
+
}
|
|
168
|
+
let attemptsForbidden = false;
|
|
169
|
+
for (const quiz of quizzes) {
|
|
170
|
+
// Skip inactive quizzes
|
|
171
|
+
if (!quiz.IsActive)
|
|
172
|
+
continue;
|
|
173
|
+
// Fetch quiz attempts. null means "not measured", which is different
|
|
174
|
+
// from an empty list, and the output says which one it was.
|
|
175
|
+
let attempts = null;
|
|
176
|
+
if (!attemptsForbidden && !quiz.ContentMetadataOnly) {
|
|
177
|
+
try {
|
|
178
|
+
const attemptsRaw = await apiClient.get(apiClient.le(courseId, `/quizzes/${quiz.QuizId}/attempts/`), { ttl: DEFAULT_CACHE_TTLS.assignments });
|
|
179
|
+
// D2L attempts endpoint may return paged { Objects: [...] } or flat array
|
|
180
|
+
attempts = Array.isArray(attemptsRaw) ? attemptsRaw : attemptsRaw.Objects ?? [];
|
|
181
|
+
}
|
|
182
|
+
catch (error) {
|
|
183
|
+
if (error?.status === 404) {
|
|
184
|
+
// 404 means no attempts yet, which is a measurement of zero
|
|
185
|
+
attempts = [];
|
|
186
|
+
}
|
|
187
|
+
else if (error?.status === 403) {
|
|
188
|
+
attemptsForbidden = true;
|
|
189
|
+
log("DEBUG", `Attempts are forbidden for course ${courseId}: not asking again`);
|
|
190
|
+
}
|
|
191
|
+
else {
|
|
192
|
+
log("DEBUG", `Failed to fetch attempts for quiz ${quiz.QuizId}`, error);
|
|
193
|
+
}
|
|
194
|
+
}
|
|
195
|
+
}
|
|
196
|
+
// Calculate remaining attempts
|
|
197
|
+
const completedAttempts = attempts?.filter((a) => a.IsCompleted) ?? null;
|
|
198
|
+
let attemptsRemaining = null;
|
|
199
|
+
let attemptWarning = null;
|
|
200
|
+
if (completedAttempts) {
|
|
201
|
+
attemptsRemaining = "Unlimited";
|
|
202
|
+
if (quiz.AttemptsAllowed && !quiz.AttemptsAllowed.IsUnlimited) {
|
|
203
|
+
const allowed = quiz.AttemptsAllowed.NumberOfAttemptsAllowed ?? 0;
|
|
204
|
+
attemptsRemaining = allowed - completedAttempts.length;
|
|
205
|
+
// Generate warning for low attempts
|
|
206
|
+
if (attemptsRemaining <= 0) {
|
|
207
|
+
attemptWarning = "WARNING: No attempts remaining";
|
|
208
|
+
}
|
|
209
|
+
else if (attemptsRemaining === 1) {
|
|
210
|
+
attemptWarning = "WARNING: Only 1 attempt remaining";
|
|
211
|
+
}
|
|
212
|
+
}
|
|
213
|
+
}
|
|
214
|
+
const timeLimit = quiz.SubmissionTimeLimit ?? quiz.TimeLimit;
|
|
215
|
+
const descriptionHtml = richTextHtml(quiz.Description);
|
|
216
|
+
// Build quiz object
|
|
217
|
+
const quizAssignment = {
|
|
218
|
+
type: "quiz",
|
|
219
|
+
id: quiz.QuizId,
|
|
220
|
+
name: quiz.Name,
|
|
221
|
+
url: baseUrl ? quizUrl(baseUrl, courseId, quiz.QuizId) : null,
|
|
222
|
+
instructions: descriptionHtml
|
|
223
|
+
? convertHtmlToMarkdown(descriptionHtml)
|
|
224
|
+
: { markdown: "", html: "" },
|
|
225
|
+
dueDate: quiz.DueDate,
|
|
226
|
+
startDate: quiz.StartDate,
|
|
227
|
+
endDate: quiz.EndDate,
|
|
228
|
+
timeLimit: timeLimit?.IsEnforced ? timeLimit.TimeLimitValue : null,
|
|
229
|
+
attemptsAllowed: quiz.AttemptsAllowed?.IsUnlimited
|
|
230
|
+
? "Unlimited"
|
|
231
|
+
: quiz.AttemptsAllowed?.NumberOfAttemptsAllowed ?? null,
|
|
232
|
+
// False when the tenant refused the attempts endpoint, in which case
|
|
233
|
+
// every count below is null rather than a guess of zero.
|
|
234
|
+
attemptsAvailable: completedAttempts !== null,
|
|
235
|
+
attemptsUsed: completedAttempts?.length ?? null,
|
|
236
|
+
attemptsRemaining,
|
|
237
|
+
attemptWarning,
|
|
238
|
+
bestScore: completedAttempts && completedAttempts.length > 0
|
|
239
|
+
? Math.max(...completedAttempts.map((a) => a.Score ?? 0))
|
|
240
|
+
: null,
|
|
241
|
+
gracePeriodMinutes: quiz.SubmissionGracePeriod ?? null,
|
|
242
|
+
hasPassword: Boolean(quiz.Password),
|
|
243
|
+
};
|
|
244
|
+
assignments.push(quizAssignment);
|
|
245
|
+
}
|
|
246
|
+
// Process the gradebook last, once the fetched items are known.
|
|
247
|
+
if (gradebookResult.status === "fulfilled") {
|
|
248
|
+
assignments.push(...gradebookHeadsUp(gradebookResult.value, assignments, courseId, baseUrl));
|
|
249
|
+
}
|
|
250
|
+
else {
|
|
251
|
+
log("DEBUG", `Failed to fetch the gradebook for course ${courseId}`, gradebookResult.reason);
|
|
252
|
+
}
|
|
253
|
+
return assignments;
|
|
254
|
+
}
|
|
255
|
+
function gradebookHeadsUp(raw, fetched, courseId, baseUrl) {
|
|
256
|
+
if (!Array.isArray(raw))
|
|
257
|
+
return [];
|
|
258
|
+
const covered = new Set(fetched.map((item) => item.id).filter((id) => typeof id === "number"));
|
|
259
|
+
const rows = [];
|
|
260
|
+
for (const column of raw) {
|
|
261
|
+
if (!STUDENT_SCORED.has(column?.GradeObjectTypeId))
|
|
262
|
+
continue;
|
|
263
|
+
if (covered.has(column.AssociatedTool?.ToolItemId))
|
|
264
|
+
continue;
|
|
265
|
+
if (typeof column.Id !== "number" || typeof column.Name !== "string")
|
|
266
|
+
continue;
|
|
267
|
+
rows.push({
|
|
268
|
+
type: "gradeOnly",
|
|
269
|
+
id: column.Id,
|
|
270
|
+
name: column.Name,
|
|
271
|
+
// Always null: a grade column carries no due date of its own, and
|
|
272
|
+
// inventing one from the column name would be a guess.
|
|
273
|
+
dueDate: null,
|
|
274
|
+
url: baseUrl ? gradebookUrl(baseUrl, courseId) : null,
|
|
275
|
+
});
|
|
276
|
+
}
|
|
277
|
+
return rows;
|
|
278
|
+
}
|
|
279
|
+
export function registerGetAssignments(server, apiClient, config) {
|
|
280
|
+
server.registerTool("get_assignments", {
|
|
281
|
+
title: "Get Assignments",
|
|
282
|
+
description: "Fetch assignments and quizzes for a specific course or all enrolled courses. Shows dropbox submissions and quizzes with due dates, status, and rubric info. Use this when the user asks about assignments, homework, what to submit, quizzes, or assignment details and rubrics.",
|
|
283
|
+
inputSchema: GetAssignmentsSchema,
|
|
284
|
+
}, async (args) => {
|
|
285
|
+
try {
|
|
286
|
+
log("DEBUG", "get_assignments tool called", { args });
|
|
287
|
+
// Parse and validate input
|
|
288
|
+
const { courseId } = GetAssignmentsSchema.parse(args);
|
|
289
|
+
// Single course case
|
|
290
|
+
if (courseId) {
|
|
291
|
+
const assignments = await fetchCourseAssignments(apiClient, courseId, config.baseUrl);
|
|
292
|
+
log("INFO", `get_assignments: Retrieved ${assignments.length} assignments for course ${courseId}`);
|
|
293
|
+
return toolResponse({ courseId, assignments });
|
|
294
|
+
}
|
|
295
|
+
// All courses case
|
|
296
|
+
// First, fetch enrolled courses
|
|
297
|
+
const enrollmentPath = apiClient.lp("/enrollments/myenrollments/?orgUnitTypeId=3&isActive=true");
|
|
298
|
+
const enrollmentResponse = await apiClient.get(enrollmentPath, { ttl: DEFAULT_CACHE_TTLS.enrollments });
|
|
299
|
+
// Apply course filter
|
|
300
|
+
const filteredEnrollments = applyCourseFilter(enrollmentResponse.Items.map(item => ({
|
|
301
|
+
id: item.OrgUnit.Id,
|
|
302
|
+
name: item.OrgUnit.Name,
|
|
303
|
+
code: item.OrgUnit.Code,
|
|
304
|
+
isActive: item.Access.IsActive,
|
|
305
|
+
canAccess: item.Access.CanAccess,
|
|
306
|
+
...item,
|
|
307
|
+
})), config.courseFilter);
|
|
308
|
+
// Fetch assignments for each course (handle 403s gracefully)
|
|
309
|
+
const assignmentPromises = filteredEnrollments.map(async (item) => {
|
|
310
|
+
try {
|
|
311
|
+
const assignments = await fetchCourseAssignments(apiClient, item.OrgUnit.Id, config.baseUrl);
|
|
312
|
+
return {
|
|
313
|
+
courseId: item.OrgUnit.Id,
|
|
314
|
+
courseName: item.OrgUnit.Name,
|
|
315
|
+
assignments,
|
|
316
|
+
};
|
|
317
|
+
}
|
|
318
|
+
catch (error) {
|
|
319
|
+
// 403 means no access (past course, etc) - log and skip
|
|
320
|
+
if (error?.status === 403) {
|
|
321
|
+
log("DEBUG", `get_assignments: 403 Forbidden for course ${item.OrgUnit.Id} (${item.OrgUnit.Name}) - skipping`);
|
|
322
|
+
return null;
|
|
323
|
+
}
|
|
324
|
+
throw error; // Re-throw other errors
|
|
325
|
+
}
|
|
326
|
+
});
|
|
327
|
+
const results = await Promise.allSettled(assignmentPromises);
|
|
328
|
+
const courses = results
|
|
329
|
+
.filter((r) => r.status === "fulfilled" && r.value !== null)
|
|
330
|
+
.map((r) => r.value);
|
|
331
|
+
log("INFO", `get_assignments: Retrieved assignments for ${courses.length} courses (out of ${enrollmentResponse.Items.length} enrolled)`);
|
|
332
|
+
return toolResponse({ courses });
|
|
333
|
+
}
|
|
334
|
+
catch (error) {
|
|
335
|
+
// Temporary: log full error details to stderr for debugging
|
|
336
|
+
if (error instanceof Error) {
|
|
337
|
+
log("ERROR", `get_assignments failed: ${error.message}\n${error.stack}`);
|
|
338
|
+
}
|
|
339
|
+
return sanitizeError(error);
|
|
340
|
+
}
|
|
341
|
+
});
|
|
342
|
+
}
|
|
343
|
+
//# sourceMappingURL=get-assignments.js.map
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
import { DEFAULT_CACHE_TTLS } from "../api/index.js";
|
|
2
|
+
import { fetchAllObjects } from "../api/paginate.js";
|
|
3
|
+
import { GetClasslistEmailsSchema } from "./schemas.js";
|
|
4
|
+
import { toolResponse, sanitizeError } from "./tool-helpers.js";
|
|
5
|
+
import { log } from "../utils/logger.js";
|
|
6
|
+
export function registerGetClasslistEmails(server, apiClient) {
|
|
7
|
+
server.registerTool("get_classlist_emails", {
|
|
8
|
+
title: "Get Classlist Emails",
|
|
9
|
+
description: "Fetch all email addresses for everyone in a course: instructors, TAs, and students. " +
|
|
10
|
+
"Use this when the user wants a list of emails for a class, needs to email the whole class, " +
|
|
11
|
+
"or wants contact info for everyone enrolled.",
|
|
12
|
+
inputSchema: GetClasslistEmailsSchema,
|
|
13
|
+
}, async (args) => {
|
|
14
|
+
try {
|
|
15
|
+
log("DEBUG", "get_classlist_emails tool called", { args });
|
|
16
|
+
const { courseId } = GetClasslistEmailsSchema.parse(args);
|
|
17
|
+
// Fetch full classlist (all roles) using paged endpoint
|
|
18
|
+
const path = apiClient.le(courseId, "/classlist/paged/");
|
|
19
|
+
const users = await fetchAllObjects(apiClient, path, {
|
|
20
|
+
ttl: DEFAULT_CACHE_TTLS.roster,
|
|
21
|
+
});
|
|
22
|
+
// Extract emails, filtering out nulls (privacy-hidden)
|
|
23
|
+
const emails = users
|
|
24
|
+
.filter((user) => user.Email)
|
|
25
|
+
.map((user) => ({
|
|
26
|
+
name: user.DisplayName,
|
|
27
|
+
email: user.Email,
|
|
28
|
+
role: user.ClasslistRoleDisplayName,
|
|
29
|
+
}));
|
|
30
|
+
log("INFO", `get_classlist_emails: ${emails.length} emails from ${users.length} users in course ${courseId}`);
|
|
31
|
+
return toolResponse(emails);
|
|
32
|
+
}
|
|
33
|
+
catch (error) {
|
|
34
|
+
return sanitizeError(error);
|
|
35
|
+
}
|
|
36
|
+
});
|
|
37
|
+
}
|
|
38
|
+
//# sourceMappingURL=get-classlist-emails.js.map
|