@charlie.act7/canvas-mcp-server 1.1.3 → 1.2.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/.mcp.json +8 -6
- package/LICENSE +21 -0
- package/README.es.md +46 -26
- package/README.md +93 -46
- package/dist/http-server.js +139 -5
- package/dist/index.js +35 -4
- package/dist/services/agent-runner.js +214 -0
- package/dist/services/canvas-client.js +248 -5
- package/dist/services/gemini-runner.js +124 -0
- package/dist/tools/analytics-tools.js +118 -0
- package/dist/tools/assignment-tools.js +77 -1
- package/dist/tools/communication-tools.js +101 -0
- package/dist/tools/conversation-tools.js +130 -0
- package/dist/tools/course-tools.js +142 -0
- package/dist/tools/enrollment-tools.js +202 -0
- package/dist/tools/new-quiz-tools.js +357 -0
- package/dist/tools/peer-review-tools.js +130 -0
- package/dist/tools/quiz-tools.js +312 -3
- package/dist/tools/rubric-tools.js +159 -0
- package/package.json +70 -68
- package/dist/tools/question-bank-tools.js +0 -238
package/dist/index.js
CHANGED
|
@@ -23,6 +23,11 @@ import { configTools } from "./tools/config-tools.js";
|
|
|
23
23
|
import { rubricTools } from "./tools/rubric-tools.js";
|
|
24
24
|
import { calendarTools } from "./tools/calendar-tools.js";
|
|
25
25
|
import { groupTools } from "./tools/group-tools.js";
|
|
26
|
+
import { enrollmentTools } from "./tools/enrollment-tools.js";
|
|
27
|
+
import { conversationTools } from "./tools/conversation-tools.js";
|
|
28
|
+
import { newQuizTools } from "./tools/new-quiz-tools.js";
|
|
29
|
+
import { analyticsTools } from "./tools/analytics-tools.js";
|
|
30
|
+
import { peerReviewTools } from "./tools/peer-review-tools.js";
|
|
26
31
|
import { canvasResources } from "./resources/canvas-resources.js";
|
|
27
32
|
import { canvasPrompts } from "./prompts/canvas-prompts.js";
|
|
28
33
|
import { startHttpServer } from "./http-server.js";
|
|
@@ -33,7 +38,7 @@ const program = new Command();
|
|
|
33
38
|
program
|
|
34
39
|
.name("canvas-mcp")
|
|
35
40
|
.description("MCP Server for Canvas LMS (Refactored)")
|
|
36
|
-
.version("1.
|
|
41
|
+
.version("1.2.0");
|
|
37
42
|
// --- CLI Commands ---
|
|
38
43
|
program
|
|
39
44
|
.command("config")
|
|
@@ -82,7 +87,7 @@ program
|
|
|
82
87
|
const client = getClient();
|
|
83
88
|
const server = new Server({
|
|
84
89
|
name: "canvas-lms-server",
|
|
85
|
-
version: "1.
|
|
90
|
+
version: "1.2.0",
|
|
86
91
|
}, {
|
|
87
92
|
capabilities: {
|
|
88
93
|
tools: {},
|
|
@@ -105,7 +110,12 @@ program
|
|
|
105
110
|
...configTools,
|
|
106
111
|
...rubricTools,
|
|
107
112
|
...calendarTools,
|
|
108
|
-
...groupTools
|
|
113
|
+
...groupTools,
|
|
114
|
+
...enrollmentTools,
|
|
115
|
+
...conversationTools,
|
|
116
|
+
...newQuizTools,
|
|
117
|
+
...analyticsTools,
|
|
118
|
+
...peerReviewTools
|
|
109
119
|
];
|
|
110
120
|
// --- Tool Handlers ---
|
|
111
121
|
server.setRequestHandler(ListToolsRequestSchema, async () => {
|
|
@@ -163,6 +173,27 @@ program
|
|
|
163
173
|
.action(async (options) => {
|
|
164
174
|
const client = getClient();
|
|
165
175
|
const port = Number.parseInt(options.port, 10);
|
|
166
|
-
|
|
176
|
+
const allTools = [
|
|
177
|
+
...courseTools,
|
|
178
|
+
...assignmentTools,
|
|
179
|
+
...quizTools,
|
|
180
|
+
...gradingTools,
|
|
181
|
+
...communicationTools,
|
|
182
|
+
...studentTools,
|
|
183
|
+
...quizQuestionTools,
|
|
184
|
+
...createTools,
|
|
185
|
+
...moduleTools,
|
|
186
|
+
...fileTools,
|
|
187
|
+
...configTools,
|
|
188
|
+
...rubricTools,
|
|
189
|
+
...calendarTools,
|
|
190
|
+
...groupTools,
|
|
191
|
+
...enrollmentTools,
|
|
192
|
+
...conversationTools,
|
|
193
|
+
...newQuizTools,
|
|
194
|
+
...analyticsTools,
|
|
195
|
+
...peerReviewTools
|
|
196
|
+
];
|
|
197
|
+
await startHttpServer(client, options.host, port, allTools);
|
|
167
198
|
});
|
|
168
199
|
program.parse(process.argv);
|
|
@@ -0,0 +1,214 @@
|
|
|
1
|
+
import { Ollama } from "ollama";
|
|
2
|
+
// ---------------------------------------------------------------------------
|
|
3
|
+
// Detección de modo
|
|
4
|
+
// ---------------------------------------------------------------------------
|
|
5
|
+
async function detectMode(ollama, model, sampleTool) {
|
|
6
|
+
try {
|
|
7
|
+
await ollama.chat({
|
|
8
|
+
model,
|
|
9
|
+
messages: [{ role: "user", content: "ping" }],
|
|
10
|
+
tools: [sampleTool],
|
|
11
|
+
stream: false
|
|
12
|
+
});
|
|
13
|
+
return "native";
|
|
14
|
+
}
|
|
15
|
+
catch (err) {
|
|
16
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
17
|
+
if (msg.includes("does not support tools"))
|
|
18
|
+
return "prompt";
|
|
19
|
+
throw err;
|
|
20
|
+
}
|
|
21
|
+
}
|
|
22
|
+
// ---------------------------------------------------------------------------
|
|
23
|
+
// Parseo de tool calls en texto plano (modo prompt)
|
|
24
|
+
// ---------------------------------------------------------------------------
|
|
25
|
+
function parseTextToolCalls(content) {
|
|
26
|
+
const results = [];
|
|
27
|
+
const tagPattern = /<tool_call>\s*([\s\S]*?)\s*<\/tool_call>/g;
|
|
28
|
+
let match;
|
|
29
|
+
while ((match = tagPattern.exec(content)) !== null) {
|
|
30
|
+
try {
|
|
31
|
+
const parsed = JSON.parse(match[1]);
|
|
32
|
+
if (parsed.name && parsed.arguments) {
|
|
33
|
+
results.push({ name: parsed.name, arguments: parsed.arguments });
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
catch { /* ignorar */ }
|
|
37
|
+
}
|
|
38
|
+
if (results.length > 0)
|
|
39
|
+
return results;
|
|
40
|
+
// Fallback: JSON suelto con "name" + "arguments"
|
|
41
|
+
const jsonPattern = /\{[\s\S]*?"name"\s*:\s*"([^"]+)"[\s\S]*?"arguments"\s*:\s*(\{[\s\S]*?\})\s*\}/g;
|
|
42
|
+
while ((match = jsonPattern.exec(content)) !== null) {
|
|
43
|
+
try {
|
|
44
|
+
results.push({ name: match[1], arguments: JSON.parse(match[2]) });
|
|
45
|
+
}
|
|
46
|
+
catch { /* ignorar */ }
|
|
47
|
+
}
|
|
48
|
+
return results;
|
|
49
|
+
}
|
|
50
|
+
// ---------------------------------------------------------------------------
|
|
51
|
+
// System prompt para modo prompt-engineering
|
|
52
|
+
// ---------------------------------------------------------------------------
|
|
53
|
+
const PROMPT_MODE_TOOLS = [
|
|
54
|
+
"canvas_list_courses",
|
|
55
|
+
"canvas_get_assignments",
|
|
56
|
+
"canvas_get_assignment",
|
|
57
|
+
"canvas_list_students",
|
|
58
|
+
"canvas_list_students_with_grades",
|
|
59
|
+
"canvas_get_student_grades",
|
|
60
|
+
"canvas_list_modules",
|
|
61
|
+
"canvas_list_quizzes",
|
|
62
|
+
"canvas_get_submissions",
|
|
63
|
+
"canvas_get_submission",
|
|
64
|
+
"canvas_grade_submission",
|
|
65
|
+
"canvas_list_announcements",
|
|
66
|
+
"canvas_list_discussions",
|
|
67
|
+
"canvas_audit_course",
|
|
68
|
+
"canvas_list_assignment_groups",
|
|
69
|
+
"canvas_list_assignment_due_dates",
|
|
70
|
+
"canvas_grade_multiple_submissions",
|
|
71
|
+
"canvas_get_submission_comments",
|
|
72
|
+
];
|
|
73
|
+
function buildPromptSystemPrompt(tools) {
|
|
74
|
+
const selected = tools
|
|
75
|
+
.filter(t => PROMPT_MODE_TOOLS.includes(t.name))
|
|
76
|
+
.map(t => ({
|
|
77
|
+
name: t.tool.name,
|
|
78
|
+
description: t.tool.description,
|
|
79
|
+
parameters: t.tool.inputSchema
|
|
80
|
+
}));
|
|
81
|
+
return `Eres un asistente educativo con acceso a Canvas LMS.
|
|
82
|
+
Responde SIEMPRE en español. Sé conciso y claro.
|
|
83
|
+
|
|
84
|
+
Herramientas disponibles:
|
|
85
|
+
${JSON.stringify(selected, null, 2)}
|
|
86
|
+
|
|
87
|
+
REGLAS:
|
|
88
|
+
1. Para consultar Canvas, responde SOLO con un bloque <tool_call>:
|
|
89
|
+
<tool_call>
|
|
90
|
+
{"name": "nombre_herramienta", "arguments": {"param": "valor"}}
|
|
91
|
+
</tool_call>
|
|
92
|
+
2. No escribas nada más cuando hagas una tool call.
|
|
93
|
+
3. Después de recibir el resultado, responde al usuario en español.
|
|
94
|
+
4. Una herramienta a la vez.`;
|
|
95
|
+
}
|
|
96
|
+
const NATIVE_SYSTEM = "Eres un asistente educativo con acceso a Canvas LMS. " +
|
|
97
|
+
"Usa las herramientas disponibles para responder sobre cursos, tareas, estudiantes y calificaciones. " +
|
|
98
|
+
"Responde SIEMPRE en español. Sé conciso y claro.";
|
|
99
|
+
// ---------------------------------------------------------------------------
|
|
100
|
+
// AgentRunner
|
|
101
|
+
// ---------------------------------------------------------------------------
|
|
102
|
+
export class AgentRunner {
|
|
103
|
+
ollama;
|
|
104
|
+
tools;
|
|
105
|
+
client;
|
|
106
|
+
ollamaTools;
|
|
107
|
+
constructor(client, tools, ollamaHost = "http://localhost:11434") {
|
|
108
|
+
this.client = client;
|
|
109
|
+
this.tools = tools;
|
|
110
|
+
this.ollama = new Ollama({ host: ollamaHost });
|
|
111
|
+
this.ollamaTools = tools.map(t => ({
|
|
112
|
+
type: "function",
|
|
113
|
+
function: {
|
|
114
|
+
name: t.tool.name,
|
|
115
|
+
description: t.tool.description ?? "",
|
|
116
|
+
parameters: t.tool.inputSchema
|
|
117
|
+
}
|
|
118
|
+
}));
|
|
119
|
+
}
|
|
120
|
+
async run(userMessage, options = {}) {
|
|
121
|
+
const modelRaw = options.model ?? "qwen2.5";
|
|
122
|
+
const modeArg = options.mode ?? "auto";
|
|
123
|
+
// Resolver nombre del modelo (fallback a :latest)
|
|
124
|
+
let model = modelRaw;
|
|
125
|
+
try {
|
|
126
|
+
await this.ollama.show({ model });
|
|
127
|
+
}
|
|
128
|
+
catch {
|
|
129
|
+
const withLatest = model.includes(":") ? model : `${model}:latest`;
|
|
130
|
+
try {
|
|
131
|
+
await this.ollama.show({ model: withLatest });
|
|
132
|
+
model = withLatest;
|
|
133
|
+
}
|
|
134
|
+
catch {
|
|
135
|
+
throw new Error(`Modelo '${modelRaw}' no encontrado en Ollama. Descárgalo con: ollama pull ${modelRaw}`);
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
// Detectar modo
|
|
139
|
+
let mode;
|
|
140
|
+
if (modeArg === "auto") {
|
|
141
|
+
mode = await detectMode(this.ollama, model, this.ollamaTools[0]);
|
|
142
|
+
}
|
|
143
|
+
else {
|
|
144
|
+
mode = modeArg;
|
|
145
|
+
}
|
|
146
|
+
const defaultSystem = mode === "prompt"
|
|
147
|
+
? buildPromptSystemPrompt(this.tools)
|
|
148
|
+
: NATIVE_SYSTEM;
|
|
149
|
+
const messages = [{
|
|
150
|
+
role: "system",
|
|
151
|
+
content: options.systemPrompt ?? defaultSystem
|
|
152
|
+
}];
|
|
153
|
+
messages.push({ role: "user", content: userMessage });
|
|
154
|
+
const toolsUsed = [];
|
|
155
|
+
// Agentic loop (máx. 10 iteraciones para evitar loops infinitos)
|
|
156
|
+
for (let i = 0; i < 10; i++) {
|
|
157
|
+
const response = await this.ollama.chat({
|
|
158
|
+
model,
|
|
159
|
+
messages,
|
|
160
|
+
...(mode === "native" ? { tools: this.ollamaTools } : {}),
|
|
161
|
+
stream: false
|
|
162
|
+
});
|
|
163
|
+
const assistantMessage = response.message;
|
|
164
|
+
messages.push(assistantMessage);
|
|
165
|
+
// Extraer tool calls
|
|
166
|
+
let calls = [];
|
|
167
|
+
if (mode === "native" && assistantMessage.tool_calls?.length) {
|
|
168
|
+
calls = assistantMessage.tool_calls.map(tc => ({
|
|
169
|
+
name: tc.function.name,
|
|
170
|
+
arguments: (tc.function.arguments ?? {})
|
|
171
|
+
}));
|
|
172
|
+
}
|
|
173
|
+
else if (mode === "prompt" && assistantMessage.content) {
|
|
174
|
+
calls = parseTextToolCalls(assistantMessage.content);
|
|
175
|
+
}
|
|
176
|
+
// Sin tool calls → respuesta final
|
|
177
|
+
if (calls.length === 0) {
|
|
178
|
+
const answer = (assistantMessage.content ?? "")
|
|
179
|
+
.replace(/<tool_call>[\s\S]*?<\/tool_call>/g, "")
|
|
180
|
+
.trim();
|
|
181
|
+
return { answer, tools_used: toolsUsed, model, mode };
|
|
182
|
+
}
|
|
183
|
+
// Ejecutar cada herramienta
|
|
184
|
+
for (const call of calls) {
|
|
185
|
+
const toolDef = this.tools.find(t => t.name === call.name);
|
|
186
|
+
let resultText;
|
|
187
|
+
if (!toolDef) {
|
|
188
|
+
resultText = `Error: herramienta '${call.name}' no existe.`;
|
|
189
|
+
}
|
|
190
|
+
else {
|
|
191
|
+
try {
|
|
192
|
+
const result = await toolDef.handler(this.client, call.arguments);
|
|
193
|
+
const blocks = result?.content ?? [];
|
|
194
|
+
resultText = blocks
|
|
195
|
+
.filter((b) => b.type === "text" && b.text)
|
|
196
|
+
.map((b) => b.text)
|
|
197
|
+
.join("\n");
|
|
198
|
+
toolsUsed.push(call.name);
|
|
199
|
+
}
|
|
200
|
+
catch (err) {
|
|
201
|
+
resultText = `Error: ${err instanceof Error ? err.message : String(err)}`;
|
|
202
|
+
}
|
|
203
|
+
}
|
|
204
|
+
messages.push({
|
|
205
|
+
role: mode === "native" ? "tool" : "user",
|
|
206
|
+
content: mode === "native"
|
|
207
|
+
? `[${call.name}]\n${resultText}`
|
|
208
|
+
: `Resultado de ${call.name}:\n${resultText}`
|
|
209
|
+
});
|
|
210
|
+
}
|
|
211
|
+
}
|
|
212
|
+
throw new Error("El agente excedió el límite de iteraciones (10).");
|
|
213
|
+
}
|
|
214
|
+
}
|
|
@@ -1,15 +1,17 @@
|
|
|
1
1
|
import axios from 'axios';
|
|
2
2
|
export class CanvasClient {
|
|
3
3
|
client;
|
|
4
|
+
quizClient;
|
|
4
5
|
token;
|
|
5
6
|
domain;
|
|
6
7
|
constructor(token, domain) {
|
|
7
8
|
this.token = token;
|
|
8
9
|
this.domain = domain;
|
|
9
10
|
this.client = this.createAxiosInstance(token, domain);
|
|
11
|
+
this.quizClient = this.createAxiosInstance(token, domain, 'api/quiz/v1');
|
|
10
12
|
}
|
|
11
|
-
createAxiosInstance(token, domain) {
|
|
12
|
-
const baseURL = `https://${domain}
|
|
13
|
+
createAxiosInstance(token, domain, apiPath = 'api/v1') {
|
|
14
|
+
const baseURL = `https://${domain}/${apiPath}`;
|
|
13
15
|
return axios.create({
|
|
14
16
|
baseURL,
|
|
15
17
|
headers: {
|
|
@@ -22,6 +24,7 @@ export class CanvasClient {
|
|
|
22
24
|
this.token = token;
|
|
23
25
|
this.domain = domain;
|
|
24
26
|
this.client = this.createAxiosInstance(token, domain);
|
|
27
|
+
this.quizClient = this.createAxiosInstance(token, domain, 'api/quiz/v1');
|
|
25
28
|
}
|
|
26
29
|
parseLinkHeader(header) {
|
|
27
30
|
if (!header)
|
|
@@ -189,9 +192,6 @@ export class CanvasClient {
|
|
|
189
192
|
await this.client.delete(url);
|
|
190
193
|
return { deleted: true, comment_id: commentId };
|
|
191
194
|
}
|
|
192
|
-
async getSubmissionComments(courseId, assignmentId, userId) {
|
|
193
|
-
return this.getSingleSubmission(courseId, assignmentId, userId);
|
|
194
|
-
}
|
|
195
195
|
async getDiscussionTopics(courseId) {
|
|
196
196
|
return this.getAllPages(`courses/${courseId}/discussion_topics`);
|
|
197
197
|
}
|
|
@@ -213,6 +213,11 @@ export class CanvasClient {
|
|
|
213
213
|
});
|
|
214
214
|
return response.data;
|
|
215
215
|
}
|
|
216
|
+
async updateAnnouncement(courseId, topicId, fields) {
|
|
217
|
+
const url = `courses/${courseId}/discussion_topics/${topicId}`;
|
|
218
|
+
const response = await this.client.put(url, fields);
|
|
219
|
+
return response.data;
|
|
220
|
+
}
|
|
216
221
|
// --- Quiz Questions ---
|
|
217
222
|
async listQuizQuestions(courseId, quizId) {
|
|
218
223
|
return this.getAllPages(`courses/${courseId}/quizzes/${quizId}/questions`, {
|
|
@@ -378,6 +383,15 @@ export class CanvasClient {
|
|
|
378
383
|
return { deleted: true };
|
|
379
384
|
}
|
|
380
385
|
// --- Rubrics ---
|
|
386
|
+
async getRubrics(courseId) {
|
|
387
|
+
return this.getAllPages(`courses/${courseId}/rubrics`);
|
|
388
|
+
}
|
|
389
|
+
async getRubric(courseId, rubricId, include) {
|
|
390
|
+
const response = await this.client.get(`courses/${courseId}/rubrics/${rubricId}`, {
|
|
391
|
+
params: { include }
|
|
392
|
+
});
|
|
393
|
+
return response.data;
|
|
394
|
+
}
|
|
381
395
|
async createRubric(courseId, rubricData, rubricAssociationData) {
|
|
382
396
|
const payload = { rubric: rubricData };
|
|
383
397
|
if (rubricAssociationData) {
|
|
@@ -386,6 +400,14 @@ export class CanvasClient {
|
|
|
386
400
|
const response = await this.client.post(`courses/${courseId}/rubrics`, payload);
|
|
387
401
|
return response.data;
|
|
388
402
|
}
|
|
403
|
+
async updateRubric(courseId, rubricId, rubricData, rubricAssociationData) {
|
|
404
|
+
const payload = { rubric: rubricData };
|
|
405
|
+
if (rubricAssociationData) {
|
|
406
|
+
payload.rubric_association = rubricAssociationData;
|
|
407
|
+
}
|
|
408
|
+
const response = await this.client.put(`courses/${courseId}/rubrics/${rubricId}`, payload);
|
|
409
|
+
return response.data;
|
|
410
|
+
}
|
|
389
411
|
async createRubricAssociation(courseId, associationData) {
|
|
390
412
|
const response = await this.client.post(`courses/${courseId}/rubric_associations`, {
|
|
391
413
|
rubric_association: associationData
|
|
@@ -456,4 +478,225 @@ export class CanvasClient {
|
|
|
456
478
|
const response = await this.client.post(`groups/${groupId}/memberships`, { user_id: userId });
|
|
457
479
|
return response.data;
|
|
458
480
|
}
|
|
481
|
+
// --- Course Management ---
|
|
482
|
+
async createCourse(accountId, data) {
|
|
483
|
+
const response = await this.client.post(`accounts/${accountId}/courses`, { course: data });
|
|
484
|
+
return response.data;
|
|
485
|
+
}
|
|
486
|
+
async updateCourse(courseId, data) {
|
|
487
|
+
const response = await this.client.put(`courses/${courseId}`, { course: data });
|
|
488
|
+
return response.data;
|
|
489
|
+
}
|
|
490
|
+
async getSyllabus(courseId) {
|
|
491
|
+
const response = await this.client.get(`courses/${courseId}`, { params: { include: ['syllabus_body'] } });
|
|
492
|
+
return response.data;
|
|
493
|
+
}
|
|
494
|
+
async healthCheck() {
|
|
495
|
+
const response = await this.client.get('users/self/profile');
|
|
496
|
+
return { status: 'ok', user: response.data.name, domain: this.domain };
|
|
497
|
+
}
|
|
498
|
+
// --- Assignment ---
|
|
499
|
+
async deleteAssignment(courseId, assignmentId) {
|
|
500
|
+
await this.client.delete(`courses/${courseId}/assignments/${assignmentId}`);
|
|
501
|
+
return { deleted: true };
|
|
502
|
+
}
|
|
503
|
+
// --- Pages ---
|
|
504
|
+
async deletePage(courseId, pageUrlOrId) {
|
|
505
|
+
await this.client.delete(`courses/${courseId}/pages/${pageUrlOrId}`);
|
|
506
|
+
return { deleted: true };
|
|
507
|
+
}
|
|
508
|
+
// --- Discussions ---
|
|
509
|
+
async createDiscussion(courseId, data) {
|
|
510
|
+
const response = await this.client.post(`courses/${courseId}/discussion_topics`, data);
|
|
511
|
+
return response.data;
|
|
512
|
+
}
|
|
513
|
+
async deleteDiscussion(courseId, topicId) {
|
|
514
|
+
await this.client.delete(`courses/${courseId}/discussion_topics/${topicId}`);
|
|
515
|
+
return { deleted: true };
|
|
516
|
+
}
|
|
517
|
+
// --- Users & Enrollments ---
|
|
518
|
+
async getProfile() {
|
|
519
|
+
const response = await this.client.get('users/self/profile');
|
|
520
|
+
return response.data;
|
|
521
|
+
}
|
|
522
|
+
async getUser(userId) {
|
|
523
|
+
const response = await this.client.get(`users/${userId}`);
|
|
524
|
+
return response.data;
|
|
525
|
+
}
|
|
526
|
+
async searchUsers(accountId, searchTerm, enrollmentType) {
|
|
527
|
+
return this.getAllPages(`accounts/${accountId}/users`, {
|
|
528
|
+
search_term: searchTerm,
|
|
529
|
+
enrollment_type: enrollmentType,
|
|
530
|
+
per_page: 50
|
|
531
|
+
});
|
|
532
|
+
}
|
|
533
|
+
async listCourseEnrollments(courseId, type) {
|
|
534
|
+
return this.getAllPages(`courses/${courseId}/enrollments`, {
|
|
535
|
+
type,
|
|
536
|
+
include: ['user'],
|
|
537
|
+
per_page: 100
|
|
538
|
+
});
|
|
539
|
+
}
|
|
540
|
+
async enrollUser(courseId, userId, enrollmentType, notify) {
|
|
541
|
+
const response = await this.client.post(`courses/${courseId}/enrollments`, {
|
|
542
|
+
enrollment: {
|
|
543
|
+
user_id: userId,
|
|
544
|
+
type: enrollmentType,
|
|
545
|
+
enrollment_state: 'active',
|
|
546
|
+
notify: notify ?? false
|
|
547
|
+
}
|
|
548
|
+
});
|
|
549
|
+
return response.data;
|
|
550
|
+
}
|
|
551
|
+
async removeEnrollment(courseId, enrollmentId, task = 'conclude') {
|
|
552
|
+
const response = await this.client.delete(`courses/${courseId}/enrollments/${enrollmentId}`, { params: { task } });
|
|
553
|
+
return response.data;
|
|
554
|
+
}
|
|
555
|
+
// --- Student Submissions ---
|
|
556
|
+
async submitAssignmentFile(courseId, assignmentId, filePath, fileName, contentType) {
|
|
557
|
+
// Step 1: Pre-flight to get submission upload URL
|
|
558
|
+
const preflightResponse = await this.client.post(`courses/${courseId}/assignments/${assignmentId}/submissions/self/files`, { name: fileName, content_type: contentType });
|
|
559
|
+
const { upload_url, upload_params } = preflightResponse.data;
|
|
560
|
+
// Step 2: Upload file to storage
|
|
561
|
+
const fs = await import('node:fs');
|
|
562
|
+
const FormData = (await import('form-data')).default;
|
|
563
|
+
const form = new FormData();
|
|
564
|
+
Object.entries(upload_params).forEach(([key, value]) => {
|
|
565
|
+
form.append(key, value);
|
|
566
|
+
});
|
|
567
|
+
form.append('file', fs.createReadStream(filePath));
|
|
568
|
+
const uploadResponse = await axios.post(upload_url, form, {
|
|
569
|
+
headers: form.getHeaders(),
|
|
570
|
+
maxRedirects: 0,
|
|
571
|
+
validateStatus: (s) => s < 400
|
|
572
|
+
});
|
|
573
|
+
// Step 3: Confirm upload and get file ID
|
|
574
|
+
let fileId;
|
|
575
|
+
const location = uploadResponse.headers['location'] || uploadResponse.data?.location;
|
|
576
|
+
if (location) {
|
|
577
|
+
const confirmResponse = await this.client.get(location);
|
|
578
|
+
fileId = confirmResponse.data.id;
|
|
579
|
+
}
|
|
580
|
+
else {
|
|
581
|
+
fileId = uploadResponse.data.id;
|
|
582
|
+
}
|
|
583
|
+
// Step 4: Submit the assignment with the uploaded file
|
|
584
|
+
const submissionResponse = await this.client.post(`courses/${courseId}/assignments/${assignmentId}/submissions`, {
|
|
585
|
+
submission: {
|
|
586
|
+
submission_type: 'online_upload',
|
|
587
|
+
file_ids: [fileId]
|
|
588
|
+
}
|
|
589
|
+
});
|
|
590
|
+
return submissionResponse.data;
|
|
591
|
+
}
|
|
592
|
+
// --- Conversations ---
|
|
593
|
+
async listConversations(scope, perPage = 50) {
|
|
594
|
+
return this.getAllPages('conversations', {
|
|
595
|
+
scope: scope ?? 'inbox',
|
|
596
|
+
per_page: perPage
|
|
597
|
+
});
|
|
598
|
+
}
|
|
599
|
+
async getConversation(conversationId) {
|
|
600
|
+
const response = await this.client.get(`conversations/${conversationId}`);
|
|
601
|
+
return response.data;
|
|
602
|
+
}
|
|
603
|
+
async getConversationUnreadCount() {
|
|
604
|
+
const response = await this.client.get('conversations/unread_count');
|
|
605
|
+
return response.data;
|
|
606
|
+
}
|
|
607
|
+
async sendConversation(data) {
|
|
608
|
+
const payload = {
|
|
609
|
+
recipients: data.recipients,
|
|
610
|
+
subject: data.subject,
|
|
611
|
+
body: data.body,
|
|
612
|
+
group_conversation: data.group_conversation ?? false,
|
|
613
|
+
bulk_message: data.bulk_message ?? false
|
|
614
|
+
};
|
|
615
|
+
if (data.course_id) {
|
|
616
|
+
payload.context_code = `course_${data.course_id}`;
|
|
617
|
+
}
|
|
618
|
+
const response = await this.client.post('conversations', payload);
|
|
619
|
+
return response.data;
|
|
620
|
+
}
|
|
621
|
+
async replyToConversation(conversationId, body) {
|
|
622
|
+
const response = await this.client.post(`conversations/${conversationId}/add_message`, { body });
|
|
623
|
+
return response.data;
|
|
624
|
+
}
|
|
625
|
+
// --- Analytics ---
|
|
626
|
+
async getCourseAnalytics(courseId) {
|
|
627
|
+
const response = await this.client.get(`courses/${courseId}/analytics/activity`);
|
|
628
|
+
return response.data;
|
|
629
|
+
}
|
|
630
|
+
async getStudentAnalytics(courseId, studentId) {
|
|
631
|
+
const response = await this.client.get(`courses/${courseId}/analytics/users/${studentId}/activity`);
|
|
632
|
+
return response.data;
|
|
633
|
+
}
|
|
634
|
+
async getCourseActivityStream(courseId) {
|
|
635
|
+
const response = await this.client.get(`courses/${courseId}/activity_stream/summary`);
|
|
636
|
+
return response.data;
|
|
637
|
+
}
|
|
638
|
+
async searchCourseContent(courseId, query, contentTypes) {
|
|
639
|
+
const types = contentTypes && contentTypes.length > 0 ? contentTypes : ['assignments', 'pages', 'discussion_topics', 'quizzes', 'files'];
|
|
640
|
+
const results = [];
|
|
641
|
+
for (const type of types) {
|
|
642
|
+
try {
|
|
643
|
+
const items = await this.getAllPages(`courses/${courseId}/${type}`, { per_page: 50 });
|
|
644
|
+
const q = query.toLowerCase();
|
|
645
|
+
const matched = items.filter((i) => (i.name || i.title || '').toLowerCase().includes(q)).map((i) => ({ ...i, _content_type: type }));
|
|
646
|
+
results.push(...matched);
|
|
647
|
+
}
|
|
648
|
+
catch {
|
|
649
|
+
// skip types the user doesn't have access to
|
|
650
|
+
}
|
|
651
|
+
}
|
|
652
|
+
return results;
|
|
653
|
+
}
|
|
654
|
+
// --- Peer Reviews ---
|
|
655
|
+
async listPeerReviews(courseId, assignmentId) {
|
|
656
|
+
return this.getAllPages(`courses/${courseId}/assignments/${assignmentId}/peer_reviews`, { include: ['submission_comments', 'user'], per_page: 100 });
|
|
657
|
+
}
|
|
658
|
+
async getSubmissionPeerReviews(courseId, assignmentId, submissionId) {
|
|
659
|
+
return this.getAllPages(`courses/${courseId}/assignments/${assignmentId}/submissions/${submissionId}/peer_reviews`, { include: ['submission_comments', 'user'], per_page: 100 });
|
|
660
|
+
}
|
|
661
|
+
async createPeerReview(courseId, assignmentId, submissionId, revieweeId) {
|
|
662
|
+
const response = await this.client.post(`courses/${courseId}/assignments/${assignmentId}/submissions/${submissionId}/peer_reviews`, { user_id: revieweeId });
|
|
663
|
+
return response.data;
|
|
664
|
+
}
|
|
665
|
+
async deletePeerReview(courseId, assignmentId, submissionId, revieweeId) {
|
|
666
|
+
await this.client.delete(`courses/${courseId}/assignments/${assignmentId}/submissions/${submissionId}/peer_reviews`, { params: { user_id: revieweeId } });
|
|
667
|
+
return { deleted: true };
|
|
668
|
+
}
|
|
669
|
+
// --- New Quizzes (LTI) ---
|
|
670
|
+
async createNewQuiz(courseId, data) {
|
|
671
|
+
const response = await this.quizClient.post(`courses/${courseId}/quizzes`, data);
|
|
672
|
+
return response.data;
|
|
673
|
+
}
|
|
674
|
+
async updateNewQuiz(courseId, quizId, data) {
|
|
675
|
+
const response = await this.quizClient.patch(`courses/${courseId}/quizzes/${quizId}`, data);
|
|
676
|
+
return response.data;
|
|
677
|
+
}
|
|
678
|
+
async deleteNewQuiz(courseId, quizId) {
|
|
679
|
+
await this.quizClient.delete(`courses/${courseId}/quizzes/${quizId}`);
|
|
680
|
+
return { deleted: true };
|
|
681
|
+
}
|
|
682
|
+
async listNewQuizItems(courseId, quizId) {
|
|
683
|
+
const response = await this.quizClient.get(`courses/${courseId}/quizzes/${quizId}/items`);
|
|
684
|
+
return response.data.items ?? response.data ?? [];
|
|
685
|
+
}
|
|
686
|
+
async getNewQuizItem(courseId, quizId, itemId) {
|
|
687
|
+
const response = await this.quizClient.get(`courses/${courseId}/quizzes/${quizId}/items/${itemId}`);
|
|
688
|
+
return response.data;
|
|
689
|
+
}
|
|
690
|
+
async createNewQuizItem(courseId, quizId, data) {
|
|
691
|
+
const response = await this.quizClient.post(`courses/${courseId}/quizzes/${quizId}/items`, data);
|
|
692
|
+
return response.data;
|
|
693
|
+
}
|
|
694
|
+
async updateNewQuizItem(courseId, quizId, itemId, data) {
|
|
695
|
+
const response = await this.quizClient.patch(`courses/${courseId}/quizzes/${quizId}/items/${itemId}`, data);
|
|
696
|
+
return response.data;
|
|
697
|
+
}
|
|
698
|
+
async deleteNewQuizItem(courseId, quizId, itemId) {
|
|
699
|
+
await this.quizClient.delete(`courses/${courseId}/quizzes/${quizId}/items/${itemId}`);
|
|
700
|
+
return { deleted: true };
|
|
701
|
+
}
|
|
459
702
|
}
|