blackboard-upc 1.0.4 → 1.0.7
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/CHANGELOG.md +48 -0
- package/CLAUDE.md +24 -2
- package/package.json +1 -1
- package/run.js +3 -1
- package/src/api/assignments.ts +23 -0
- package/src/api/quiz.ts +363 -0
- package/src/auth/login.ts +152 -57
- package/src/auth/session.ts +22 -0
- package/src/commands/login.ts +5 -3
- package/src/index.ts +4 -4
- package/src/mcp/server.ts +248 -18
- package/tsconfig.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -4,6 +4,54 @@ All notable changes to `blackboard-upc` will be documented here.
|
|
|
4
4
|
|
|
5
5
|
---
|
|
6
6
|
|
|
7
|
+
## [1.0.7] — 2026-04-12
|
|
8
|
+
|
|
9
|
+
### Added
|
|
10
|
+
- **Persistent browser context** — el perfil de Playwright se guarda en `~/.blackboard-cli/browser-profile/`; las cookies de Microsoft SSO persisten entre sesiones, eliminando el login manual repetido
|
|
11
|
+
- **Silent auto-refresh** — cuando la sesión expira, la CLI relanza el browser en headless y se re-autentica automáticamente si el SSO de Microsoft sigue activo (sin intervención del usuario)
|
|
12
|
+
- **TTL real del servidor** — la expiración de sesión ya no es hardcoded a 8h; se parsea el campo `expires` del cookie `BbRouter` para usar el timestamp real del servidor (fallback: 3h)
|
|
13
|
+
- `get_assignment_feedback` — muestra nota, comentarios del profesor y archivos de feedback para todas las tareas de un curso
|
|
14
|
+
- `download_feedback_file` *(experimental)* — descarga archivos adjuntados por el profesor a una corrección
|
|
15
|
+
|
|
16
|
+
### Changed
|
|
17
|
+
- `blackboard login` ahora muestra el tiempo de expiración real (ej. "expira en 2.9h") en vez de "8 horas"
|
|
18
|
+
- `whoami`, `status` y `api` usan `loadOrRefreshSession()` — intentan refresh silencioso antes de pedir login manual
|
|
19
|
+
|
|
20
|
+
---
|
|
21
|
+
|
|
22
|
+
## [1.0.6] — 2026-04-12
|
|
23
|
+
|
|
24
|
+
### Added
|
|
25
|
+
- Soporte completo para quizzes/evaluaciones de Blackboard Ultra:
|
|
26
|
+
- `get_quiz_questions` — obtiene todas las preguntas, opciones y respuesta actual de un intento; acepta URL directa o IDs separados (`courseId` + `contentId` + `attemptId`)
|
|
27
|
+
- `save_quiz_answer` — guarda una respuesta individual sin enviar (verdadero/falso o índice de opción)
|
|
28
|
+
- `submit_quiz` — envía el intento final (siempre pide confirmación)
|
|
29
|
+
- `src/api/quiz.ts` — módulo nuevo con tipos `QuizQuestion`, `QuizInfo`, `QuizAttemptPolicy` y toda la lógica de los endpoints internos de Ultra
|
|
30
|
+
- Verifica intentos restantes antes de cargar preguntas (`getQuizColumnId`)
|
|
31
|
+
|
|
32
|
+
### Fixed
|
|
33
|
+
- `tsconfig.json` — agrega `"DOM"` a `lib` para que los callbacks de `page.evaluate()` en `login.ts` compilen sin errores
|
|
34
|
+
|
|
35
|
+
---
|
|
36
|
+
|
|
37
|
+
## [1.0.5] — 2026-03-31
|
|
38
|
+
|
|
39
|
+
### Fixed
|
|
40
|
+
- `run.js` ahora prepone el directorio del Node que lo ejecuta al PATH antes de lanzar `tsx`
|
|
41
|
+
- Soluciona el crash en MCP cuando el usuario tiene nvm con Node 16 como default (Playwright requiere >=18)
|
|
42
|
+
|
|
43
|
+
---
|
|
44
|
+
|
|
45
|
+
## [1.0.4] — 2026-03-30
|
|
46
|
+
|
|
47
|
+
### Changed
|
|
48
|
+
- `download_attachment` y `download_file_url` ya no devuelven base64 — guardan el archivo directamente a disco
|
|
49
|
+
- Directorio por defecto: `process.cwd()` (donde el usuario está trabajando), configurable con `outputDir`
|
|
50
|
+
- Pasar `filename` (el `displayName` de `list_attachments`) para guardar con el nombre correcto
|
|
51
|
+
- Respuesta devuelve `{ saved, size, mimeType }` — sin datos en el contexto
|
|
52
|
+
|
|
53
|
+
---
|
|
54
|
+
|
|
7
55
|
## [1.0.3] — 2026-03-30
|
|
8
56
|
|
|
9
57
|
### Changed
|
package/CLAUDE.md
CHANGED
|
@@ -24,9 +24,25 @@ If you get `Not authenticated`, ask the user to run `blackboard login`.
|
|
|
24
24
|
6. list_attachments <courseId> <contentId>→ find downloadable files
|
|
25
25
|
```
|
|
26
26
|
|
|
27
|
+
### Quiz workflow
|
|
28
|
+
|
|
29
|
+
```
|
|
30
|
+
1. list_contents <courseId> → find the quiz contentId
|
|
31
|
+
2. get_quiz_questions <url|ids> → load questions + options + attempt policy
|
|
32
|
+
3. save_quiz_answer (per question) → save each answer individually
|
|
33
|
+
4. submit_quiz (confirm first!) → finalize and submit the attempt
|
|
34
|
+
```
|
|
35
|
+
|
|
36
|
+
### Feedback workflow
|
|
37
|
+
|
|
38
|
+
```
|
|
39
|
+
1. get_assignment_feedback <courseId> → scores + instructor comments + feedback files for all assignments
|
|
40
|
+
2. download_feedback_file <ids> → download an annotated file the professor attached to the grade
|
|
41
|
+
```
|
|
42
|
+
|
|
27
43
|
## Agent behavior rules
|
|
28
44
|
|
|
29
|
-
- **Always confirm before submitting** (`submit_attempt`). Show the user what will be submitted and ask for confirmation. Never submit silently.
|
|
45
|
+
- **Always confirm before submitting** (`submit_attempt`, `submit_quiz`). Show the user what will be submitted and ask for confirmation. Never submit silently.
|
|
30
46
|
- **Show grades in context** — when showing grades, also show the assignment name, max score, and due date if available.
|
|
31
47
|
- **Navigate content recursively** — if the user asks for materials, explore subfolders using `list_contents` with `parentId`.
|
|
32
48
|
- **Use `raw_api` for anything not covered** — the Blackboard REST API is extensive. If there's no specific tool, use `raw_api` with the correct endpoint.
|
|
@@ -65,6 +81,12 @@ GET /learn/api/public/v1/courses/{courseId}/contents/{id}/attachments/{id}/downl
|
|
|
65
81
|
| `list_attempts` | Submission history |
|
|
66
82
|
| `get_grades` | Full grade report for a course |
|
|
67
83
|
| `list_attachments` | Files in a content item |
|
|
68
|
-
| `download_attachment` | Download file
|
|
84
|
+
| `download_attachment` | Download file to disk |
|
|
85
|
+
| `download_file_url` | Download a bbcswebdav URL directly |
|
|
69
86
|
| `submit_attempt` | Submit assignment (confirm first!) |
|
|
87
|
+
| `get_assignment_feedback` | Scores + instructor comments + feedback files for all assignments in a course |
|
|
88
|
+
| `download_feedback_file` | **[EXPERIMENTAL]** Download a file the professor attached to a graded attempt |
|
|
89
|
+
| `get_quiz_questions` | Load quiz questions + options from an attempt (URL or IDs) |
|
|
90
|
+
| `save_quiz_answer` | Save one answer without submitting |
|
|
91
|
+
| `submit_quiz` | Finalize and submit a quiz attempt (confirm first!) |
|
|
70
92
|
| `raw_api` | Any other Blackboard endpoint |
|
package/package.json
CHANGED
package/run.js
CHANGED
|
@@ -4,5 +4,7 @@ const { spawnSync } = require('child_process');
|
|
|
4
4
|
const path = require('path');
|
|
5
5
|
const tsx = path.join(__dirname, 'node_modules', '.bin', 'tsx');
|
|
6
6
|
const entry = path.join(__dirname, 'src', 'index.ts');
|
|
7
|
-
const
|
|
7
|
+
const nodeDir = path.dirname(process.execPath);
|
|
8
|
+
const env = { ...process.env, PATH: `${nodeDir}${path.delimiter}${process.env.PATH}` };
|
|
9
|
+
const result = spawnSync(tsx, [entry, ...process.argv.slice(2)], { stdio: 'inherit', env });
|
|
8
10
|
process.exit(result.status ?? 0);
|
package/src/api/assignments.ts
CHANGED
|
@@ -33,6 +33,17 @@ export interface Attempt {
|
|
|
33
33
|
modified?: string;
|
|
34
34
|
attemptDate?: string;
|
|
35
35
|
files?: Array<{ id: string; fileName: string; mimeType: string }>;
|
|
36
|
+
// Instructor feedback fields
|
|
37
|
+
instructorFeedback?: string;
|
|
38
|
+
feedback?: string;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export interface AttemptFile {
|
|
42
|
+
id: string;
|
|
43
|
+
name: string;
|
|
44
|
+
mimeType?: string;
|
|
45
|
+
size?: number;
|
|
46
|
+
href?: string;
|
|
36
47
|
}
|
|
37
48
|
|
|
38
49
|
export interface SubmitAttemptBody {
|
|
@@ -122,6 +133,18 @@ export async function submitAttempt(
|
|
|
122
133
|
return r.data;
|
|
123
134
|
}
|
|
124
135
|
|
|
136
|
+
export async function getAttemptFiles(
|
|
137
|
+
client: AxiosInstance,
|
|
138
|
+
courseId: string,
|
|
139
|
+
columnId: string,
|
|
140
|
+
attemptId: string
|
|
141
|
+
): Promise<AttemptFile[]> {
|
|
142
|
+
const r = await client.get(
|
|
143
|
+
`/learn/api/public/v2/courses/${courseId}/gradebook/columns/${columnId}/attempts/${attemptId}/files`
|
|
144
|
+
);
|
|
145
|
+
return r.data.results ?? [];
|
|
146
|
+
}
|
|
147
|
+
|
|
125
148
|
export async function getMyGrade(
|
|
126
149
|
client: AxiosInstance,
|
|
127
150
|
courseId: string,
|
package/src/api/quiz.ts
ADDED
|
@@ -0,0 +1,363 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Quiz / Assessment support for Blackboard Ultra.
|
|
3
|
+
*
|
|
4
|
+
* All endpoints discovered by intercepting the Ultra SPA network traffic:
|
|
5
|
+
*
|
|
6
|
+
* GET /learn/api/v1/courses/{courseId}/gradebook/attempts/{attemptId}
|
|
7
|
+
* ?columnId={columnId}&expand=toolAttemptDetail,alignedGoals
|
|
8
|
+
* → Returns full question data in toolAttemptDetail["resource/x-bb-assessment"].questionAttempts
|
|
9
|
+
*
|
|
10
|
+
* PATCH /learn/api/v1/courses/{courseId}/gradebook/attempts/{attemptId}/assessment/answers/{questionAttemptId}
|
|
11
|
+
* → Saves a single answer (eitherOr or multipleanswer)
|
|
12
|
+
*
|
|
13
|
+
* PATCH /learn/api/v1/courses/{courseId}/gradebook/attempts/{attemptId}
|
|
14
|
+
* ?autoSubmitted=false&expand=attemptReceipt.lateSubmission
|
|
15
|
+
* → Final submit
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
import type { AxiosInstance } from 'axios';
|
|
19
|
+
|
|
20
|
+
// ── Types ────────────────────────────────────────────────────────────────────
|
|
21
|
+
|
|
22
|
+
export interface QuizOption {
|
|
23
|
+
id: string;
|
|
24
|
+
text: string;
|
|
25
|
+
index: number;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export type QuizQuestionType = 'eitherOr' | 'multipleanswer' | 'presentation' | string;
|
|
29
|
+
|
|
30
|
+
export interface QuizQuestion {
|
|
31
|
+
/** Question attempt ID — used as the URL segment for saving answers */
|
|
32
|
+
questionAttemptId: string;
|
|
33
|
+
/** Question definition ID */
|
|
34
|
+
questionId: string;
|
|
35
|
+
/** Position within the quiz (1-based visible number) */
|
|
36
|
+
position: number;
|
|
37
|
+
/** 'eitherOr' = true/false, 'multipleanswer' = MC, 'presentation' = text only */
|
|
38
|
+
type: QuizQuestionType;
|
|
39
|
+
/** Plain text of the question (HTML stripped) */
|
|
40
|
+
text: string;
|
|
41
|
+
points: number;
|
|
42
|
+
/** Answer options (present for eitherOr and multipleanswer) */
|
|
43
|
+
options?: QuizOption[];
|
|
44
|
+
/** Currently saved answer:
|
|
45
|
+
* - eitherOr: true | false | null
|
|
46
|
+
* - multipleanswer: boolean[] (one per option, in options order)
|
|
47
|
+
*/
|
|
48
|
+
currentAnswer?: boolean | boolean[] | null;
|
|
49
|
+
/** Raw question object from API (needed when saving eitherOr answers) */
|
|
50
|
+
_raw: any;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
export interface QuizInfo {
|
|
54
|
+
attemptId: string;
|
|
55
|
+
courseId: string;
|
|
56
|
+
columnId: string;
|
|
57
|
+
title: string;
|
|
58
|
+
status: string;
|
|
59
|
+
totalPoints: number;
|
|
60
|
+
questions: QuizQuestion[];
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
// ── Helpers ──────────────────────────────────────────────────────────────────
|
|
64
|
+
|
|
65
|
+
function stripHtml(html: string): string {
|
|
66
|
+
return html
|
|
67
|
+
.replace(/<!--[^>]*-->/g, '')
|
|
68
|
+
.replace(/<[^>]+>/g, ' ')
|
|
69
|
+
.replace(/ /g, ' ')
|
|
70
|
+
.replace(/\s+/g, ' ')
|
|
71
|
+
.trim();
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
function parseQuestionAttempt(qa: any, idx: number): QuizQuestion {
|
|
75
|
+
const q = qa.question || {};
|
|
76
|
+
const text = stripHtml(q.questionText?.rawText || q.questionText?.displayText || '');
|
|
77
|
+
|
|
78
|
+
let options: QuizOption[] | undefined;
|
|
79
|
+
|
|
80
|
+
if (qa.questionType === 'eitherOr') {
|
|
81
|
+
// True/False — always two options
|
|
82
|
+
options = [
|
|
83
|
+
{ id: 'true', text: 'Verdadero', index: 0 },
|
|
84
|
+
{ id: 'false', text: 'Falso', index: 1 },
|
|
85
|
+
];
|
|
86
|
+
} else if (qa.questionType === 'multipleanswer' && Array.isArray(q.answers)) {
|
|
87
|
+
options = q.answers.map((a: any, i: number) => ({
|
|
88
|
+
id: a.id,
|
|
89
|
+
text: stripHtml(a.answerText?.rawText || a.answerText?.displayText || ''),
|
|
90
|
+
index: i,
|
|
91
|
+
}));
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
return {
|
|
95
|
+
questionAttemptId: qa.id,
|
|
96
|
+
questionId: qa.questionId || q.id,
|
|
97
|
+
position: qa.visibleQuestionNumber ?? idx + 1,
|
|
98
|
+
type: qa.questionType,
|
|
99
|
+
text,
|
|
100
|
+
points: q.points ?? 0,
|
|
101
|
+
options,
|
|
102
|
+
currentAnswer: qa.givenAnswer ?? null,
|
|
103
|
+
_raw: qa,
|
|
104
|
+
};
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
// ── Get quiz questions ────────────────────────────────────────────────────────
|
|
108
|
+
|
|
109
|
+
/**
|
|
110
|
+
* Fetch all questions for a quiz attempt via the internal Blackboard API.
|
|
111
|
+
*
|
|
112
|
+
* @param courseId e.g. _529533_1
|
|
113
|
+
* @param columnId Gradebook column ID — from content item contentHandler.gradeColumnId
|
|
114
|
+
* @param attemptId e.g. _94898825_1
|
|
115
|
+
*/
|
|
116
|
+
export async function getQuizQuestions(
|
|
117
|
+
client: AxiosInstance,
|
|
118
|
+
courseId: string,
|
|
119
|
+
columnId: string,
|
|
120
|
+
attemptId: string
|
|
121
|
+
): Promise<QuizInfo> {
|
|
122
|
+
const r = await client.get(
|
|
123
|
+
`/learn/api/v1/courses/${courseId}/gradebook/attempts/${attemptId}`,
|
|
124
|
+
{ params: { columnId, expand: 'toolAttemptDetail,alignedGoals' } }
|
|
125
|
+
);
|
|
126
|
+
|
|
127
|
+
const data = r.data;
|
|
128
|
+
const detail = data.toolAttemptDetail?.['resource/x-bb-assessment'];
|
|
129
|
+
|
|
130
|
+
if (!detail) {
|
|
131
|
+
throw new Error(
|
|
132
|
+
`No toolAttemptDetail found for attempt ${attemptId}. ` +
|
|
133
|
+
`Make sure the columnId (${columnId}) and courseId are correct.`
|
|
134
|
+
);
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
const rawQuestions: any[] = detail.questionAttempts || [];
|
|
138
|
+
// Filter out presentation-only questions (no points, no interaction)
|
|
139
|
+
const answerableQuestions = rawQuestions.filter(
|
|
140
|
+
(qa) => qa.questionType !== 'presentation'
|
|
141
|
+
);
|
|
142
|
+
|
|
143
|
+
const questions = answerableQuestions.map(parseQuestionAttempt);
|
|
144
|
+
|
|
145
|
+
return {
|
|
146
|
+
attemptId,
|
|
147
|
+
courseId,
|
|
148
|
+
columnId,
|
|
149
|
+
title: detail.assessment?.title || 'Quiz',
|
|
150
|
+
status: detail.status || data.status || 'IN_PROGRESS',
|
|
151
|
+
totalPoints: detail.possiblePoints || detail.assessment?.totalPoints || 0,
|
|
152
|
+
questions,
|
|
153
|
+
};
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
// ── Save a single answer ──────────────────────────────────────────────────────
|
|
157
|
+
|
|
158
|
+
/**
|
|
159
|
+
* Save one answer for a quiz question.
|
|
160
|
+
*
|
|
161
|
+
* @param courseId Course ID
|
|
162
|
+
* @param attemptId Quiz attempt ID
|
|
163
|
+
* @param question The question object from getQuizQuestions
|
|
164
|
+
* @param answer
|
|
165
|
+
* - eitherOr: boolean (true = Verdadero, false = Falso)
|
|
166
|
+
* - multipleanswer: number (0-based index of the selected option)
|
|
167
|
+
* OR boolean[] (one per option)
|
|
168
|
+
*/
|
|
169
|
+
export async function saveQuizAnswer(
|
|
170
|
+
client: AxiosInstance,
|
|
171
|
+
courseId: string,
|
|
172
|
+
attemptId: string,
|
|
173
|
+
question: QuizQuestion,
|
|
174
|
+
answer: boolean | number | boolean[]
|
|
175
|
+
): Promise<any> {
|
|
176
|
+
const url = `/learn/api/v1/courses/${courseId}/gradebook/attempts/${attemptId}/assessment/answers/${question.questionAttemptId}`;
|
|
177
|
+
|
|
178
|
+
let body: any;
|
|
179
|
+
|
|
180
|
+
if (question.type === 'eitherOr') {
|
|
181
|
+
const givenAnswer = typeof answer === 'boolean' ? answer : Boolean(answer);
|
|
182
|
+
body = {
|
|
183
|
+
questionType: 'eitherOr',
|
|
184
|
+
givenAnswer,
|
|
185
|
+
question: question._raw.question,
|
|
186
|
+
};
|
|
187
|
+
} else if (question.type === 'multipleanswer') {
|
|
188
|
+
const optionCount = question.options?.length ?? 0;
|
|
189
|
+
let givenAnswer: boolean[];
|
|
190
|
+
|
|
191
|
+
if (Array.isArray(answer)) {
|
|
192
|
+
givenAnswer = answer as boolean[];
|
|
193
|
+
} else {
|
|
194
|
+
// Convert index to boolean array
|
|
195
|
+
const idx = typeof answer === 'number' ? answer : 0;
|
|
196
|
+
givenAnswer = Array.from({ length: optionCount }, (_, i) => i === idx);
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
body = {
|
|
200
|
+
questionType: 'multipleanswer',
|
|
201
|
+
givenAnswer,
|
|
202
|
+
lookupOrder: question._raw.lookupOrder || [],
|
|
203
|
+
order: question._raw.order || [],
|
|
204
|
+
question: question._raw.question,
|
|
205
|
+
};
|
|
206
|
+
} else {
|
|
207
|
+
throw new Error(`Unsupported question type: ${question.type}`);
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
const r = await client.patch(url, body);
|
|
211
|
+
return r.data;
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
// ── Submit the quiz attempt ────────────────────────────────────────────────────
|
|
215
|
+
|
|
216
|
+
/**
|
|
217
|
+
* Finalize and submit a quiz attempt.
|
|
218
|
+
* ALWAYS ask the user to confirm before calling this.
|
|
219
|
+
*/
|
|
220
|
+
export async function submitQuizAttempt(
|
|
221
|
+
client: AxiosInstance,
|
|
222
|
+
courseId: string,
|
|
223
|
+
attemptId: string
|
|
224
|
+
): Promise<any> {
|
|
225
|
+
const url = `/learn/api/v1/courses/${courseId}/gradebook/attempts/${attemptId}`;
|
|
226
|
+
const r = await client.patch(url, {
|
|
227
|
+
toolAttemptDetail: { 'resource/x-bb-assessment': { type: 'Test' } },
|
|
228
|
+
status: 'NEEDS_GRADING',
|
|
229
|
+
studentSubmission: null,
|
|
230
|
+
}, {
|
|
231
|
+
params: {
|
|
232
|
+
autoSubmitted: 'false',
|
|
233
|
+
expand: 'attemptReceipt.lateSubmission',
|
|
234
|
+
},
|
|
235
|
+
});
|
|
236
|
+
return r.data;
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
// ── Get quiz column ID + attempt limits ───────────────────────────────────────
|
|
240
|
+
|
|
241
|
+
export interface QuizAttemptPolicy {
|
|
242
|
+
columnId: string;
|
|
243
|
+
assessmentId: string;
|
|
244
|
+
title: string;
|
|
245
|
+
/** Max attempts allowed. 0 = unlimited. */
|
|
246
|
+
attemptsAllowed: number;
|
|
247
|
+
/** Attempts left for this student. -1 = unlimited, 0 = none left. */
|
|
248
|
+
attemptsLeft: number;
|
|
249
|
+
/** Human-readable summary, e.g. "Ilimitados" or "2 de 3 restantes" */
|
|
250
|
+
attemptSummary: string;
|
|
251
|
+
/** true = safe to proceed, false = no attempts left */
|
|
252
|
+
canAttempt: boolean;
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
/**
|
|
256
|
+
* Resolve the gradebook column ID for a quiz content item AND fetch
|
|
257
|
+
* the attempt policy (max attempts + attempts left) for the current user.
|
|
258
|
+
*/
|
|
259
|
+
export async function getQuizColumnId(
|
|
260
|
+
client: AxiosInstance,
|
|
261
|
+
courseId: string,
|
|
262
|
+
contentId: string,
|
|
263
|
+
userId?: string
|
|
264
|
+
): Promise<QuizAttemptPolicy> {
|
|
265
|
+
// 1. Content item → columnId + assessmentId
|
|
266
|
+
const contentR = await client.get(
|
|
267
|
+
`/learn/api/public/v1/courses/${courseId}/contents/${contentId}`
|
|
268
|
+
);
|
|
269
|
+
const content = contentR.data;
|
|
270
|
+
const handler = content.contentHandler;
|
|
271
|
+
|
|
272
|
+
if (!handler?.gradeColumnId) {
|
|
273
|
+
throw new Error(
|
|
274
|
+
`Content item ${contentId} does not have a gradeColumnId. ` +
|
|
275
|
+
`Is it a quiz (resource/x-bb-asmt-test-link)?`
|
|
276
|
+
);
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
const columnId: string = handler.gradeColumnId;
|
|
280
|
+
const assessmentId: string = handler.assessmentId;
|
|
281
|
+
const title: string = content.title || 'Quiz';
|
|
282
|
+
|
|
283
|
+
// 2. Gradebook column → attemptsAllowed (0 = unlimited)
|
|
284
|
+
const colR = await client.get(
|
|
285
|
+
`/learn/api/public/v2/courses/${courseId}/gradebook/columns/${columnId}`
|
|
286
|
+
);
|
|
287
|
+
const attemptsAllowed: number = colR.data?.grading?.attemptsAllowed ?? 0;
|
|
288
|
+
|
|
289
|
+
// 3. Student grade → attemptsLeft (-1 = unlimited)
|
|
290
|
+
let attemptsLeft = -1;
|
|
291
|
+
if (userId) {
|
|
292
|
+
try {
|
|
293
|
+
const gradeR = await client.get(
|
|
294
|
+
`/learn/api/v1/courses/${courseId}/gradebook/columns/${columnId}/grades`,
|
|
295
|
+
{ params: { expand: 'attemptsLeft', userId } }
|
|
296
|
+
);
|
|
297
|
+
const grade = gradeR.data?.results?.[0];
|
|
298
|
+
if (grade?.attemptsLeft !== undefined) {
|
|
299
|
+
attemptsLeft = grade.attemptsLeft;
|
|
300
|
+
}
|
|
301
|
+
} catch {
|
|
302
|
+
// non-fatal: fall back to attemptsAllowed logic
|
|
303
|
+
}
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
// 4. Derive human-readable summary and canAttempt flag
|
|
307
|
+
const unlimited = attemptsAllowed === 0 || attemptsLeft === -1;
|
|
308
|
+
const canAttempt = unlimited || attemptsLeft > 0;
|
|
309
|
+
|
|
310
|
+
let attemptSummary: string;
|
|
311
|
+
if (unlimited) {
|
|
312
|
+
attemptSummary = 'Ilimitados';
|
|
313
|
+
} else if (attemptsLeft === 0) {
|
|
314
|
+
attemptSummary = `Sin intentos restantes (máximo: ${attemptsAllowed})`;
|
|
315
|
+
} else {
|
|
316
|
+
const used = attemptsAllowed - attemptsLeft;
|
|
317
|
+
attemptSummary = `${attemptsLeft} de ${attemptsAllowed} restantes (${used} enviados)`;
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
return {
|
|
321
|
+
columnId,
|
|
322
|
+
assessmentId,
|
|
323
|
+
title,
|
|
324
|
+
attemptsAllowed,
|
|
325
|
+
attemptsLeft,
|
|
326
|
+
attemptSummary,
|
|
327
|
+
canAttempt,
|
|
328
|
+
};
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
// ── Parse URL helper ──────────────────────────────────────────────────────────
|
|
332
|
+
|
|
333
|
+
/**
|
|
334
|
+
* Parse a Blackboard Ultra quiz URL into its component IDs.
|
|
335
|
+
*
|
|
336
|
+
* Handles:
|
|
337
|
+
* /ultra/stream/assessment/{contentId}/overview/attempt/{attemptId}?courseId={courseId}
|
|
338
|
+
* /ultra/stream/assessment/{contentId}/take/attempt/{attemptId}?courseId={courseId}
|
|
339
|
+
*/
|
|
340
|
+
export function parseQuizUrl(urlStr: string): {
|
|
341
|
+
contentId?: string;
|
|
342
|
+
attemptId?: string;
|
|
343
|
+
courseId?: string;
|
|
344
|
+
} {
|
|
345
|
+
try {
|
|
346
|
+
const u = new URL(
|
|
347
|
+
urlStr.startsWith('http')
|
|
348
|
+
? urlStr
|
|
349
|
+
: `https://aulavirtual.upc.edu.pe${urlStr}`
|
|
350
|
+
);
|
|
351
|
+
const parts = u.pathname.split('/');
|
|
352
|
+
const assessmentIdx = parts.indexOf('assessment');
|
|
353
|
+
const attemptIdx = parts.indexOf('attempt');
|
|
354
|
+
|
|
355
|
+
return {
|
|
356
|
+
contentId: assessmentIdx >= 0 ? parts[assessmentIdx + 1] : undefined,
|
|
357
|
+
attemptId: attemptIdx >= 0 ? parts[attemptIdx + 1] : undefined,
|
|
358
|
+
courseId: u.searchParams.get('courseId') ?? undefined,
|
|
359
|
+
};
|
|
360
|
+
} catch {
|
|
361
|
+
return {};
|
|
362
|
+
}
|
|
363
|
+
}
|
package/src/auth/login.ts
CHANGED
|
@@ -1,12 +1,19 @@
|
|
|
1
1
|
import { chromium } from 'playwright';
|
|
2
|
+
import fs from 'fs';
|
|
3
|
+
import os from 'os';
|
|
4
|
+
import path from 'path';
|
|
2
5
|
import type { Session, Cookie } from '../types/index.js';
|
|
3
6
|
import { saveSession } from './session.js';
|
|
4
7
|
|
|
5
8
|
const BASE_URL = 'https://aulavirtual.upc.edu.pe';
|
|
6
9
|
const SAML_URL = `${BASE_URL}/auth-saml/saml/login?apId=_4893_1&redirectUrl=${encodeURIComponent(`${BASE_URL}/ultra`)}`;
|
|
7
10
|
|
|
8
|
-
//
|
|
9
|
-
const
|
|
11
|
+
const SESSION_TTL_FALLBACK_MS = 3 * 60 * 60 * 1000; // 3h — matches BbRouter timeout:10800
|
|
12
|
+
const PROFILE_DIR = path.join(os.homedir(), '.blackboard-cli', 'browser-profile');
|
|
13
|
+
|
|
14
|
+
const USER_AGENT =
|
|
15
|
+
'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 ' +
|
|
16
|
+
'(KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36';
|
|
10
17
|
|
|
11
18
|
export interface LoginOptions {
|
|
12
19
|
headless?: boolean;
|
|
@@ -15,14 +22,50 @@ export interface LoginOptions {
|
|
|
15
22
|
timeout?: number;
|
|
16
23
|
}
|
|
17
24
|
|
|
25
|
+
export class SilentLoginFailed extends Error {
|
|
26
|
+
constructor(reason: string) {
|
|
27
|
+
super(`Silent re-login failed: ${reason}`);
|
|
28
|
+
this.name = 'SilentLoginFailed';
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
function extractBbRouterExpiry(cookies: Cookie[]): number {
|
|
33
|
+
const bb = cookies.find(c => c.name === 'BbRouter');
|
|
34
|
+
if (bb) {
|
|
35
|
+
const m = bb.value.match(/expires:(\d+)/);
|
|
36
|
+
if (m) {
|
|
37
|
+
const ms = parseInt(m[1], 10) * 1000;
|
|
38
|
+
const now = Date.now();
|
|
39
|
+
// Sanity check: must be a future timestamp within 24 hours
|
|
40
|
+
if (ms > now && ms < now + 24 * 60 * 60 * 1000) return ms;
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
return Date.now() + SESSION_TTL_FALLBACK_MS;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
function extractXsrf(cookies: Cookie[]): string {
|
|
47
|
+
const bb = cookies.find(c => c.name === 'BbRouter');
|
|
48
|
+
if (bb) {
|
|
49
|
+
const m = bb.value.match(/xsrf:([a-f0-9-]+)/);
|
|
50
|
+
if (m) return m[1];
|
|
51
|
+
}
|
|
52
|
+
return '';
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
function ensureProfileDir(): void {
|
|
56
|
+
if (!fs.existsSync(PROFILE_DIR)) {
|
|
57
|
+
fs.mkdirSync(PROFILE_DIR, { recursive: true, mode: 0o700 });
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
|
|
18
61
|
export async function login(opts: LoginOptions = {}): Promise<Session> {
|
|
19
62
|
const { headless = false, timeout = 120_000 } = opts;
|
|
20
63
|
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
64
|
+
ensureProfileDir();
|
|
65
|
+
|
|
66
|
+
const context = await chromium.launchPersistentContext(PROFILE_DIR, {
|
|
67
|
+
headless,
|
|
68
|
+
userAgent: USER_AGENT,
|
|
26
69
|
});
|
|
27
70
|
|
|
28
71
|
const page = await context.newPage();
|
|
@@ -31,28 +74,36 @@ export async function login(opts: LoginOptions = {}): Promise<Session> {
|
|
|
31
74
|
console.log('Navigating to UPC Aula Virtual...');
|
|
32
75
|
await page.goto(SAML_URL, { waitUntil: 'networkidle', timeout });
|
|
33
76
|
|
|
34
|
-
//
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
77
|
+
// With persistent context, Microsoft SSO may auto-complete without showing the login page
|
|
78
|
+
let needsInteractiveLogin = false;
|
|
79
|
+
try {
|
|
80
|
+
await page.waitForURL(/login\.microsoftonline\.com/, { timeout: 8_000 });
|
|
81
|
+
needsInteractiveLogin = true;
|
|
82
|
+
} catch {
|
|
83
|
+
// SSO cookies still valid — SAML redirect auto-completing
|
|
41
84
|
}
|
|
42
85
|
|
|
43
|
-
if (
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
86
|
+
if (needsInteractiveLogin) {
|
|
87
|
+
if (opts.username) {
|
|
88
|
+
await page.fill('input[type="email"], input[name="loginfmt"]', opts.username);
|
|
89
|
+
await page.click('input[type="submit"], button[type="submit"]');
|
|
90
|
+
await page.waitForTimeout(1500);
|
|
91
|
+
}
|
|
49
92
|
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
93
|
+
if (opts.password) {
|
|
94
|
+
await page.waitForSelector('input[type="password"], input[name="passwd"]', { timeout: 15_000 });
|
|
95
|
+
await page.fill('input[type="password"], input[name="passwd"]', opts.password);
|
|
96
|
+
await page.click('input[type="submit"], button[type="submit"]');
|
|
97
|
+
await page.waitForTimeout(1500);
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
// Handle "Stay signed in?" prompt
|
|
101
|
+
try {
|
|
102
|
+
await page.waitForSelector('#idBtn_Back, #KmsiCheckboxField', { timeout: 8_000 });
|
|
103
|
+
const noBtn = page.locator('#idBtn_Back');
|
|
104
|
+
if (await noBtn.isVisible()) await noBtn.click();
|
|
105
|
+
} catch {}
|
|
106
|
+
}
|
|
56
107
|
|
|
57
108
|
// Wait for redirect back to aulavirtual.upc.edu.pe/ultra
|
|
58
109
|
console.log('Waiting for authentication to complete...');
|
|
@@ -75,37 +126,23 @@ export async function login(opts: LoginOptions = {}): Promise<Session> {
|
|
|
75
126
|
sameSite: c.sameSite,
|
|
76
127
|
}));
|
|
77
128
|
|
|
78
|
-
// Extract XSRF token from
|
|
79
|
-
|
|
80
|
-
// Try various sources
|
|
81
|
-
const metaXsrf = document.querySelector<HTMLMetaElement>(
|
|
82
|
-
'meta[name="blackboard.platform.security.NonceUtil.nonce"]'
|
|
83
|
-
)?.content;
|
|
84
|
-
if (metaXsrf) return metaXsrf;
|
|
85
|
-
|
|
86
|
-
// Try from cookies
|
|
87
|
-
const allCookies = document.cookie.split(';').reduce<Record<string, string>>((acc, c) => {
|
|
88
|
-
const [k, v] = c.trim().split('=');
|
|
89
|
-
acc[k] = v;
|
|
90
|
-
return acc;
|
|
91
|
-
}, {});
|
|
92
|
-
return allCookies['XSRF-TOKEN'] || '';
|
|
93
|
-
});
|
|
129
|
+
// Extract XSRF token from BbRouter cookie
|
|
130
|
+
let nonce = extractXsrf(cookies);
|
|
94
131
|
|
|
95
|
-
//
|
|
96
|
-
let nonce = xsrfToken;
|
|
132
|
+
// Fallback: try meta tag in the page
|
|
97
133
|
if (!nonce) {
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
134
|
+
nonce = await page.evaluate(() => {
|
|
135
|
+
const metaXsrf = document.querySelector<HTMLMetaElement>(
|
|
136
|
+
'meta[name="blackboard.platform.security.NonceUtil.nonce"]'
|
|
137
|
+
)?.content;
|
|
138
|
+
if (metaXsrf) return metaXsrf;
|
|
139
|
+
const allCookies = document.cookie.split(';').reduce<Record<string, string>>((acc, c) => {
|
|
140
|
+
const [k, v] = c.trim().split('=');
|
|
141
|
+
acc[k] = v;
|
|
142
|
+
return acc;
|
|
143
|
+
}, {});
|
|
144
|
+
return allCookies['XSRF-TOKEN'] || '';
|
|
145
|
+
});
|
|
109
146
|
}
|
|
110
147
|
|
|
111
148
|
// Get current user info via direct HTTP call with captured cookies
|
|
@@ -126,7 +163,7 @@ export async function login(opts: LoginOptions = {}): Promise<Session> {
|
|
|
126
163
|
xsrfToken: nonce,
|
|
127
164
|
userId: userData?.id,
|
|
128
165
|
userName: userData?.userName,
|
|
129
|
-
expiresAt:
|
|
166
|
+
expiresAt: extractBbRouterExpiry(cookies),
|
|
130
167
|
};
|
|
131
168
|
|
|
132
169
|
saveSession(session);
|
|
@@ -134,6 +171,64 @@ export async function login(opts: LoginOptions = {}): Promise<Session> {
|
|
|
134
171
|
|
|
135
172
|
return session;
|
|
136
173
|
} finally {
|
|
137
|
-
await
|
|
174
|
+
await context.close();
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
export async function silentRelogin(previousSession?: Session | null): Promise<Session> {
|
|
179
|
+
if (!fs.existsSync(PROFILE_DIR)) {
|
|
180
|
+
throw new SilentLoginFailed('No browser profile — run blackboard login first');
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
let context;
|
|
184
|
+
try {
|
|
185
|
+
context = await chromium.launchPersistentContext(PROFILE_DIR, {
|
|
186
|
+
headless: true,
|
|
187
|
+
userAgent: USER_AGENT,
|
|
188
|
+
});
|
|
189
|
+
} catch (err: any) {
|
|
190
|
+
throw new SilentLoginFailed(`Could not open browser profile: ${err.message}`);
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
const page = await context.newPage();
|
|
194
|
+
|
|
195
|
+
try {
|
|
196
|
+
await page.goto(SAML_URL, { waitUntil: 'commit', timeout: 20_000 });
|
|
197
|
+
|
|
198
|
+
// If SSO cookies are still valid, this redirect completes automatically
|
|
199
|
+
await page.waitForURL(/aulavirtual\.upc\.edu\.pe\/ultra/, { timeout: 15_000 });
|
|
200
|
+
await page.waitForLoadState('networkidle', { timeout: 10_000 });
|
|
201
|
+
|
|
202
|
+
const rawCookies = await context.cookies();
|
|
203
|
+
const cookies: Cookie[] = rawCookies.map((c) => ({
|
|
204
|
+
name: c.name,
|
|
205
|
+
value: c.value,
|
|
206
|
+
domain: c.domain,
|
|
207
|
+
path: c.path,
|
|
208
|
+
expires: c.expires,
|
|
209
|
+
httpOnly: c.httpOnly,
|
|
210
|
+
secure: c.secure,
|
|
211
|
+
sameSite: c.sameSite,
|
|
212
|
+
}));
|
|
213
|
+
|
|
214
|
+
if (!cookies.some(c => c.name === 'JSESSIONID' || c.name === 'BbRouter')) {
|
|
215
|
+
throw new SilentLoginFailed('Redirect succeeded but session cookies are missing');
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
const session: Session = {
|
|
219
|
+
cookies,
|
|
220
|
+
xsrfToken: extractXsrf(cookies),
|
|
221
|
+
userId: previousSession?.userId,
|
|
222
|
+
userName: previousSession?.userName,
|
|
223
|
+
expiresAt: extractBbRouterExpiry(cookies),
|
|
224
|
+
};
|
|
225
|
+
|
|
226
|
+
saveSession(session);
|
|
227
|
+
return session;
|
|
228
|
+
} catch (err: any) {
|
|
229
|
+
if (err instanceof SilentLoginFailed) throw err;
|
|
230
|
+
throw new SilentLoginFailed(err.message ?? 'Timed out — SSO session likely expired');
|
|
231
|
+
} finally {
|
|
232
|
+
await context.close();
|
|
138
233
|
}
|
|
139
234
|
}
|
package/src/auth/session.ts
CHANGED
|
@@ -42,3 +42,25 @@ export function isSessionValid(session: Session | null): boolean {
|
|
|
42
42
|
session.cookies.some(c => c.name === 'BbRouter');
|
|
43
43
|
return hasCriticalCookies;
|
|
44
44
|
}
|
|
45
|
+
|
|
46
|
+
export async function loadOrRefreshSession(): Promise<Session | null> {
|
|
47
|
+
// 1. Session still valid — return directly
|
|
48
|
+
const session = loadSession();
|
|
49
|
+
if (session !== null) return session;
|
|
50
|
+
|
|
51
|
+
// 2. Session expired — read raw file to preserve userId/userName for silent refresh
|
|
52
|
+
let expiredSession: Session | null = null;
|
|
53
|
+
try {
|
|
54
|
+
const raw = fs.readFileSync(SESSION_FILE, 'utf-8');
|
|
55
|
+
expiredSession = JSON.parse(raw);
|
|
56
|
+
} catch {}
|
|
57
|
+
|
|
58
|
+
// Dynamic import to avoid circular dependency (login.ts imports from session.ts)
|
|
59
|
+
const { silentRelogin, SilentLoginFailed } = await import('./login.js');
|
|
60
|
+
try {
|
|
61
|
+
return await silentRelogin(expiredSession);
|
|
62
|
+
} catch (err) {
|
|
63
|
+
if (err instanceof SilentLoginFailed) return null; // SSO expired — caller must prompt for login
|
|
64
|
+
throw err;
|
|
65
|
+
}
|
|
66
|
+
}
|
package/src/commands/login.ts
CHANGED
|
@@ -3,7 +3,7 @@ import inquirer from 'inquirer';
|
|
|
3
3
|
import chalk from 'chalk';
|
|
4
4
|
import ora from 'ora';
|
|
5
5
|
import { login } from '../auth/login.js';
|
|
6
|
-
import { loadSession, clearSession, isSessionValid } from '../auth/session.js';
|
|
6
|
+
import { loadSession, loadOrRefreshSession, clearSession, isSessionValid } from '../auth/session.js';
|
|
7
7
|
import { ok, fail, warn, whatNext } from '../ui/theme.js';
|
|
8
8
|
|
|
9
9
|
export function loginCommand(program: Command) {
|
|
@@ -34,7 +34,9 @@ export function loginCommand(program: Command) {
|
|
|
34
34
|
password: opts.password,
|
|
35
35
|
});
|
|
36
36
|
|
|
37
|
-
|
|
37
|
+
const remainingMin = Math.round((session.expiresAt - Date.now()) / 60_000);
|
|
38
|
+
const remainingHr = (remainingMin / 60).toFixed(1);
|
|
39
|
+
console.log(ok(`Sesión guardada — expira en ${remainingHr}h`));
|
|
38
40
|
if (session.userName) console.log(chalk.gray(` Usuario: ${session.userName}`));
|
|
39
41
|
if (session.userId) console.log(chalk.gray(` ID: ${session.userId}`));
|
|
40
42
|
whatNext();
|
|
@@ -56,7 +58,7 @@ export function loginCommand(program: Command) {
|
|
|
56
58
|
.command('whoami')
|
|
57
59
|
.description('Show current logged-in user')
|
|
58
60
|
.action(async () => {
|
|
59
|
-
const session =
|
|
61
|
+
const session = await loadOrRefreshSession();
|
|
60
62
|
if (!isSessionValid(session)) {
|
|
61
63
|
console.log(chalk.red('Not logged in. Run: blackboard login'));
|
|
62
64
|
process.exit(1);
|
package/src/index.ts
CHANGED
|
@@ -6,7 +6,7 @@ import { coursesCommand } from './commands/courses.js';
|
|
|
6
6
|
import { apiDocsCommand } from './commands/api-docs.js';
|
|
7
7
|
import { downloadCommand } from './commands/download.js';
|
|
8
8
|
import { assignmentsCommand } from './commands/assignments.js';
|
|
9
|
-
import { loadSession, isSessionValid } from './auth/session.js';
|
|
9
|
+
import { loadSession, loadOrRefreshSession, isSessionValid } from './auth/session.js';
|
|
10
10
|
import { createClient } from './api/client.js';
|
|
11
11
|
import { getMe, getSystemVersion } from './api/courses.js';
|
|
12
12
|
import { BANNER, ok, fail, hint } from './ui/theme.js';
|
|
@@ -16,7 +16,7 @@ const program = new Command();
|
|
|
16
16
|
program
|
|
17
17
|
.name('blackboard')
|
|
18
18
|
.description('CLI no oficial para UPC Aula Virtual (Blackboard Learn)')
|
|
19
|
-
.version('1.0.
|
|
19
|
+
.version('1.0.7')
|
|
20
20
|
.addHelpText('beforeAll', BANNER);
|
|
21
21
|
|
|
22
22
|
// Auth commands
|
|
@@ -31,7 +31,7 @@ program
|
|
|
31
31
|
.description('Estado de sesión y versión del servidor')
|
|
32
32
|
.option('--json', 'Output raw JSON')
|
|
33
33
|
.action(async (opts) => {
|
|
34
|
-
const session =
|
|
34
|
+
const session = await loadOrRefreshSession();
|
|
35
35
|
const valid = isSessionValid(session);
|
|
36
36
|
|
|
37
37
|
const sysVersion = await getSystemVersion(
|
|
@@ -75,7 +75,7 @@ program
|
|
|
75
75
|
.option('-b, --body <json>', 'Cuerpo JSON para POST/PUT')
|
|
76
76
|
.option('-q, --query <params>', 'Query params (ej: "limit=10&offset=0")')
|
|
77
77
|
.action(async (method: string, apiPath: string, opts) => {
|
|
78
|
-
const session =
|
|
78
|
+
const session = await loadOrRefreshSession();
|
|
79
79
|
if (!isSessionValid(session)) {
|
|
80
80
|
console.error(JSON.stringify({ error: 'Not authenticated. Run: blackboard login' }));
|
|
81
81
|
process.exit(1);
|
package/src/mcp/server.ts
CHANGED
|
@@ -3,7 +3,7 @@ import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js'
|
|
|
3
3
|
import { z } from 'zod';
|
|
4
4
|
import fs from 'fs';
|
|
5
5
|
import path from 'path';
|
|
6
|
-
import {
|
|
6
|
+
import { loadOrRefreshSession, isSessionValid } from '../auth/session.js';
|
|
7
7
|
import { createClient } from '../api/client.js';
|
|
8
8
|
import {
|
|
9
9
|
getMe,
|
|
@@ -15,10 +15,18 @@ import {
|
|
|
15
15
|
getGradeColumns,
|
|
16
16
|
getSystemVersion,
|
|
17
17
|
} from '../api/courses.js';
|
|
18
|
-
import { listAssignments, listAttempts, submitAttempt, uploadFile } from '../api/assignments.js';
|
|
18
|
+
import { listAssignments, listAttempts, submitAttempt, uploadFile, getAttemptFiles } from '../api/assignments.js';
|
|
19
|
+
import {
|
|
20
|
+
getQuizQuestions,
|
|
21
|
+
saveQuizAnswer,
|
|
22
|
+
submitQuizAttempt,
|
|
23
|
+
getQuizColumnId,
|
|
24
|
+
parseQuizUrl,
|
|
25
|
+
type QuizQuestion,
|
|
26
|
+
} from '../api/quiz.js';
|
|
19
27
|
|
|
20
|
-
function getClient() {
|
|
21
|
-
const session =
|
|
28
|
+
async function getClient() {
|
|
29
|
+
const session = await loadOrRefreshSession();
|
|
22
30
|
if (!isSessionValid(session)) {
|
|
23
31
|
throw new Error('Not authenticated. Ask the user to run: blackboard login');
|
|
24
32
|
}
|
|
@@ -33,21 +41,21 @@ export async function startMcpServer() {
|
|
|
33
41
|
|
|
34
42
|
// ── whoami ─────────────────────────────────────────────────────────────────
|
|
35
43
|
server.registerTool('whoami', { description: 'Get the currently authenticated UPC student info' }, async () => {
|
|
36
|
-
const { client } = getClient();
|
|
44
|
+
const { client } = await getClient();
|
|
37
45
|
const me = await getMe(client);
|
|
38
46
|
return { content: [{ type: 'text', text: JSON.stringify(me, null, 2) }] };
|
|
39
47
|
});
|
|
40
48
|
|
|
41
49
|
// ── system_version ─────────────────────────────────────────────────────────
|
|
42
50
|
server.registerTool('system_version', { description: 'Get Blackboard Learn server version' }, async () => {
|
|
43
|
-
const { client } = getClient();
|
|
51
|
+
const { client } = await getClient();
|
|
44
52
|
const v = await getSystemVersion(client);
|
|
45
53
|
return { content: [{ type: 'text', text: JSON.stringify(v, null, 2) }] };
|
|
46
54
|
});
|
|
47
55
|
|
|
48
56
|
// ── list_courses ────────────────────────────────────────────────────────────
|
|
49
57
|
server.registerTool('list_courses', { description: 'List all enrolled courses for the current student' }, async () => {
|
|
50
|
-
const { client, session } = getClient();
|
|
58
|
+
const { client, session } = await getClient();
|
|
51
59
|
let userId = session.userId;
|
|
52
60
|
if (!userId) { const me = await getMe(client); userId = me.id; }
|
|
53
61
|
const data = await getMyCourses(client, userId!, { limit: 50 });
|
|
@@ -62,7 +70,7 @@ export async function startMcpServer() {
|
|
|
62
70
|
inputSchema: { courseId: z.string().describe('Blackboard course ID like _529580_1') },
|
|
63
71
|
},
|
|
64
72
|
async ({ courseId }) => {
|
|
65
|
-
const { client } = getClient();
|
|
73
|
+
const { client } = await getClient();
|
|
66
74
|
const data = await getCourse(client, courseId);
|
|
67
75
|
return { content: [{ type: 'text', text: JSON.stringify(data, null, 2) }] };
|
|
68
76
|
}
|
|
@@ -79,7 +87,7 @@ export async function startMcpServer() {
|
|
|
79
87
|
},
|
|
80
88
|
},
|
|
81
89
|
async ({ courseId, parentId }) => {
|
|
82
|
-
const { client } = getClient();
|
|
90
|
+
const { client } = await getClient();
|
|
83
91
|
const data = await getCourseContents(client, courseId, parentId);
|
|
84
92
|
return { content: [{ type: 'text', text: JSON.stringify(data, null, 2) }] };
|
|
85
93
|
}
|
|
@@ -93,7 +101,7 @@ export async function startMcpServer() {
|
|
|
93
101
|
inputSchema: { courseId: z.string().describe('Blackboard course ID') },
|
|
94
102
|
},
|
|
95
103
|
async ({ courseId }) => {
|
|
96
|
-
const { client } = getClient();
|
|
104
|
+
const { client } = await getClient();
|
|
97
105
|
const data = await getCourseAnnouncements(client, courseId);
|
|
98
106
|
return { content: [{ type: 'text', text: JSON.stringify(data, null, 2) }] };
|
|
99
107
|
}
|
|
@@ -107,7 +115,7 @@ export async function startMcpServer() {
|
|
|
107
115
|
inputSchema: { courseId: z.string().describe('Blackboard course ID') },
|
|
108
116
|
},
|
|
109
117
|
async ({ courseId }) => {
|
|
110
|
-
const { client } = getClient();
|
|
118
|
+
const { client } = await getClient();
|
|
111
119
|
const data = await listAssignments(client, courseId);
|
|
112
120
|
return { content: [{ type: 'text', text: JSON.stringify(data, null, 2) }] };
|
|
113
121
|
}
|
|
@@ -124,7 +132,7 @@ export async function startMcpServer() {
|
|
|
124
132
|
},
|
|
125
133
|
},
|
|
126
134
|
async ({ courseId, columnId }) => {
|
|
127
|
-
const { client } = getClient();
|
|
135
|
+
const { client } = await getClient();
|
|
128
136
|
const data = await listAttempts(client, courseId, columnId);
|
|
129
137
|
return { content: [{ type: 'text', text: JSON.stringify(data, null, 2) }] };
|
|
130
138
|
}
|
|
@@ -138,7 +146,7 @@ export async function startMcpServer() {
|
|
|
138
146
|
inputSchema: { courseId: z.string().describe('Blackboard course ID') },
|
|
139
147
|
},
|
|
140
148
|
async ({ courseId }) => {
|
|
141
|
-
const { client, session } = getClient();
|
|
149
|
+
const { client, session } = await getClient();
|
|
142
150
|
let userId = session.userId;
|
|
143
151
|
if (!userId) { const me = await getMe(client); userId = me.id; }
|
|
144
152
|
const [columns, grades] = await Promise.all([
|
|
@@ -168,7 +176,7 @@ export async function startMcpServer() {
|
|
|
168
176
|
},
|
|
169
177
|
},
|
|
170
178
|
async ({ courseId, contentId, attachmentId, filename, outputDir }) => {
|
|
171
|
-
const { client } = getClient();
|
|
179
|
+
const { client } = await getClient();
|
|
172
180
|
|
|
173
181
|
const url = attachmentId.startsWith('http')
|
|
174
182
|
? attachmentId
|
|
@@ -208,7 +216,7 @@ export async function startMcpServer() {
|
|
|
208
216
|
},
|
|
209
217
|
},
|
|
210
218
|
async ({ courseId, contentId }) => {
|
|
211
|
-
const { client } = getClient();
|
|
219
|
+
const { client } = await getClient();
|
|
212
220
|
|
|
213
221
|
// Try standard REST attachments endpoint first (works for x-bb-file)
|
|
214
222
|
try {
|
|
@@ -271,7 +279,7 @@ export async function startMcpServer() {
|
|
|
271
279
|
},
|
|
272
280
|
},
|
|
273
281
|
async ({ url, filename, outputDir }) => {
|
|
274
|
-
const { client } = getClient();
|
|
282
|
+
const { client } = await getClient();
|
|
275
283
|
const r = await client.get(url, { responseType: 'arraybuffer', headers: { Accept: '*/*' } });
|
|
276
284
|
|
|
277
285
|
const contentDisposition = r.headers['content-disposition'] as string | undefined;
|
|
@@ -308,7 +316,7 @@ export async function startMcpServer() {
|
|
|
308
316
|
},
|
|
309
317
|
},
|
|
310
318
|
async ({ courseId, columnId, studentComments, studentSubmission }) => {
|
|
311
|
-
const { client } = getClient();
|
|
319
|
+
const { client } = await getClient();
|
|
312
320
|
const attempt = await submitAttempt(client, courseId, columnId, {
|
|
313
321
|
studentComments,
|
|
314
322
|
studentSubmission,
|
|
@@ -318,6 +326,228 @@ export async function startMcpServer() {
|
|
|
318
326
|
}
|
|
319
327
|
);
|
|
320
328
|
|
|
329
|
+
// ── get_quiz_questions ──────────────────────────────────────────────────────
|
|
330
|
+
server.registerTool(
|
|
331
|
+
'get_quiz_questions',
|
|
332
|
+
{
|
|
333
|
+
description:
|
|
334
|
+
'Fetch all questions and answer options from a Blackboard Ultra quiz attempt. ' +
|
|
335
|
+
'Provide either a full quiz URL, or courseId + contentId + attemptId. ' +
|
|
336
|
+
'Returns each question with its type, text, options, and current saved answer.',
|
|
337
|
+
inputSchema: {
|
|
338
|
+
url: z
|
|
339
|
+
.string()
|
|
340
|
+
.optional()
|
|
341
|
+
.describe(
|
|
342
|
+
'Full Ultra quiz URL, e.g. https://aulavirtual.upc.edu.pe/ultra/stream/assessment/_69146765_1/overview/attempt/_94898825_1?courseId=_529533_1'
|
|
343
|
+
),
|
|
344
|
+
courseId: z.string().optional().describe('Course ID (e.g. _529533_1) — required if url not given'),
|
|
345
|
+
contentId: z.string().optional().describe('Quiz content item ID (e.g. _69146765_1) — required if url not given'),
|
|
346
|
+
attemptId: z.string().optional().describe('Attempt ID (e.g. _94898825_1) — required if url not given'),
|
|
347
|
+
},
|
|
348
|
+
},
|
|
349
|
+
async ({ url, courseId, contentId, attemptId }) => {
|
|
350
|
+
const { client, session } = await getClient();
|
|
351
|
+
|
|
352
|
+
// Resolve IDs from URL or direct params
|
|
353
|
+
let resolvedCourseId = courseId;
|
|
354
|
+
let resolvedContentId = contentId;
|
|
355
|
+
let resolvedAttemptId = attemptId;
|
|
356
|
+
|
|
357
|
+
if (url) {
|
|
358
|
+
const parsed = parseQuizUrl(url);
|
|
359
|
+
resolvedCourseId = resolvedCourseId || parsed.courseId;
|
|
360
|
+
resolvedContentId = resolvedContentId || parsed.contentId;
|
|
361
|
+
resolvedAttemptId = resolvedAttemptId || parsed.attemptId;
|
|
362
|
+
}
|
|
363
|
+
|
|
364
|
+
if (!resolvedCourseId || !resolvedContentId || !resolvedAttemptId) {
|
|
365
|
+
throw new Error(
|
|
366
|
+
'Provide either a full quiz URL or all three of: courseId, contentId, attemptId'
|
|
367
|
+
);
|
|
368
|
+
}
|
|
369
|
+
|
|
370
|
+
// Get columnId + attempt policy — reuse session already obtained above
|
|
371
|
+
const policy = await getQuizColumnId(client, resolvedCourseId, resolvedContentId, session.userId);
|
|
372
|
+
|
|
373
|
+
if (!policy.canAttempt) {
|
|
374
|
+
return {
|
|
375
|
+
content: [{
|
|
376
|
+
type: 'text',
|
|
377
|
+
text: JSON.stringify({
|
|
378
|
+
error: 'NO_ATTEMPTS_LEFT',
|
|
379
|
+
message: `No quedan intentos para este cuestionario. ${policy.attemptSummary}`,
|
|
380
|
+
policy,
|
|
381
|
+
}, null, 2),
|
|
382
|
+
}],
|
|
383
|
+
};
|
|
384
|
+
}
|
|
385
|
+
|
|
386
|
+
const info = await getQuizQuestions(client, resolvedCourseId, policy.columnId, resolvedAttemptId);
|
|
387
|
+
return {
|
|
388
|
+
content: [{
|
|
389
|
+
type: 'text',
|
|
390
|
+
text: JSON.stringify({ attemptPolicy: policy, ...info }, null, 2),
|
|
391
|
+
}],
|
|
392
|
+
};
|
|
393
|
+
}
|
|
394
|
+
);
|
|
395
|
+
|
|
396
|
+
// ── save_quiz_answer ────────────────────────────────────────────────────────
|
|
397
|
+
server.registerTool(
|
|
398
|
+
'save_quiz_answer',
|
|
399
|
+
{
|
|
400
|
+
description:
|
|
401
|
+
'Save a single answer for a quiz question (does NOT submit — use submit_quiz to finalize). ' +
|
|
402
|
+
'question is the full question object from get_quiz_questions. ' +
|
|
403
|
+
'answer: boolean for true/false, or 0-based index number for multiple-choice.',
|
|
404
|
+
inputSchema: {
|
|
405
|
+
courseId: z.string().describe('Course ID'),
|
|
406
|
+
attemptId: z.string().describe('Quiz attempt ID (e.g. _94898825_1)'),
|
|
407
|
+
question: z.string().describe('JSON string of the question object from get_quiz_questions'),
|
|
408
|
+
answer: z.union([z.boolean(), z.number()]).describe(
|
|
409
|
+
'For true/false: true or false. For multiple-choice: 0-based index of selected option.'
|
|
410
|
+
),
|
|
411
|
+
},
|
|
412
|
+
},
|
|
413
|
+
async ({ courseId, attemptId, question: questionJson, answer }) => {
|
|
414
|
+
const { client } = await getClient();
|
|
415
|
+
const question: QuizQuestion = JSON.parse(questionJson);
|
|
416
|
+
const result = await saveQuizAnswer(client, courseId, attemptId, question, answer as boolean | number);
|
|
417
|
+
return { content: [{ type: 'text', text: JSON.stringify(result, null, 2) }] };
|
|
418
|
+
}
|
|
419
|
+
);
|
|
420
|
+
|
|
421
|
+
// ── submit_quiz ─────────────────────────────────────────────────────────────
|
|
422
|
+
server.registerTool(
|
|
423
|
+
'submit_quiz',
|
|
424
|
+
{
|
|
425
|
+
description:
|
|
426
|
+
'Finalize and submit a quiz attempt. ALWAYS confirm with the user before calling this. ' +
|
|
427
|
+
'All individual answers should be saved first via save_quiz_answer.',
|
|
428
|
+
inputSchema: {
|
|
429
|
+
courseId: z.string().describe('Course ID'),
|
|
430
|
+
attemptId: z.string().describe('Quiz attempt ID to submit'),
|
|
431
|
+
},
|
|
432
|
+
},
|
|
433
|
+
async ({ courseId, attemptId }) => {
|
|
434
|
+
const { client } = await getClient();
|
|
435
|
+
const result = await submitQuizAttempt(client, courseId, attemptId);
|
|
436
|
+
return { content: [{ type: 'text', text: JSON.stringify(result, null, 2) }] };
|
|
437
|
+
}
|
|
438
|
+
);
|
|
439
|
+
|
|
440
|
+
// ── get_assignment_feedback ─────────────────────────────────────────────────
|
|
441
|
+
server.registerTool(
|
|
442
|
+
'get_assignment_feedback',
|
|
443
|
+
{
|
|
444
|
+
description:
|
|
445
|
+
'Get professor feedback and scores for all assignments in a course. ' +
|
|
446
|
+
'For each graded submission, shows score, instructor comments, and any feedback files attached by the professor.',
|
|
447
|
+
inputSchema: { courseId: z.string().describe('Blackboard course ID') },
|
|
448
|
+
},
|
|
449
|
+
async ({ courseId }) => {
|
|
450
|
+
const { client, session } = await getClient();
|
|
451
|
+
|
|
452
|
+
const assignments = await listAssignments(client, courseId);
|
|
453
|
+
|
|
454
|
+
const results = await Promise.all(
|
|
455
|
+
assignments.map(async (col) => {
|
|
456
|
+
try {
|
|
457
|
+
const attempts = await listAttempts(client, courseId, col.id);
|
|
458
|
+
if (!attempts.length) {
|
|
459
|
+
return { assignment: col.name, columnId: col.id, status: 'no_attempts' };
|
|
460
|
+
}
|
|
461
|
+
|
|
462
|
+
// Most recent attempt first
|
|
463
|
+
const latest = attempts.sort((a, b) =>
|
|
464
|
+
(b.attemptDate ?? b.modified ?? '').localeCompare(a.attemptDate ?? a.modified ?? '')
|
|
465
|
+
)[0];
|
|
466
|
+
|
|
467
|
+
// Try to get feedback files (professor may have attached annotated docs)
|
|
468
|
+
let feedbackFiles: any[] = [];
|
|
469
|
+
try {
|
|
470
|
+
feedbackFiles = await getAttemptFiles(client, courseId, col.id, latest.id);
|
|
471
|
+
} catch {}
|
|
472
|
+
|
|
473
|
+
return {
|
|
474
|
+
assignment: col.name,
|
|
475
|
+
columnId: col.id,
|
|
476
|
+
contentId: col.contentId,
|
|
477
|
+
due: col.grading?.due,
|
|
478
|
+
maxScore: col.score?.possible,
|
|
479
|
+
attempt: {
|
|
480
|
+
id: latest.id,
|
|
481
|
+
status: latest.status,
|
|
482
|
+
score: latest.score,
|
|
483
|
+
grade: latest.displayGrade?.text,
|
|
484
|
+
submittedAt: latest.attemptDate ?? latest.modified,
|
|
485
|
+
// Professor feedback — field name varies by BB version
|
|
486
|
+
instructorFeedback:
|
|
487
|
+
latest.text ?? latest.instructorFeedback ?? latest.feedback ?? null,
|
|
488
|
+
studentComments: latest.studentComments ?? null,
|
|
489
|
+
feedbackFiles: feedbackFiles.map((f) => ({
|
|
490
|
+
id: f.id,
|
|
491
|
+
name: f.name,
|
|
492
|
+
mimeType: f.mimeType,
|
|
493
|
+
size: f.size,
|
|
494
|
+
})),
|
|
495
|
+
},
|
|
496
|
+
};
|
|
497
|
+
} catch {
|
|
498
|
+
return { assignment: col.name, columnId: col.id, status: 'error_fetching' };
|
|
499
|
+
}
|
|
500
|
+
})
|
|
501
|
+
);
|
|
502
|
+
|
|
503
|
+
return { content: [{ type: 'text', text: JSON.stringify(results, null, 2) }] };
|
|
504
|
+
}
|
|
505
|
+
);
|
|
506
|
+
|
|
507
|
+
// ── download_feedback_file ───────────────────────────────────────────────────
|
|
508
|
+
server.registerTool(
|
|
509
|
+
'download_feedback_file',
|
|
510
|
+
{
|
|
511
|
+
description:
|
|
512
|
+
'[EXPERIMENTAL] Download a feedback file that a professor attached to a graded attempt. ' +
|
|
513
|
+
'Use the fileId from get_assignment_feedback → attempt.feedbackFiles. ' +
|
|
514
|
+
'The download endpoint may not be available on all Blackboard versions.',
|
|
515
|
+
inputSchema: {
|
|
516
|
+
courseId: z.string().describe('Blackboard course ID'),
|
|
517
|
+
columnId: z.string().describe('Gradebook column (assignment) ID'),
|
|
518
|
+
attemptId: z.string().describe('Attempt ID from get_assignment_feedback'),
|
|
519
|
+
fileId: z.string().describe('File ID from get_assignment_feedback → attempt.feedbackFiles'),
|
|
520
|
+
filename: z.string().optional().describe('Filename to save as (defaults to the name from feedbackFiles)'),
|
|
521
|
+
outputDir: z.string().optional().describe('Directory to save the file (default: current working directory)'),
|
|
522
|
+
},
|
|
523
|
+
},
|
|
524
|
+
async ({ courseId, columnId, attemptId, fileId, filename, outputDir }) => {
|
|
525
|
+
const { client } = await getClient();
|
|
526
|
+
|
|
527
|
+
const url = `/learn/api/public/v2/courses/${courseId}/gradebook/columns/${columnId}/attempts/${attemptId}/files/${fileId}/download`;
|
|
528
|
+
const r = await client.get(url, { responseType: 'arraybuffer', headers: { Accept: '*/*' } });
|
|
529
|
+
|
|
530
|
+
const contentDisposition = r.headers['content-disposition'] as string | undefined;
|
|
531
|
+
const detectedName = contentDisposition
|
|
532
|
+
? (contentDisposition.match(/filename[^;=\n]*=((['"]).*?\2|[^;\n]*)/))?.[1]?.replace(/['"]/g, '').trim()
|
|
533
|
+
: undefined;
|
|
534
|
+
const finalName = filename ?? detectedName ?? `feedback_${fileId}`;
|
|
535
|
+
|
|
536
|
+
const dir = path.resolve(outputDir ?? process.cwd());
|
|
537
|
+
if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true });
|
|
538
|
+
const dest = path.join(dir, finalName);
|
|
539
|
+
fs.writeFileSync(dest, Buffer.from(r.data));
|
|
540
|
+
|
|
541
|
+
const mimeType = (r.headers['content-type'] as string | undefined) ?? 'application/octet-stream';
|
|
542
|
+
return {
|
|
543
|
+
content: [{
|
|
544
|
+
type: 'text',
|
|
545
|
+
text: JSON.stringify({ saved: dest, size: r.data.byteLength, mimeType }, null, 2),
|
|
546
|
+
}],
|
|
547
|
+
};
|
|
548
|
+
}
|
|
549
|
+
);
|
|
550
|
+
|
|
321
551
|
// ── raw_api ─────────────────────────────────────────────────────────────────
|
|
322
552
|
server.registerTool(
|
|
323
553
|
'raw_api',
|
|
@@ -331,7 +561,7 @@ export async function startMcpServer() {
|
|
|
331
561
|
},
|
|
332
562
|
},
|
|
333
563
|
async ({ method, path, query, body }) => {
|
|
334
|
-
const { client } = getClient();
|
|
564
|
+
const { client } = await getClient();
|
|
335
565
|
const params = query ? Object.fromEntries(new URLSearchParams(query)) : undefined;
|
|
336
566
|
const data = body ? JSON.parse(body) : undefined;
|
|
337
567
|
const r = await client.request({ method: method.toLowerCase() as any, url: path, params, data });
|