campus-cli
Advanced tools
+13
-0
@@ -7,2 +7,15 @@ # Changelog | ||
| ## [1.1.0] — 2026-07-21 | ||
| ### Removed | ||
| - **Breaking**: se eliminaron las tools `blackboard_get_quiz_questions`, `blackboard_save_quiz_answer` y `blackboard_submit_quiz` (auto-resolver y enviar quizzes/evaluaciones). Navegar y descargar archivos adjuntos dentro de un quiz sigue funcionando igual vía `blackboard_list_contents`/`blackboard_list_attachments`. | ||
| ### Added | ||
| - `blackboard_upload_attempt_file` — sube un archivo local (imagen, PDF, etc.) a Blackboard y devuelve un `fileUploadId` para adjuntarlo a una entrega. | ||
| - `blackboard_save_attempt_draft` — guarda texto y/o archivos en una entrega de tarea SIN enviarla; el intento queda abierto para seguir editando. No requiere confirmación (a diferencia de `blackboard_submit_attempt`). | ||
| - `blackboard_submit_attempt` ahora también acepta `fileUploadIds` para adjuntar archivos al enviar. | ||
| ### Notes | ||
| - Estas tools operan sobre columnas de calificación tipo entrega de archivo/texto/link. En Blackboard Ultra, tareas y quizzes comparten el mismo tipo de contenido (`resource/x-bb-asmt-test-link`); si se usan contra una evaluación con preguntas interactivas, Blackboard responde `400` ("Attempts cannot be created for assessments with non-presentation-only questions"). Un `403 bb-rest-attempt-past-due-exception` es el comportamiento normal una vez vencida la fecha de entrega. | ||
| ## [1.0.2] — 2026-07-15 | ||
@@ -9,0 +22,0 @@ |
+4
-21
@@ -27,19 +27,2 @@ # campus-cli — Agent Guide | ||
| ### Quiz workflow | ||
| ``` | ||
| 1. blackboard_list_contents <courseId> → find the quiz contentId | ||
| 2. blackboard_get_quiz_questions <url|ids> → load questions + options + attempt policy | ||
| 3. blackboard_save_quiz_answer (per question) → save each answer individually | ||
| 4. blackboard_submit_quiz (confirm first!) → finalize and submit the attempt | ||
| ``` | ||
| Supported question types in `blackboard_save_quiz_answer`: | ||
| | `question.type` | `answer` format | | ||
| |-------------------|--------------------------------------------------------------------------------| | ||
| | `eitherOr` | boolean (`true` = Verdadero, `false` = Falso) | | ||
| | `multipleanswer` | number — 0-based index of the chosen option | | ||
| | `fimb` | JSON string `'{"BLANK-1":"value1","BLANK-2":"value2"}'` — read names from `question.blanks` | | ||
| ### Feedback workflow | ||
@@ -54,3 +37,4 @@ | ||
| - **Always confirm before submitting** (`blackboard_submit_attempt`, `blackboard_submit_quiz`). Show the user what will be submitted and ask for confirmation. Never submit silently. | ||
| - **Always confirm before submitting** (`blackboard_submit_attempt`). Show the user what will be submitted and ask for confirmation. Never submit silently. `blackboard_save_attempt_draft` (saving progress without sending) does not need this confirmation — only the final submit does. | ||
| - **`blackboard_save_attempt_draft`/`blackboard_submit_attempt` only work on file/text/link-submission columns** — not on quiz-style columns with interactive questions (both look identical from `blackboard_list_assignments`, since Ultra treats tests and assignments as the same `resource/x-bb-asmt-test-link` content type). If Blackboard returns `400` with a message like "Attempts cannot be created for assessments with non-presentation-only questions", that column is actually a quiz/test — tell the user, don't retry. A `403 bb-rest-attempt-past-due-exception` is expected/normal once the due date has passed and late attempts aren't allowed — same as the web UI would show, not a bug. | ||
| - **Show grades in context** — when showing grades, also show the assignment name, max score, and due date if available. | ||
@@ -96,8 +80,7 @@ - **Navigate content recursively** — if the user asks for materials, explore subfolders using `blackboard_list_contents` with `parentId`. | ||
| | `blackboard_download_file_url` | Download a bbcswebdav URL directly | | ||
| | `blackboard_upload_attempt_file` | Upload a local file (image, PDF, etc.) and get a fileUploadId | | ||
| | `blackboard_save_attempt_draft` | Save text/files to an attempt WITHOUT submitting (stays open for editing) | | ||
| | `blackboard_submit_attempt` | Submit assignment (confirm first!) | | ||
| | `blackboard_get_assignment_feedback` | Scores + instructor comments + feedback files for all assignments in a course | | ||
| | `blackboard_download_feedback_file` | **[EXPERIMENTAL]** Download a file the professor attached to a graded attempt | | ||
| | `blackboard_get_quiz_questions` | Load quiz questions + options from an attempt (URL or IDs) | | ||
| | `blackboard_save_quiz_answer` | Save one answer without submitting | | ||
| | `blackboard_submit_quiz` | Finalize and submit a quiz attempt (confirm first!) | | ||
| | `blackboard_raw_api` | Any other Blackboard endpoint | |
+1
-1
| { | ||
| "name": "campus-cli", | ||
| "version": "1.0.2", | ||
| "version": "1.1.0", | ||
| "description": "CLI/MCP no oficial para el campus universitario (Blackboard, Canvas, Moodle...) — acceso desde la terminal y MCP para IA", | ||
@@ -5,0 +5,0 @@ "main": "run.js", |
+3
-4
@@ -298,6 +298,5 @@ # campus-cli | ||
| | `blackboard_download_attachment` | Descargar archivo | | ||
| | `blackboard_upload_attempt_file` | Subir un archivo local (imagen, PDF, etc.) y obtener un fileUploadId | | ||
| | `blackboard_save_attempt_draft` | Guardar texto/archivos en un intento SIN enviarlo (queda abierto para seguir editando) | | ||
| | `blackboard_submit_attempt` | Entregar tarea; confirma antes de enviar | | ||
| | `blackboard_get_quiz_questions` | Cargar preguntas de un quiz | | ||
| | `blackboard_save_quiz_answer` | Guardar una respuesta sin enviar | | ||
| | `blackboard_submit_quiz` | Finalizar y enviar un quiz; confirma antes de enviar | | ||
| | `blackboard_get_assignment_feedback` | Comentarios y feedback del profesor | | ||
@@ -321,3 +320,3 @@ | `blackboard_raw_api` | Cualquier endpoint de Blackboard | | ||
| - Tarea 1 de Algoritmos, vence el 15/04. | ||
| - Quiz de Bases de Datos, vence el 18/04. | ||
| - Lectura de Bases de Datos, vence el 18/04. | ||
| ``` | ||
@@ -324,0 +323,0 @@ |
@@ -15,5 +15,12 @@ import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'; | ||
| blackboard_get_grades → blackboard_list_contents para materiales. | ||
| Para entregas: blackboard_upload_attempt_file sube cada archivo/imagen y | ||
| devuelve un fileUploadId; blackboard_save_attempt_draft guarda texto y/o | ||
| esos fileUploadIds sin enviar (el intento queda abierto para seguir | ||
| editando, no requiere confirmación); blackboard_submit_attempt finaliza | ||
| y envía — siempre confirma con el usuario qué se va a enviar antes de | ||
| llamarla. | ||
| blackboard_raw_api cubre cualquier endpoint no expuesto por una tool | ||
| dedicada. Antes de blackboard_submit_attempt o blackboard_submit_quiz, | ||
| siempre confirma con el usuario qué se va a enviar. | ||
| dedicada. | ||
@@ -20,0 +27,0 @@ Futuro: canvas_* y moodle_* para otras universidades (Canvas, Moodle) — |
@@ -18,11 +18,5 @@ import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'; | ||
| import { listAssignments, listAttempts, submitAttempt, uploadFile, getAttemptFiles } from './api/assignments.js'; | ||
| import { | ||
| getQuizQuestions, | ||
| saveQuizAnswer, | ||
| submitQuizAttempt, | ||
| getQuizColumnId, | ||
| parseQuizUrl, | ||
| type QuizQuestion, | ||
| } from './api/quiz.js'; | ||
| const MAX_UPLOAD_BYTES = 50 * 1024 * 1024; // 50MB | ||
| async function getClient() { | ||
@@ -301,87 +295,29 @@ const session = await loadOrRefreshSession(); | ||
| // ── blackboard_submit_attempt ────────────────────────────────────────────────────────── | ||
| // ── blackboard_upload_attempt_file ───────────────────────────────────────────────────── | ||
| server.registerTool( | ||
| 'blackboard_submit_attempt', | ||
| 'blackboard_upload_attempt_file', | ||
| { | ||
| description: 'Submit an assignment attempt. ALWAYS confirm with the user before submitting.', | ||
| description: | ||
| 'Upload a local file (image, PDF, doc, etc.) to Blackboard and get back a fileUploadId. ' + | ||
| 'This only uploads the file — it does NOT attach it to an attempt yet. ' + | ||
| 'Pass the returned fileUploadId(s) into blackboard_save_attempt_draft or blackboard_submit_attempt via fileUploadIds.', | ||
| inputSchema: { | ||
| courseId: z.string().describe('Blackboard course ID'), | ||
| columnId: z.string().describe('Assignment (gradebook column) ID'), | ||
| studentComments: z.string().optional().describe('Comment to the instructor'), | ||
| studentSubmission: z.string().optional().describe('Text body of the submission'), | ||
| filePath: z.string().describe('Absolute path to the local file to upload'), | ||
| }, | ||
| }, | ||
| async ({ courseId, columnId, studentComments, studentSubmission }) => { | ||
| async ({ filePath }) => { | ||
| const { client } = await getClient(); | ||
| const attempt = await submitAttempt(client, courseId, columnId, { | ||
| studentComments, | ||
| studentSubmission, | ||
| status: 'NeedsGrading', | ||
| }); | ||
| return { content: [{ type: 'text', text: JSON.stringify(attempt, null, 2) }] }; | ||
| } | ||
| ); | ||
| // ── blackboard_get_quiz_questions ────────────────────────────────────────────────────── | ||
| server.registerTool( | ||
| 'blackboard_get_quiz_questions', | ||
| { | ||
| description: | ||
| 'Fetch all questions and answer options from a Blackboard Ultra quiz attempt. ' + | ||
| 'Provide either a full quiz URL, or courseId + contentId + attemptId. ' + | ||
| 'Returns each question with its type, text, options, and current saved answer.', | ||
| inputSchema: { | ||
| url: z | ||
| .string() | ||
| .optional() | ||
| .describe( | ||
| 'Full Ultra quiz URL, e.g. https://aulavirtual.upc.edu.pe/ultra/stream/assessment/_69146765_1/overview/attempt/_94898825_1?courseId=_529533_1' | ||
| ), | ||
| courseId: z.string().optional().describe('Course ID (e.g. _529533_1) — required if url not given'), | ||
| contentId: z.string().optional().describe('Quiz content item ID (e.g. _69146765_1) — required if url not given'), | ||
| attemptId: z.string().optional().describe('Attempt ID (e.g. _94898825_1) — required if url not given'), | ||
| }, | ||
| }, | ||
| async ({ url, courseId, contentId, attemptId }) => { | ||
| const { client, session } = await getClient(); | ||
| // Resolve IDs from URL or direct params | ||
| let resolvedCourseId = courseId; | ||
| let resolvedContentId = contentId; | ||
| let resolvedAttemptId = attemptId; | ||
| if (url) { | ||
| const parsed = parseQuizUrl(url); | ||
| resolvedCourseId = resolvedCourseId || parsed.courseId; | ||
| resolvedContentId = resolvedContentId || parsed.contentId; | ||
| resolvedAttemptId = resolvedAttemptId || parsed.attemptId; | ||
| const resolved = path.resolve(filePath); | ||
| if (!fs.existsSync(resolved)) { | ||
| throw new Error(`File not found: ${resolved}`); | ||
| } | ||
| if (!resolvedCourseId || !resolvedContentId || !resolvedAttemptId) { | ||
| throw new Error( | ||
| 'Provide either a full quiz URL or all three of: courseId, contentId, attemptId' | ||
| ); | ||
| const { size } = fs.statSync(resolved); | ||
| if (size > MAX_UPLOAD_BYTES) { | ||
| throw new Error(`File too large (${size} bytes). Max is ${MAX_UPLOAD_BYTES} bytes.`); | ||
| } | ||
| // Get columnId + attempt policy — reuse session already obtained above | ||
| const policy = await getQuizColumnId(client, resolvedCourseId, resolvedContentId, session.userId); | ||
| if (!policy.canAttempt) { | ||
| return { | ||
| content: [{ | ||
| type: 'text', | ||
| text: JSON.stringify({ | ||
| error: 'NO_ATTEMPTS_LEFT', | ||
| message: `No quedan intentos para este cuestionario. ${policy.attemptSummary}`, | ||
| policy, | ||
| }, null, 2), | ||
| }], | ||
| }; | ||
| } | ||
| const info = await getQuizQuestions(client, resolvedCourseId, policy.columnId, resolvedAttemptId); | ||
| const fileUploadId = await uploadFile(client, resolved); | ||
| return { | ||
| content: [{ | ||
| type: 'text', | ||
| text: JSON.stringify({ attemptPolicy: policy, ...info }, null, 2), | ||
| text: JSON.stringify({ fileUploadId, fileName: path.basename(resolved), size }, null, 2), | ||
| }], | ||
@@ -392,68 +328,61 @@ }; | ||
| // ── blackboard_save_quiz_answer ──────────────────────────────────────────────────────── | ||
| // ── blackboard_save_attempt_draft ────────────────────────────────────────────────────── | ||
| server.registerTool( | ||
| 'blackboard_save_quiz_answer', | ||
| 'blackboard_save_attempt_draft', | ||
| { | ||
| description: | ||
| 'Save a single answer for a quiz question (does NOT submit — use blackboard_submit_quiz to finalize). ' + | ||
| 'question is the full question object from blackboard_get_quiz_questions. ' + | ||
| 'answer format depends on question.type:\n' + | ||
| ' - eitherOr (true/false): boolean (true = Verdadero)\n' + | ||
| ' - multipleanswer (MC): number (0-based index of the chosen option)\n' + | ||
| ' - fimb (fill-in-multi-blanks): JSON string of an object mapping blank names to values, ' + | ||
| 'e.g. \'{"BLANK-1":"1438.62","BLANK-2":"140.62"}\' (read blank names from question.blanks)', | ||
| 'Save progress on an assignment attempt WITHOUT submitting it — text, attached files, or both. ' + | ||
| 'The attempt stays open (status InProgress) so the student can keep editing it later. ' + | ||
| 'This does NOT send it to the instructor for grading — use blackboard_submit_attempt for that, ' + | ||
| 'and always confirm with the user before calling that one.', | ||
| inputSchema: { | ||
| courseId: z.string().describe('Course ID'), | ||
| attemptId: z.string().describe('Quiz attempt ID (e.g. _94898825_1)'), | ||
| question: z.string().describe('JSON string of the question object from blackboard_get_quiz_questions'), | ||
| answer: z.union([z.boolean(), z.number(), z.string()]).describe( | ||
| 'eitherOr: true/false. multipleanswer: 0-based index. ' + | ||
| 'fimb: JSON string of {blankName: value} (e.g. \'{"BLANK-1":"1438.62"}\').' | ||
| courseId: z.string().describe('Blackboard course ID'), | ||
| columnId: z.string().describe('Assignment (gradebook column) ID'), | ||
| studentComments: z.string().optional().describe('Comment to the instructor'), | ||
| studentSubmission: z.string().optional().describe('Text body of the submission'), | ||
| fileUploadIds: z.array(z.string()).optional().describe( | ||
| 'fileUploadId(s) from blackboard_upload_attempt_file to attach to this draft' | ||
| ), | ||
| }, | ||
| }, | ||
| async ({ courseId, attemptId, question: questionJson, answer }) => { | ||
| async ({ courseId, columnId, studentComments, studentSubmission, fileUploadIds }) => { | ||
| const { client } = await getClient(); | ||
| const question: QuizQuestion = JSON.parse(questionJson); | ||
| // For fimb, the MCP transport gives us a JSON string — parse it into the Record<string, string> | ||
| // saveQuizAnswer expects. boolean / number pass through as-is. | ||
| let parsedAnswer: boolean | number | Record<string, string>; | ||
| if (typeof answer === 'string') { | ||
| try { | ||
| const obj = JSON.parse(answer); | ||
| if (obj === null || typeof obj !== 'object' || Array.isArray(obj)) { | ||
| throw new Error('fimb answer string must parse to a JSON object'); | ||
| } | ||
| parsedAnswer = obj as Record<string, string>; | ||
| } catch (e: any) { | ||
| throw new Error( | ||
| `Invalid fimb answer: expected a JSON object string like '{"BLANK-1":"value"}'. ${e.message}` | ||
| ); | ||
| } | ||
| } else { | ||
| parsedAnswer = answer; | ||
| } | ||
| const result = await saveQuizAnswer(client, courseId, attemptId, question, parsedAnswer); | ||
| return { content: [{ type: 'text', text: JSON.stringify(result, null, 2) }] }; | ||
| const attempt = await submitAttempt(client, courseId, columnId, { | ||
| studentComments, | ||
| studentSubmission, | ||
| fileUploadIds, | ||
| status: 'InProgress', | ||
| }); | ||
| return { content: [{ type: 'text', text: JSON.stringify(attempt, null, 2) }] }; | ||
| } | ||
| ); | ||
| // ── blackboard_submit_quiz ───────────────────────────────────────────────────────────── | ||
| // ── blackboard_submit_attempt ────────────────────────────────────────────────────────── | ||
| server.registerTool( | ||
| 'blackboard_submit_quiz', | ||
| 'blackboard_submit_attempt', | ||
| { | ||
| description: | ||
| 'Finalize and submit a quiz attempt. ALWAYS confirm with the user before calling this. ' + | ||
| 'All individual answers should be saved first via blackboard_save_quiz_answer.', | ||
| 'Submit (finalize) an assignment attempt for grading — text, attached files, or both. ' + | ||
| 'ALWAYS confirm with the user before submitting, showing exactly what will be sent. ' + | ||
| 'Once submitted the instructor can grade it; use blackboard_save_attempt_draft instead ' + | ||
| 'if the student just wants to save progress without sending it yet.', | ||
| inputSchema: { | ||
| courseId: z.string().describe('Course ID'), | ||
| attemptId: z.string().describe('Quiz attempt ID to submit'), | ||
| courseId: z.string().describe('Blackboard course ID'), | ||
| columnId: z.string().describe('Assignment (gradebook column) ID'), | ||
| studentComments: z.string().optional().describe('Comment to the instructor'), | ||
| studentSubmission: z.string().optional().describe('Text body of the submission'), | ||
| fileUploadIds: z.array(z.string()).optional().describe( | ||
| 'fileUploadId(s) from blackboard_upload_attempt_file to attach to this submission' | ||
| ), | ||
| }, | ||
| }, | ||
| async ({ courseId, attemptId }) => { | ||
| async ({ courseId, columnId, studentComments, studentSubmission, fileUploadIds }) => { | ||
| const { client } = await getClient(); | ||
| const result = await submitQuizAttempt(client, courseId, attemptId); | ||
| return { content: [{ type: 'text', text: JSON.stringify(result, null, 2) }] }; | ||
| const attempt = await submitAttempt(client, courseId, columnId, { | ||
| studentComments, | ||
| studentSubmission, | ||
| fileUploadIds, | ||
| status: 'NeedsGrading', | ||
| }); | ||
| return { content: [{ type: 'text', text: JSON.stringify(attempt, null, 2) }] }; | ||
| } | ||
@@ -460,0 +389,0 @@ ); |
| /** | ||
| * Quiz / Assessment support for Blackboard Ultra. | ||
| * | ||
| * All endpoints discovered by intercepting the Ultra SPA network traffic: | ||
| * | ||
| * GET /learn/api/v1/courses/{courseId}/gradebook/attempts/{attemptId} | ||
| * ?columnId={columnId}&expand=toolAttemptDetail,alignedGoals | ||
| * → Returns full question data in toolAttemptDetail["resource/x-bb-assessment"].questionAttempts | ||
| * | ||
| * PATCH /learn/api/v1/courses/{courseId}/gradebook/attempts/{attemptId}/assessment/answers/{questionAttemptId} | ||
| * → Saves a single answer (eitherOr or multipleanswer) | ||
| * | ||
| * PATCH /learn/api/v1/courses/{courseId}/gradebook/attempts/{attemptId} | ||
| * ?autoSubmitted=false&expand=attemptReceipt.lateSubmission | ||
| * → Final submit | ||
| */ | ||
| import type { AxiosInstance } from 'axios'; | ||
| // ── Types ──────────────────────────────────────────────────────────────────── | ||
| export interface QuizOption { | ||
| id: string; | ||
| text: string; | ||
| index: number; | ||
| } | ||
| export type QuizQuestionType = 'eitherOr' | 'multipleanswer' | 'fimb' | 'presentation' | string; | ||
| export interface QuizQuestion { | ||
| /** Question attempt ID — used as the URL segment for saving answers */ | ||
| questionAttemptId: string; | ||
| /** Question definition ID */ | ||
| questionId: string; | ||
| /** Position within the quiz (1-based visible number) */ | ||
| position: number; | ||
| /** 'eitherOr' = true/false, 'multipleanswer' = MC, 'fimb' = fill-in-multiple-blanks, 'presentation' = text only */ | ||
| type: QuizQuestionType; | ||
| /** Plain text of the question (HTML stripped) */ | ||
| text: string; | ||
| points: number; | ||
| /** Answer options (present for eitherOr and multipleanswer) */ | ||
| options?: QuizOption[]; | ||
| /** Blank names in order (present for fimb), e.g. ['BLANK-1', 'BLANK-2'] */ | ||
| blanks?: string[]; | ||
| /** Currently saved answer: | ||
| * - eitherOr: true | false | null | ||
| * - multipleanswer: boolean[] (one per option, in options order) | ||
| * - fimb: Record<string, string|null> (one per blank) | ||
| */ | ||
| currentAnswer?: boolean | boolean[] | Record<string, string | null> | null; | ||
| /** Raw question object from API (needed when saving eitherOr answers) */ | ||
| _raw: any; | ||
| } | ||
| export interface QuizInfo { | ||
| attemptId: string; | ||
| courseId: string; | ||
| columnId: string; | ||
| title: string; | ||
| status: string; | ||
| totalPoints: number; | ||
| questions: QuizQuestion[]; | ||
| } | ||
| // ── Helpers ────────────────────────────────────────────────────────────────── | ||
| function stripHtml(html: string): string { | ||
| return html | ||
| .replace(/<!--[^>]*-->/g, '') | ||
| .replace(/<[^>]+>/g, ' ') | ||
| .replace(/ /g, ' ') | ||
| .replace(/\s+/g, ' ') | ||
| .trim(); | ||
| } | ||
| function parseQuestionAttempt(qa: any, idx: number): QuizQuestion { | ||
| const q = qa.question || {}; | ||
| const text = stripHtml(q.questionText?.rawText || q.questionText?.displayText || ''); | ||
| let options: QuizOption[] | undefined; | ||
| let blanks: string[] | undefined; | ||
| let currentAnswer: QuizQuestion['currentAnswer'] = qa.givenAnswer ?? null; | ||
| if (qa.questionType === 'eitherOr') { | ||
| // True/False — always two options | ||
| options = [ | ||
| { id: 'true', text: 'Verdadero', index: 0 }, | ||
| { id: 'false', text: 'Falso', index: 1 }, | ||
| ]; | ||
| } else if (qa.questionType === 'multipleanswer' && Array.isArray(q.answers)) { | ||
| options = q.answers.map((a: any, i: number) => ({ | ||
| id: a.id, | ||
| text: stripHtml(a.answerText?.rawText || a.answerText?.displayText || ''), | ||
| index: i, | ||
| })); | ||
| } else if (qa.questionType === 'fimb' && qa.givenAnswers && typeof qa.givenAnswers === 'object') { | ||
| // Fill-in-multiple-blanks — blank names come from givenAnswers keys (e.g. BLANK-1, BLANK-2) | ||
| blanks = Object.keys(qa.givenAnswers); | ||
| currentAnswer = qa.givenAnswers as Record<string, string | null>; | ||
| } | ||
| return { | ||
| questionAttemptId: qa.id, | ||
| questionId: qa.questionId || q.id, | ||
| position: qa.visibleQuestionNumber ?? idx + 1, | ||
| type: qa.questionType, | ||
| text, | ||
| points: q.points ?? 0, | ||
| options, | ||
| blanks, | ||
| currentAnswer, | ||
| _raw: qa, | ||
| }; | ||
| } | ||
| // ── Get quiz questions ──────────────────────────────────────────────────────── | ||
| /** | ||
| * Fetch all questions for a quiz attempt via the internal Blackboard API. | ||
| * | ||
| * @param courseId e.g. _529533_1 | ||
| * @param columnId Gradebook column ID — from content item contentHandler.gradeColumnId | ||
| * @param attemptId e.g. _94898825_1 | ||
| */ | ||
| export async function getQuizQuestions( | ||
| client: AxiosInstance, | ||
| courseId: string, | ||
| columnId: string, | ||
| attemptId: string | ||
| ): Promise<QuizInfo> { | ||
| const r = await client.get( | ||
| `/learn/api/v1/courses/${courseId}/gradebook/attempts/${attemptId}`, | ||
| { params: { columnId, expand: 'toolAttemptDetail,alignedGoals' } } | ||
| ); | ||
| const data = r.data; | ||
| const detail = data.toolAttemptDetail?.['resource/x-bb-assessment']; | ||
| if (!detail) { | ||
| throw new Error( | ||
| `No toolAttemptDetail found for attempt ${attemptId}. ` + | ||
| `Make sure the columnId (${columnId}) and courseId are correct.` | ||
| ); | ||
| } | ||
| const rawQuestions: any[] = detail.questionAttempts || []; | ||
| // Filter out presentation-only questions (no points, no interaction) | ||
| const answerableQuestions = rawQuestions.filter( | ||
| (qa) => qa.questionType !== 'presentation' | ||
| ); | ||
| const questions = answerableQuestions.map(parseQuestionAttempt); | ||
| return { | ||
| attemptId, | ||
| courseId, | ||
| columnId, | ||
| title: detail.assessment?.title || 'Quiz', | ||
| status: detail.status || data.status || 'IN_PROGRESS', | ||
| totalPoints: detail.possiblePoints || detail.assessment?.totalPoints || 0, | ||
| questions, | ||
| }; | ||
| } | ||
| // ── Save a single answer ────────────────────────────────────────────────────── | ||
| /** | ||
| * Save one answer for a quiz question. | ||
| * | ||
| * @param courseId Course ID | ||
| * @param attemptId Quiz attempt ID | ||
| * @param question The question object from getQuizQuestions | ||
| * @param answer | ||
| * - eitherOr: boolean (true = Verdadero, false = Falso) | ||
| * - multipleanswer: number (0-based index of the selected option) | ||
| * OR boolean[] (one per option) | ||
| * - fimb: Record<string, string> (one value per blank name, e.g. { "BLANK-1": "1438.62", "BLANK-2": "140.62" }) | ||
| */ | ||
| export async function saveQuizAnswer( | ||
| client: AxiosInstance, | ||
| courseId: string, | ||
| attemptId: string, | ||
| question: QuizQuestion, | ||
| answer: boolean | number | boolean[] | Record<string, string> | ||
| ): Promise<any> { | ||
| const url = `/learn/api/v1/courses/${courseId}/gradebook/attempts/${attemptId}/assessment/answers/${question.questionAttemptId}`; | ||
| let body: any; | ||
| if (question.type === 'eitherOr') { | ||
| const givenAnswer = typeof answer === 'boolean' ? answer : Boolean(answer); | ||
| body = { | ||
| questionType: 'eitherOr', | ||
| givenAnswer, | ||
| question: question._raw.question, | ||
| }; | ||
| } else if (question.type === 'multipleanswer') { | ||
| const optionCount = question.options?.length ?? 0; | ||
| let givenAnswer: boolean[]; | ||
| if (Array.isArray(answer)) { | ||
| givenAnswer = answer as boolean[]; | ||
| } else { | ||
| // Convert index to boolean array | ||
| const idx = typeof answer === 'number' ? answer : 0; | ||
| givenAnswer = Array.from({ length: optionCount }, (_, i) => i === idx); | ||
| } | ||
| body = { | ||
| questionType: 'multipleanswer', | ||
| givenAnswer, | ||
| lookupOrder: question._raw.lookupOrder || [], | ||
| order: question._raw.order || [], | ||
| question: question._raw.question, | ||
| }; | ||
| } else if (question.type === 'fimb') { | ||
| if (typeof answer !== 'object' || Array.isArray(answer) || answer === null) { | ||
| throw new Error( | ||
| `fimb answers must be a Record<string, string> mapping blank name to value. ` + | ||
| `Expected blanks: ${question.blanks?.join(', ') || '(unknown)'}` | ||
| ); | ||
| } | ||
| // Build givenAnswers using the question's known blank names — preserves order and | ||
| // ensures any blank not provided ends up as null (consistent with Blackboard behavior). | ||
| const blanks = question.blanks ?? Object.keys(answer); | ||
| const givenAnswers: Record<string, string | null> = {}; | ||
| for (const name of blanks) { | ||
| const val = (answer as Record<string, string>)[name]; | ||
| givenAnswers[name] = val !== undefined ? String(val) : null; | ||
| } | ||
| body = { | ||
| questionType: 'fimb', | ||
| givenAnswers, | ||
| question: question._raw.question, | ||
| }; | ||
| } else { | ||
| throw new Error(`Unsupported question type: ${question.type}`); | ||
| } | ||
| const r = await client.patch(url, body); | ||
| return r.data; | ||
| } | ||
| // ── Submit the quiz attempt ──────────────────────────────────────────────────── | ||
| /** | ||
| * Finalize and submit a quiz attempt. | ||
| * ALWAYS ask the user to confirm before calling this. | ||
| */ | ||
| export async function submitQuizAttempt( | ||
| client: AxiosInstance, | ||
| courseId: string, | ||
| attemptId: string | ||
| ): Promise<any> { | ||
| const url = `/learn/api/v1/courses/${courseId}/gradebook/attempts/${attemptId}`; | ||
| const r = await client.patch(url, { | ||
| toolAttemptDetail: { 'resource/x-bb-assessment': { type: 'Test' } }, | ||
| status: 'NEEDS_GRADING', | ||
| studentSubmission: null, | ||
| }, { | ||
| params: { | ||
| autoSubmitted: 'false', | ||
| expand: 'attemptReceipt.lateSubmission', | ||
| }, | ||
| }); | ||
| return r.data; | ||
| } | ||
| // ── Get quiz column ID + attempt limits ─────────────────────────────────────── | ||
| export interface QuizAttemptPolicy { | ||
| columnId: string; | ||
| assessmentId: string; | ||
| title: string; | ||
| /** Max attempts allowed. 0 = unlimited. */ | ||
| attemptsAllowed: number; | ||
| /** Attempts left for this student. -1 = unlimited, 0 = none left. */ | ||
| attemptsLeft: number; | ||
| /** Human-readable summary, e.g. "Ilimitados" or "2 de 3 restantes" */ | ||
| attemptSummary: string; | ||
| /** true = safe to proceed, false = no attempts left */ | ||
| canAttempt: boolean; | ||
| } | ||
| /** | ||
| * Resolve the gradebook column ID for a quiz content item AND fetch | ||
| * the attempt policy (max attempts + attempts left) for the current user. | ||
| */ | ||
| export async function getQuizColumnId( | ||
| client: AxiosInstance, | ||
| courseId: string, | ||
| contentId: string, | ||
| userId?: string | ||
| ): Promise<QuizAttemptPolicy> { | ||
| // 1. Content item → columnId + assessmentId | ||
| const contentR = await client.get( | ||
| `/learn/api/public/v1/courses/${courseId}/contents/${contentId}` | ||
| ); | ||
| const content = contentR.data; | ||
| const handler = content.contentHandler; | ||
| if (!handler?.gradeColumnId) { | ||
| throw new Error( | ||
| `Content item ${contentId} does not have a gradeColumnId. ` + | ||
| `Is it a quiz (resource/x-bb-asmt-test-link)?` | ||
| ); | ||
| } | ||
| const columnId: string = handler.gradeColumnId; | ||
| const assessmentId: string = handler.assessmentId; | ||
| const title: string = content.title || 'Quiz'; | ||
| // 2. Gradebook column → attemptsAllowed (0 = unlimited) | ||
| const colR = await client.get( | ||
| `/learn/api/public/v2/courses/${courseId}/gradebook/columns/${columnId}` | ||
| ); | ||
| const attemptsAllowed: number = colR.data?.grading?.attemptsAllowed ?? 0; | ||
| // 3. Student grade → attemptsLeft (-1 = unlimited) | ||
| let attemptsLeft = -1; | ||
| if (userId) { | ||
| try { | ||
| const gradeR = await client.get( | ||
| `/learn/api/v1/courses/${courseId}/gradebook/columns/${columnId}/grades`, | ||
| { params: { expand: 'attemptsLeft', userId } } | ||
| ); | ||
| const grade = gradeR.data?.results?.[0]; | ||
| if (grade?.attemptsLeft !== undefined) { | ||
| attemptsLeft = grade.attemptsLeft; | ||
| } | ||
| } catch { | ||
| // non-fatal: fall back to attemptsAllowed logic | ||
| } | ||
| } | ||
| // 4. Derive human-readable summary and canAttempt flag | ||
| const unlimited = attemptsAllowed === 0 || attemptsLeft === -1; | ||
| const canAttempt = unlimited || attemptsLeft > 0; | ||
| let attemptSummary: string; | ||
| if (unlimited) { | ||
| attemptSummary = 'Ilimitados'; | ||
| } else if (attemptsLeft === 0) { | ||
| attemptSummary = `Sin intentos restantes (máximo: ${attemptsAllowed})`; | ||
| } else { | ||
| const used = attemptsAllowed - attemptsLeft; | ||
| attemptSummary = `${attemptsLeft} de ${attemptsAllowed} restantes (${used} enviados)`; | ||
| } | ||
| return { | ||
| columnId, | ||
| assessmentId, | ||
| title, | ||
| attemptsAllowed, | ||
| attemptsLeft, | ||
| attemptSummary, | ||
| canAttempt, | ||
| }; | ||
| } | ||
| // ── Parse URL helper ────────────────────────────────────────────────────────── | ||
| /** | ||
| * Parse a Blackboard Ultra quiz URL into its component IDs. | ||
| * | ||
| * Handles: | ||
| * /ultra/stream/assessment/{contentId}/overview/attempt/{attemptId}?courseId={courseId} | ||
| * /ultra/stream/assessment/{contentId}/take/attempt/{attemptId}?courseId={courseId} | ||
| */ | ||
| export function parseQuizUrl(urlStr: string): { | ||
| contentId?: string; | ||
| attemptId?: string; | ||
| courseId?: string; | ||
| } { | ||
| try { | ||
| const u = new URL( | ||
| urlStr.startsWith('http') | ||
| ? urlStr | ||
| : `https://aulavirtual.upc.edu.pe${urlStr}` | ||
| ); | ||
| const parts = u.pathname.split('/'); | ||
| const assessmentIdx = parts.indexOf('assessment'); | ||
| const attemptIdx = parts.indexOf('attempt'); | ||
| return { | ||
| contentId: assessmentIdx >= 0 ? parts[assessmentIdx + 1] : undefined, | ||
| attemptId: attemptIdx >= 0 ? parts[attemptIdx + 1] : undefined, | ||
| courseId: u.searchParams.get('courseId') ?? undefined, | ||
| }; | ||
| } catch { | ||
| return {}; | ||
| } | ||
| } |
AI-detected potential code anomaly
Supply chain riskAI has identified unusual behaviors that may pose a security risk.
Long strings
Supply chain riskContains long string literals, which may be a sign of obfuscated or packed code.
URL strings
Supply chain riskPackage contains fragments of external URLs or IP addresses, which the package may be accessing at runtime.
AI-detected potential code anomaly
Supply chain riskAI has identified unusual behaviors that may pose a security risk.
URL strings
Supply chain riskPackage contains fragments of external URLs or IP addresses, which the package may be accessing at runtime.
132149
-10.29%23
-4.17%2342
-14.93%424
-0.24%11
10%