utn-mcp 0.8.1 → 0.8.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -19,6 +19,7 @@ El agente recorre el aula como lo harías vos: entra al curso, mira los foros y
19
19
  | `aula_list_courses` | Lista tus cursos. Es el punto de partida. |
20
20
  | `aula_open` | Entra a un curso, un módulo, un foro o una discusión. Cada respuesta trae las direcciones de lo que hay adentro y de un nivel más arriba, así el agente navega sin perderse. |
21
21
  | `aula_read_file` | Lee un archivo del aula, por partes cuando es largo. |
22
+ | `aula_study_pack` | Une en un solo PDF con índice los apuntes que elijas de una materia (un 'machacado'), guardado en tu compu. Solo en modo local. |
22
23
  | `aula_get_site_info` | Datos de tu usuario y de tu regional en el aula virtual. |
23
24
  | `aula_forum_post` | Abre un hilo en un foro o responde en una discusión. |
24
25
  | `aula_forum_edit_post` | Edita una publicación tuya. |
@@ -120,7 +121,7 @@ utn-mcp login
120
121
  y en la config del cliente usás `"command": "utn-mcp"` sin argumentos.
121
122
 
122
123
  ### Regionales
123
- La regional se elige al iniciar sesión, de una lista con las regionales de la UTN. Buenos Aires (FRBA), Córdoba (FRC), Resistencia (FRRE) y Tucumán (FRT) ya están probadas. También está el aula de la Tecnicatura en Programación a distancia del SIED (TUP). El login se adapta solo: si el aula entra por SSO, sea un Keycloak propio (FRBA) u Office 365 (Haedo, FRH, y San Nicolás, FRSN), usa el SSO; si no, el login directo de Moodle. Con Office 365 todavía no se pueden completar cuentas que piden segundo factor (MFA).
124
+ La regional se elige al iniciar sesión, de una lista con las regionales de la UTN. Buenos Aires (FRBA), Córdoba (FRC), Resistencia (FRRE) y Tucumán (FRT) ya están probadas. También está el aula de la Tecnicatura en Programación a distancia del SIED (TUP). El login se adapta solo: si el aula entra por SSO, sea un Keycloak propio (FRBA) u Office 365 (Haedo, FRH, y San Nicolás, FRSN), usa el SSO; si no, el login directo de Moodle. Si tu cuenta de Office 365 pide segundo factor (MFA), el login te pregunta con qué método seguir y te pide el código o que apruebes en el celular. Como el servidor renueva la sesión sin vos delante, con MFA te va a pedir que vuelvas a iniciar sesión cuando venza (dura semanas).
124
125
 
125
126
  La lista puede traer regionales que nadie probó todavía: su aula tiene habilitado el servicio que usa UTN-MCP, así que casi seguro anda. En un servidor hosteado, la primera persona que entra con una de esas la deja verificada para el resto. Las que no tienen el servicio habilitado aparecen como "todavía sin soporte": se pueden elegir, pero el login avisa que por ahora no se puede usar y no manda la contraseña.
126
127
 
@@ -160,9 +161,10 @@ La configuración va en variables de entorno o en un `.env` en la carpeta desde
160
161
  | `UTN_MCP_ACCESS_URL`, `UTN_MCP_ACCESS_SECRET` | Servicio que decide quién puede entrar. | — |
161
162
  | `UTN_MCP_ALLOWED_EMAILS` | Emails o dominios (`@frba.utn.edu.ar`) habilitados, separados por coma, si no hay `UTN_MCP_ACCESS_URL`. | Entra cualquiera con usuario de la UTN |
162
163
  | `UTN_MCP_REDIRECT_ALLOW` | Hosts (`app.ejemplo.com`) o esquemas (`miapp:`) extra, separados por coma, a los que puede volver el login de OAuth. Loopback, Claude, ChatGPT, VS Code y Cursor pasan siempre. Cualquier otro host HTTPS (un agente propio, por ejemplo) se acepta, pero el login avisa a dónde vuelve y el alumno tiene que marcar que lo conecta bajo su responsabilidad: es la defensa contra alguien que use tu página de login para quedarse con su sesión. Los hosts de esta lista pasan sin aviso. | — |
163
- | `UTN_MCP_ADMIN_SECRET` | Habilita la API de `/admin` para un panel de cuentas tuyo: `POST /admin/usuarios` (todos los usuarios con sus apps), `/admin/clientes` y `/admin/clientes/revocar` (las apps de un usuario, por email), `/admin/regionales` (las regionales con su soporte) y `/admin/verificar` (entra al aula con usuario y contraseña, como el login de OAuth, y le guarda la sesión). | — |
164
+ | `UTN_MCP_ADMIN_SECRET` | Habilita la API de `/admin` para un panel de cuentas tuyo: `POST /admin/usuarios` (todos los usuarios con sus apps), `/admin/clientes` y `/admin/clientes/revocar` (las apps de un usuario, por email), `/admin/regionales` (las regionales con su soporte) y `/admin/verificar` (entra al aula con usuario y contraseña, como el login de OAuth, y le guarda la sesión; si Microsoft pide segundo factor, devuelve el paso y se sigue con `/admin/segundo-factor`). | — |
164
165
  | `UTN_MCP_LOGIN_URL` | Página donde el usuario renueva su sesión del aula; el agente se la pasa. | `<UTN_MCP_PUBLIC_URL>/login` |
165
166
  | `UTN_MCP_DB`, `UTN_MCP_KEY` | Base de sesiones y su clave de cifrado (32 bytes en base64). | En la carpeta de config |
167
+ | `UTN_MCP_PACK_MAX_FILES`, `UTN_MCP_PACK_MAX_MB`, `UTN_MCP_PACK_MAX_FINAL_MB` | Topes del compilado de estudio (`aula_study_pack`, solo modo local): archivos por pack, MB de descarga total y MB del PDF final, que se arma entero en memoria. | 150, 300 y 30 |
166
168
 
167
169
  Con Docker, detrás de un reverse proxy con HTTPS en la misma máquina (red host, para que el rate limit del login vea la IP real):
168
170
  ```bash
@@ -27,6 +27,27 @@ export function getCourseSections(session, courseId, cmid) {
27
27
  }
28
28
  return callWs(session, "core_course_get_contents", params);
29
29
  }
30
+ /**
31
+ * Los archivos de las secciones y módulos visibles del curso, en el orden en que aparecen en el aula.
32
+ * Los mismos filtros de visibilidad que el mapa del curso de format/navigate.
33
+ */
34
+ export async function getCourseFiles(session, courseId) {
35
+ const sections = await getCourseSections(session, courseId);
36
+ const entries = [];
37
+ for (const section of sections) {
38
+ if (section.uservisible === false)
39
+ continue;
40
+ for (const module of section.modules) {
41
+ if (!module.uservisible)
42
+ continue;
43
+ for (const content of module.contents ?? []) {
44
+ if (content.type === "file")
45
+ entries.push({ section: section.name, module: module.name, file: content });
46
+ }
47
+ }
48
+ }
49
+ return entries;
50
+ }
30
51
  // --- Foros ---
31
52
  /** Cuántas discusiones trae una página del índice de un foro. */
32
53
  export const PAGE_SIZE = 50;
@@ -6,9 +6,10 @@ import { AULA_PLATFORM } from "./stored-session.js";
6
6
  * guarda la sesión (el token). La contraseña se usa solo acá: no se guarda en ningún lado.
7
7
  * Lo usan `utn-mcp login --http`, el login por OAuth de la versión hosteada y `/admin/verificar`.
8
8
  * `regional` tiene que venir con su soporte en este servidor (`availableRegionals` con las verificadas).
9
+ * `secondFactor` contesta el MFA de Microsoft si la cuenta lo pide.
9
10
  */
10
- export async function linkAulaAccount(store, regional, credentials) {
11
- const token = await fetchToken(regional, credentials);
11
+ export async function linkAulaAccount(store, regional, credentials, secondFactor) {
12
+ const token = await fetchToken(regional, credentials, secondFactor);
12
13
  // El token recién emitido: si falla, no hay con qué renovarlo.
13
14
  const fresh = {
14
15
  getRegional: async () => regional,
@@ -31,12 +31,12 @@ export class LocalSession {
31
31
  });
32
32
  return this.refreshing;
33
33
  }
34
- /** Obtiene un token nuevo y lo guarda. */
35
- async login(regional, credentials) {
34
+ /** Obtiene un token nuevo y lo guarda. Sin `secondFactor` (al renovar solo), una cuenta con MFA no entra. */
35
+ async login(regional, credentials, secondFactor) {
36
36
  if (!credentials)
37
37
  throw new LoginRequiredError();
38
38
  logger.info(`Obteniendo token del aula virtual de ${regional.name}...`);
39
- const token = await fetchToken(regional, credentials);
39
+ const token = await fetchToken(regional, credentials, secondFactor);
40
40
  await this.write({ token, obtainedAt: new Date().toISOString(), regional: regional.id });
41
41
  return token;
42
42
  }
@@ -34,23 +34,47 @@ export async function callWs(session, wsfunction, params = {}) {
34
34
  /**
35
35
  * Baja un archivo de `webservice/pluginfile.php` con el token; lo renueva una vez si venció.
36
36
  * Los errores llegan como JSON (`{ error, errorcode }`) en lugar del archivo.
37
+ * Los redirects se siguen a mano y SOLO al propio aula y a rutas de archivos (pluginfile):
38
+ * un pluginfile legítimo no redirige afuera, así que cualquier otro destino se rechaza.
37
39
  */
38
40
  export async function fetchFile(session, url, maxBytes) {
39
41
  let token = await session.getToken();
40
- for (let attempt = 0;; attempt++) {
41
- const withToken = new URL(url);
42
+ const { baseUrl } = await session.getRegional();
43
+ const aulaOrigin = new URL(baseUrl).origin;
44
+ let current = url;
45
+ let redirects = 0;
46
+ // Aparte de los redirects: uno antes del token vencido no tiene que gastar la renovación.
47
+ let refreshed = false;
48
+ for (;;) {
49
+ const withToken = new URL(current);
42
50
  withToken.searchParams.set("token", token);
43
51
  let res;
44
52
  try {
45
- res = await fetch(withToken, { signal: AbortSignal.timeout(60_000) });
53
+ res = await fetch(withToken, { signal: AbortSignal.timeout(60_000), redirect: "manual" });
46
54
  }
47
55
  catch (e) {
48
56
  throw new MoodleError(`No se pudo bajar el archivo: ${e.message}`);
49
57
  }
58
+ if (res.status >= 300 && res.status < 400) {
59
+ const location = res.headers.get("location");
60
+ const next = location ? new URL(location, current) : null;
61
+ redirects++;
62
+ if (redirects > 3 ||
63
+ !next ||
64
+ next.username ||
65
+ next.password ||
66
+ next.origin !== aulaOrigin ||
67
+ !next.pathname.includes("pluginfile.php")) {
68
+ throw new MoodleError(`El aula redirigió la descarga a una dirección no permitida (${location ?? "sin destino"})`);
69
+ }
70
+ current = next;
71
+ continue;
72
+ }
50
73
  const contentType = res.headers.get("content-type");
51
74
  if (contentType?.startsWith("application/json")) {
52
75
  const data = (await res.json());
53
- if (attempt === 0 && data.errorcode && TOKEN_ERRORS.has(data.errorcode)) {
76
+ if (!refreshed && data.errorcode && TOKEN_ERRORS.has(data.errorcode)) {
77
+ refreshed = true;
54
78
  token = await session.refreshToken();
55
79
  continue;
56
80
  }
@@ -1,4 +1,5 @@
1
1
  import { randomInt } from "node:crypto";
2
+ import { setTimeout as sleep } from "node:timers/promises";
2
3
  import { CookieJar } from "tough-cookie";
3
4
  import { UnsupportedRegionalError } from "./regionals.js";
4
5
  import { MoodleError } from "./moodle-api.js";
@@ -38,8 +39,10 @@ const PROVIDERS = {
38
39
  * plugin de mobile launch, que puede estar deshabilitado); si falla, flujo de launch.php.
39
40
  * Las cookies se usan solo durante el login y se descartan: las sesiones de Moodle y del SSO duran
40
41
  * horas y el token, semanas, así que no sirven para renovarlo.
42
+ * `secondFactor` contesta el MFA de Microsoft; sin él (las renovaciones solas), una cuenta con MFA da
43
+ * `MfaRequiredError`.
41
44
  */
42
- export async function fetchToken(regional, credentials) {
45
+ export async function fetchToken(regional, credentials, secondFactor) {
43
46
  if (regional.support === "unsupported")
44
47
  throw new UnsupportedRegionalError(regional);
45
48
  const { baseUrl, sso } = regional;
@@ -55,14 +58,14 @@ export async function fetchToken(regional, credentials) {
55
58
  let landing = await follow(jar, launchUrl);
56
59
  if ("token" in landing)
57
60
  return landing.token;
58
- const provider = sso && PROVIDERS[sso.provider];
59
- const ssoHref = provider && landing.html.match(provider.link)?.[1];
60
- if (sso && provider && ssoHref) {
61
+ const ssoProvider = sso ? PROVIDERS[sso.provider] : undefined;
62
+ const ssoHref = ssoProvider && landing.html.match(ssoProvider.link)?.[1];
63
+ if (sso && ssoProvider && ssoHref) {
61
64
  landing = await follow(jar, new URL(decodeHtml(ssoHref), landing.url).toString());
62
65
  if ("token" in landing)
63
66
  return landing.token;
64
67
  if (new URL(landing.url).host === sso.host) {
65
- landing = await provider.submit(jar, landing, credentials);
68
+ landing = await ssoProvider.submit(jar, landing, credentials, secondFactor);
66
69
  if ("token" in landing)
67
70
  return landing.token;
68
71
  if (new URL(landing.url).host === sso.host) {
@@ -70,7 +73,7 @@ export async function fetchToken(regional, credentials) {
70
73
  const viaTokenPhp = await tryTokenPhp(baseUrl, credentials);
71
74
  if (viaTokenPhp)
72
75
  return viaTokenPhp;
73
- throw new InvalidCredentialsError(provider.loginError(landing.html));
76
+ throw new InvalidCredentialsError(ssoProvider.loginError(landing.html));
74
77
  }
75
78
  }
76
79
  }
@@ -100,21 +103,44 @@ export async function fetchToken(regional, credentials) {
100
103
  }
101
104
  throw unexpected(landing.url);
102
105
  }
103
- export class MfaRequiredError extends Error {
104
- constructor(reason) {
105
- super("Tu cuenta de Microsoft pide un segundo factor (código, app o similar), que UTN-MCP todavía no puede " +
106
- "completar solo." + (reason ? ` Microsoft dijo: ${reason}` : ""));
106
+ /** La cuenta pide MFA y no hay quién lo conteste: pasa al renovar solo, sin el usuario delante. */
107
+ export class MfaRequiredError extends LoginRequiredError {
108
+ constructor() {
109
+ super("Tu cuenta de Microsoft pide un segundo factor (MFA), que solo se completa al iniciar sesión: " +
110
+ "corré `utn-mcp login`.");
107
111
  this.name = "MfaRequiredError";
108
112
  }
109
113
  }
110
- /** Cantidad máxima de pasos de Microsoft entre la contraseña y la vuelta al aula (KMSI y similares). */
111
- const MAX_MICROSOFT_STEPS = 6;
114
+ /** El segundo factor no se pudo completar; el mensaje dice por qué y se le puede mostrar al usuario. */
115
+ export class MfaError extends Error {
116
+ constructor(message) {
117
+ super(message);
118
+ this.name = "MfaError";
119
+ }
120
+ }
121
+ /** Los `authMethodId` que se completan sin navegador: los demás (FIDO, Windows Hello) necesitan uno. */
122
+ const MFA_METHODS = {
123
+ PhoneAppNotification: { kind: "approve", label: "Notificación de Microsoft Authenticator" },
124
+ CompanionAppsNotification: { kind: "approve", label: "Notificación de Microsoft Authenticator" },
125
+ PhoneAppOTP: { kind: "code", label: "Código de Microsoft Authenticator" },
126
+ OneWaySMS: { kind: "code", label: "Código por SMS" },
127
+ TwoWayVoiceMobile: { kind: "approve", label: "Llamada al celular" },
128
+ TwoWayVoiceAlternateMobile: { kind: "approve", label: "Llamada al celular alternativo" },
129
+ TwoWayVoiceOffice: { kind: "approve", label: "Llamada al teléfono de la oficina" },
130
+ };
131
+ /** Intentos de código antes de cortar: Microsoft también bloquea después de varios. */
132
+ const MAX_CODE_ATTEMPTS = 3;
133
+ /** Cada cuánto se pregunta si ya se aprobó, en milisegundos, y cuántas veces (un minuto en total). */
134
+ const APPROVE_POLL_MS = 2_000;
135
+ const APPROVE_POLLS = 30;
136
+ /** Cantidad máxima de pasos de Microsoft entre la contraseña y la vuelta al aula (MFA, KMSI y similares). */
137
+ const MAX_MICROSOFT_STEPS = 8;
112
138
  /**
113
139
  * Formulario de Office 365 (Microsoft Entra ID). No es un `<form>`: los datos están en el `$Config` de
114
- * la página. Después de la contraseña puede venir "¿Mantener la sesión iniciada?" antes del formulario
115
- * que vuelve al aula con el `code`. No completa MFA ni logins federados (ADFS).
140
+ * la página. Después de la contraseña pueden venir el segundo factor y "¿Mantener la sesión iniciada?"
141
+ * antes del formulario que vuelve al aula con el `code`. No completa logins federados (ADFS).
116
142
  */
117
- async function submitMicrosoftForm(jar, page, { username, password }) {
143
+ async function submitMicrosoftForm(jar, page, { username, password }, secondFactor) {
118
144
  const ssoHost = new URL(page.url).host;
119
145
  const cfg = parseAadConfig(page.html);
120
146
  if (!cfg?.urlPost || !cfg.sFT) {
@@ -144,8 +170,12 @@ async function submitMicrosoftForm(jar, page, { username, password }) {
144
170
  return follow(jar, action, { method: "POST", body: new URLSearchParams(form.inputs) });
145
171
  }
146
172
  const step = parseAadConfig(landing.html);
147
- if (step?.arrUserProofs?.length)
148
- throw new MfaRequiredError(step.strServiceExceptionMessage);
173
+ if (step?.arrUserProofs?.length) {
174
+ if (!secondFactor)
175
+ throw new MfaRequiredError();
176
+ landing = await completeMfa(jar, landing, step, username, secondFactor);
177
+ continue;
178
+ }
149
179
  if (step?.sErrorCode)
150
180
  return landing;
151
181
  if (step?.urlPost) {
@@ -169,6 +199,103 @@ async function submitMicrosoftForm(jar, page, { username, password }) {
169
199
  }
170
200
  throw new Error("Demasiados pasos en el login de Microsoft.");
171
201
  }
202
+ /**
203
+ * El segundo factor de Microsoft, con los mismos endpoints que usa su página: `BeginAuth` lo arranca
204
+ * (manda la notificación, el SMS o la llamada), `EndAuth` lo verifica (con el código, o preguntando si
205
+ * ya se aprobó) y `ProcessAuth` sigue el login, que vuelve a la página siguiente (KMSI o el aula).
206
+ */
207
+ async function completeMfa(jar, page, cfg, username, secondFactor) {
208
+ const proofs = cfg.arrUserProofs ?? [];
209
+ const methods = proofs.flatMap(({ authMethodId: id, display }) => {
210
+ const known = id ? MFA_METHODS[id] : undefined;
211
+ return id && known ? [{ id, kind: known.kind, label: display ? `${known.label} · ${display}` : known.label }] : [];
212
+ });
213
+ if (!methods.length) {
214
+ const ids = proofs.map((p) => p.authMethodId).join(", ");
215
+ throw new MfaError(`Tu cuenta de Microsoft solo tiene métodos de segundo factor que necesitan el navegador (${ids}). ` +
216
+ "Agregá Microsoft Authenticator o un teléfono en https://mysignins.microsoft.com/security-info y volvé a intentar.");
217
+ }
218
+ if (!cfg.urlBeginAuth || !cfg.urlEndAuth || !cfg.urlPost) {
219
+ throw new Error("No se encontró el formulario del segundo factor de Microsoft; puede que haya cambiado.");
220
+ }
221
+ const preferred = proofs.find((p) => p.isDefault)?.authMethodId;
222
+ methods.sort((a, b) => Number(b.id === preferred) - Number(a.id === preferred));
223
+ const chosenId = methods.length > 1 ? await secondFactor({ step: "choose", methods }) : methods[0].id;
224
+ const method = methods.find((m) => m.id === chosenId);
225
+ if (!method)
226
+ throw new MfaError("Ese método de segundo factor no está entre los de tu cuenta.");
227
+ const sasUrl = (url) => new URL(url, page.url).toString();
228
+ const begin = await postSas(jar, sasUrl(cfg.urlBeginAuth), {
229
+ AuthMethodId: method.id,
230
+ Method: "BeginAuth",
231
+ ctx: cfg.sCtx ?? "",
232
+ flowToken: cfg.sFT ?? "",
233
+ });
234
+ if (!begin.Success)
235
+ throw new MfaError(`Microsoft no pudo arrancar el segundo factor (${begin.Message || begin.ResultValue}).`);
236
+ const endAuth = (extra) => postSas(jar, sasUrl(cfg.urlEndAuth), {
237
+ Method: "EndAuth",
238
+ AuthMethodId: method.id,
239
+ SessionId: begin.SessionId,
240
+ FlowToken: begin.FlowToken,
241
+ Ctx: begin.Ctx,
242
+ ...extra,
243
+ });
244
+ let end;
245
+ let otc = "";
246
+ if (method.kind === "code") {
247
+ for (let attempt = 0; attempt < MAX_CODE_ATTEMPTS && !end?.Success; attempt++) {
248
+ otc = (await secondFactor({ step: "code", method, retry: attempt > 0 })).replace(/\s/g, "");
249
+ end = await endAuth({ AdditionalAuthData: otc, PollCount: 1 });
250
+ if (!end.Success && !/incorrect|wrongcode/i.test(end.ResultValue ?? ""))
251
+ break;
252
+ }
253
+ }
254
+ else {
255
+ await secondFactor({ step: "approve", method, number: begin.Entropy || undefined });
256
+ for (let poll = 1; poll <= APPROVE_POLLS; poll++) {
257
+ end = await endAuth({ PollCount: poll });
258
+ if (end.Success || end.ResultValue !== "AuthenticationPending")
259
+ break;
260
+ await sleep(APPROVE_POLL_MS);
261
+ }
262
+ }
263
+ if (!end?.Success) {
264
+ const reason = end?.ResultValue === "AuthenticationPending" ? "no llegó la aprobación a tiempo" : end?.Message || end?.ResultValue;
265
+ throw new MfaError(`Microsoft no aceptó el segundo factor (${reason}). Volvé a iniciar sesión para probar de nuevo.`);
266
+ }
267
+ return follow(jar, sasUrl(cfg.urlPost), {
268
+ method: "POST",
269
+ body: new URLSearchParams({
270
+ request: end.Ctx ?? begin.Ctx ?? "",
271
+ mfaAuthMethod: method.id,
272
+ flowToken: end.FlowToken ?? begin.FlowToken ?? "",
273
+ canary: cfg.canary ?? "",
274
+ otc,
275
+ login: username,
276
+ hideSmsInMfaProofs: "false",
277
+ }),
278
+ });
279
+ }
280
+ /** POST JSON a `SAS/BeginAuth` o `SAS/EndAuth`, con las cookies del login. */
281
+ async function postSas(jar, url, body) {
282
+ const cookie = await jar.getCookieString(url);
283
+ const res = await fetch(url, {
284
+ method: "POST",
285
+ headers: { "content-type": "application/json", accept: "application/json", ...(cookie ? { cookie } : {}) },
286
+ body: JSON.stringify(body),
287
+ signal: AbortSignal.timeout(TIMEOUT_MS),
288
+ });
289
+ for (const setCookie of res.headers.getSetCookie()) {
290
+ await jar.setCookie(setCookie, url, { ignoreError: true });
291
+ }
292
+ try {
293
+ return (await res.json());
294
+ }
295
+ catch {
296
+ throw new Error(`Respuesta inesperada del segundo factor de Microsoft (${res.status}).`);
297
+ }
298
+ }
172
299
  /** El motivo del rechazo en una página de Microsoft (ej. AADSTS50126: usuario o contraseña incorrectos). */
173
300
  function microsoftLoginError(html) {
174
301
  const cfg = parseAadConfig(html);
@@ -1,6 +1,6 @@
1
1
  import { loadCredentials } from "./credentials.js";
2
2
  import { defaultRegional, findRegional } from "./regionals.js";
3
- import { fetchToken, LoginRequiredError } from "./sso-login.js";
3
+ import { fetchToken, LoginRequiredError, MfaRequiredError } from "./sso-login.js";
4
4
  /** Con qué nombre guarda el aula su sesión en el store. */
5
5
  export const AULA_PLATFORM = "aula";
6
6
  /**
@@ -47,7 +47,9 @@ export class StoredAulaSession {
47
47
  (credentials.regional ?? defaultRegional().id) === regional.id;
48
48
  if (!credentials || !sameUser)
49
49
  throw this.loginRequired("La sesión del aula venció.");
50
- const token = await fetchToken(regional, credentials);
50
+ const token = await fetchToken(regional, credentials).catch((e) => {
51
+ throw e instanceof MfaRequiredError ? this.loginRequired("Tu cuenta de Microsoft pide un segundo factor (MFA).") : e;
52
+ });
51
53
  // Sin `...stored`: las sesiones guardadas antes traen cookies, que ya no se usan.
52
54
  const data = { username: stored.username, token };
53
55
  await this.store.saveSession(this.userId, AULA_PLATFORM, JSON.stringify(data));
@@ -1,3 +1,14 @@
1
+ /**
2
+ * Direcciones de navegación del aula: un path que el modelo recibe en una respuesta
3
+ * y devuelve tal cual para entrar ahí. No hay estado en el servidor; la dirección
4
+ * dice todo lo necesario, y el padre se deduce del texto (eso es el "volver").
5
+ */
6
+ export const AddressLevel = {
7
+ Root: "root",
8
+ Course: "course",
9
+ Module: "module",
10
+ Discussion: "discussion",
11
+ };
1
12
  export const ROOT = "/";
2
13
  export const courseAddr = (courseId) => `/curso/${courseId}`;
3
14
  export const moduleAddr = (courseId, cmid) => `/curso/${courseId}/mod/${cmid}`;
@@ -7,7 +7,7 @@ const BUDGET_CHARS = 30_000;
7
7
  /** Lo que cuesta una página como imagen (~1.600 tokens), medido en caracteres de texto. */
8
8
  const IMAGE_COST_CHARS = 6_000;
9
9
  /** El más grande que hay hoy en los cursos pesa 12 MB. */
10
- const MAX_BYTES = 30 * 1024 * 1024;
10
+ export const MAX_BYTES = 30 * 1024 * 1024;
11
11
  /**
12
12
  * Moodle devuelve las URLs del web service, que piden token. Al usuario le sirve la misma ruta sin
13
13
  * `/webservice`, que se abre con la sesión de su navegador. Es la única que mostramos: el token no
@@ -32,7 +32,7 @@ export function userFacingUrl(fileurl) {
32
32
  * Acepta cualquiera de las dos formas y devuelve la del web service. Solo archivos del aula del
33
33
  * usuario (`baseUrl`): con cualquier otra URL estaríamos mandando su token a donde diga el modelo.
34
34
  */
35
- function downloadUrl(baseUrl, url) {
35
+ export function downloadUrl(baseUrl, url) {
36
36
  let u;
37
37
  try {
38
38
  u = new URL(url);
@@ -0,0 +1,199 @@
1
+ import { createHash } from "node:crypto";
2
+ import { mkdir, writeFile } from "node:fs/promises";
3
+ import { join } from "node:path";
4
+ import { mergePdfs } from "../../shared/documents/merge.js";
5
+ import { exportsDir } from "../../shared/paths.js";
6
+ import { getCourseFiles, getUserCourses } from "../client/client.js";
7
+ import { parseAddress } from "./address.js";
8
+ import { downloadUrl, MAX_BYTES } from "./files.js";
9
+ import { fetchFile } from "../client/moodle-api.js";
10
+ /**
11
+ * Topes de tamaño por pack: pdf-lib junta todo el resultado en memoria, así que sin tope total un
12
+ * curso enorme podría colgar la compu donde corre el servidor. Lo que queda afuera por un tope se
13
+ * lista en `omitidos` con el motivo, para pedir el resto en otro pack.
14
+ * Una variable de entorno con basura NO puede desactivar el tope: si no es un número válido,
15
+ * vuelve al default.
16
+ */
17
+ const capFromEnv = (raw, fallback) => {
18
+ const n = Number(raw ?? fallback);
19
+ return Number.isFinite(n) && n > 0 ? n : fallback;
20
+ };
21
+ const MAX_PACK_FILES = capFromEnv(process.env.UTN_MCP_PACK_MAX_FILES, 150);
22
+ const MAX_PACK_BYTES = capFromEnv(process.env.UTN_MCP_PACK_MAX_MB, 300) * 1024 * 1024;
23
+ /** Máximo del PDF final: se estima con los tamaños de entrada para cortar antes de bajar todo. */
24
+ const MAX_FINAL_MB = capFromEnv(process.env.UTN_MCP_PACK_MAX_FINAL_MB, 30);
25
+ const MAX_FINAL_BYTES = MAX_FINAL_MB * 1024 * 1024;
26
+ const isPdf = (e) => e.file.mimetype === "application/pdf" || e.file.filename.toLowerCase().endsWith(".pdf");
27
+ /** Los caracteres que no sirven para un nombre de archivo se cambian por "-". */
28
+ const safeFileName = (name) => name.replace(/[^\p{L}\p{N}._-]+/gu, "-").replace(/^-+|-+$/g, "");
29
+ /**
30
+ * Escribe el pack sin pisar packs anteriores: nombre con fecha-hora y, si ya existe (dos packs
31
+ * en el mismo segundo), un sufijo -2, -3... La escritura es atómica por nombre (bandera "wx").
32
+ */
33
+ export async function writePackFile(dir, courseName, bytes) {
34
+ const now = new Date();
35
+ const pad = (n) => String(n).padStart(2, "0");
36
+ const stamp = `${now.getFullYear()}${pad(now.getMonth() + 1)}${pad(now.getDate())}-${pad(now.getHours())}${pad(now.getMinutes())}${pad(now.getSeconds())}`;
37
+ const base = join(dir, `${safeFileName(courseName)}-apuntes-${stamp}`);
38
+ for (let n = 1; n <= 100; n++) {
39
+ const candidate = n === 1 ? `${base}.pdf` : `${base}-${n}.pdf`;
40
+ try {
41
+ await writeFile(candidate, bytes, { flag: "wx" });
42
+ return candidate;
43
+ }
44
+ catch (e) {
45
+ if (e.code !== "EEXIST")
46
+ throw e;
47
+ }
48
+ }
49
+ throw new Error(`No se pudo guardar el pack: ya existen 100 archivos para "${base}".`);
50
+ }
51
+ /**
52
+ * Baja PDF de un curso y los une en un solo archivo con índice, guardado en exportsDir().
53
+ * Con `archivos` une solo las URL pedidas (tal como las devuelve aula_open, validadas con
54
+ * `downloadUrl` como en aula_read_file); sin `archivos`, todos los PDF del curso. El pack sigue
55
+ * el orden del aula, no el de la lista, para que el índice acompañe las unidades. Los duplicados
56
+ * exactos se descartan al armar, porque ahí se bajan igual. Todo lo omitido queda listado en
57
+ * `omitidos` con el motivo; en el atajo sin `archivos`, los archivos que no son PDF solo se
58
+ * cuentan en `archivosNoPdf` en vez de listarse uno por uno.
59
+ */
60
+ export async function buildStudyPack(session, rawAddress, archivos) {
61
+ const address = parseAddress(rawAddress);
62
+ if (address.level !== "course") {
63
+ throw new Error(`Para armar el pack pasá la dirección de un curso entero ("/curso/{courseId}", de aula_list_courses), no "${rawAddress}".`);
64
+ }
65
+ const { baseUrl } = await session.getRegional();
66
+ const course = (await getUserCourses(session)).find((c) => c.id === address.courseId);
67
+ const entries = await getCourseFiles(session, address.courseId);
68
+ let selected;
69
+ const omitted = [];
70
+ if (archivos) {
71
+ // Cada URL pedida se valida igual que en aula_read_file: solo archivos del aula del usuario.
72
+ const requested = new Set(archivos.map((u) => downloadUrl(baseUrl, u).href));
73
+ const byUrl = new Map(entries.map((e) => [downloadUrl(baseUrl, e.file.fileurl).href, e]));
74
+ const unknown = [...requested].filter((u) => !byUrl.has(u));
75
+ if (unknown.length) {
76
+ throw new Error(`Estas URL no son de archivos del curso (salen de aula_open sobre "${rawAddress}"):\n` +
77
+ unknown.map((u) => `- ${u}`).join("\n"));
78
+ }
79
+ // El orden lo manda el aula, no la lista: el índice sigue las unidades.
80
+ selected = entries.filter((e) => requested.has(downloadUrl(baseUrl, e.file.fileurl).href));
81
+ for (const e of selected.filter((x) => !isPdf(x))) {
82
+ omitted.push({ nombre: e.file.filename, seccion: e.section, motivo: "no es un PDF" });
83
+ }
84
+ }
85
+ else {
86
+ selected = entries;
87
+ }
88
+ const pdfs = selected.filter(isPdf);
89
+ const archivosNoPdf = archivos ? 0 : selected.length - pdfs.length;
90
+ if (!pdfs.length) {
91
+ throw new Error(archivos
92
+ ? "Ninguna de las URL pedidas es un PDF: la tool une únicamente PDF."
93
+ : "El curso no tiene ningún PDF en sus secciones visibles.");
94
+ }
95
+ const capMotivo = `se alcanzó el tope del pack (${MAX_PACK_FILES} archivos / ${MAX_PACK_BYTES / 1024 / 1024} MB)`;
96
+ const inputs = [];
97
+ const kept = [];
98
+ const seen = new Map(); // hash -> nombre del primero con ese contenido
99
+ let totalBytes = 0;
100
+ let finalLleno = false;
101
+ for (const entry of pdfs) {
102
+ const name = entry.file.filename;
103
+ if (finalLleno) {
104
+ omitted.push({
105
+ nombre: name,
106
+ seccion: entry.section,
107
+ motivo: `el PDF final llegó al tope de ${MAX_FINAL_MB} MB (estimado con los tamaños de entrada)`,
108
+ });
109
+ continue;
110
+ }
111
+ if (inputs.length >= MAX_PACK_FILES || totalBytes + (entry.file.filesize ?? 0) > MAX_PACK_BYTES) {
112
+ omitted.push({ nombre: name, seccion: entry.section, motivo: capMotivo });
113
+ continue;
114
+ }
115
+ let bytes;
116
+ try {
117
+ bytes = (await fetchFile(session, downloadUrl(baseUrl, entry.file.fileurl), MAX_BYTES)).bytes;
118
+ }
119
+ catch (e) {
120
+ omitted.push({ nombre: name, seccion: entry.section, motivo: e.message });
121
+ continue;
122
+ }
123
+ const hash = createHash("sha256").update(bytes).digest("hex");
124
+ const original = seen.get(hash);
125
+ if (original) {
126
+ omitted.push({ nombre: name, seccion: entry.section, motivo: `es idéntico a "${original}"` });
127
+ continue;
128
+ }
129
+ // El tamaño real se conoce recién ahora (Moodle a veces no lo anuncia): tope post-descarga.
130
+ if (totalBytes + bytes.length > MAX_PACK_BYTES) {
131
+ omitted.push({ nombre: name, seccion: entry.section, motivo: capMotivo });
132
+ continue;
133
+ }
134
+ // Corte temprano del tope del PDF final, estimado con los tamaños de entrada: lo que ya
135
+ // no puede entrar no se baja de más ni tira abajo el pack entero al final.
136
+ if (totalBytes + bytes.length > MAX_FINAL_BYTES) {
137
+ finalLleno = true;
138
+ omitted.push({
139
+ nombre: name,
140
+ seccion: entry.section,
141
+ motivo: `el PDF final llegó al tope de ${MAX_FINAL_MB} MB (estimado con los tamaños de entrada)`,
142
+ });
143
+ continue;
144
+ }
145
+ seen.set(hash, name);
146
+ inputs.push({ title: name, bytes });
147
+ kept.push(entry);
148
+ totalBytes += bytes.length;
149
+ }
150
+ // En el índice va el módulo, que se lee mejor que el archivo; si el módulo aporta más de un
151
+ // archivo, también el archivo, para que las entradas no queden repetidas.
152
+ const perModule = new Map();
153
+ const moduleKey = (e) => `${e.section}\n${e.module}`;
154
+ for (const e of kept)
155
+ perModule.set(moduleKey(e), (perModule.get(moduleKey(e)) ?? 0) + 1);
156
+ kept.forEach((e, i) => {
157
+ inputs[i].title = perModule.get(moduleKey(e)) > 1 ? `${e.module}: ${e.file.filename}` : e.module;
158
+ });
159
+ const courseName = course?.shortname ?? `curso-${address.courseId}`;
160
+ const dir = exportsDir();
161
+ // La estimación puede quedar corta (el índice y los enlaces agregan páginas): si el unido
162
+ // supera el tope igual, salen los últimos materiales hasta que entre, en vez de tirar todo
163
+ // el trabajo de descarga.
164
+ let pack = await mergePdfs(inputs, `Apuntes de ${course?.fullname ?? courseName}`);
165
+ while (pack.bytes.length > MAX_FINAL_BYTES && inputs.length > 1) {
166
+ inputs.pop();
167
+ const entry = kept.pop();
168
+ omitted.push({
169
+ nombre: entry.file.filename,
170
+ seccion: entry.section,
171
+ motivo: `el PDF final superó el tope de ${MAX_FINAL_MB} MB al unir`,
172
+ });
173
+ pack = await mergePdfs(inputs, `Apuntes de ${course?.fullname ?? courseName}`);
174
+ }
175
+ if (pack.bytes.length > MAX_FINAL_BYTES) {
176
+ const mb = Math.round((pack.bytes.length / 1024 / 1024) * 10) / 10;
177
+ throw new Error(`El PDF final pesa ${mb} MB, más del máximo (${MAX_FINAL_MB} MB): no se guardó. ` +
178
+ "Pedí el pack con `archivos`, eligiendo solo los PDF que necesitás.");
179
+ }
180
+ for (const s of pack.skipped) {
181
+ omitted.push({ nombre: kept[s.index].file.filename, seccion: kept[s.index].section, motivo: s.reason });
182
+ }
183
+ await mkdir(dir, { recursive: true });
184
+ const path = await writePackFile(dir, courseName, pack.bytes);
185
+ return {
186
+ archivo: path,
187
+ curso: course?.fullname ?? courseName,
188
+ paginas: pack.pages,
189
+ materiales: pack.merged.map((m) => ({
190
+ nombre: kept[m.index].file.filename,
191
+ seccion: kept[m.index].section,
192
+ modulo: kept[m.index].module,
193
+ paginas: m.pages,
194
+ empiezaEn: m.startPage,
195
+ })),
196
+ omitidos: omitted.length ? omitted : undefined,
197
+ archivosNoPdf: archivosNoPdf || undefined,
198
+ };
199
+ }
@@ -22,7 +22,7 @@ export async function runLogin(http) {
22
22
  if (http)
23
23
  accessToken = await loginToStore(regional, credentials);
24
24
  else
25
- await new LocalSession().login(regional, credentials);
25
+ await new LocalSession().login(regional, credentials, askSecondFactor);
26
26
  // Se guardan recién después de comprobar que el aula las aceptó.
27
27
  if (await saveCredentials(credentials)) {
28
28
  logger.info("Credenciales guardadas en el keychain del sistema.");
@@ -50,9 +50,21 @@ async function askRegional() {
50
50
  const labels = regionals.map((r) => r.name + SUPPORT_LABEL[r.support]);
51
51
  return regionals[await askChoice("Regional:", labels, Math.max(initial, 0))];
52
52
  }
53
+ /** Contesta por la terminal el segundo factor de Microsoft, si la cuenta lo pide. */
54
+ const askSecondFactor = async (prompt) => {
55
+ if (prompt.step === "choose") {
56
+ const labels = prompt.methods.map((m) => m.label);
57
+ return prompt.methods[await askChoice("Microsoft pide un segundo factor:", labels)].id;
58
+ }
59
+ if (prompt.step === "code") {
60
+ return ask(prompt.retry ? "El código no era correcto. Probá de nuevo: " : `${prompt.method.label}: `);
61
+ }
62
+ const number = prompt.number ? ` y tocá el número ${prompt.number}` : "";
63
+ return ask(`Aprobá el inicio de sesión en el celular (${prompt.method.label})${number}. Después apretá Enter. `);
64
+ };
53
65
  /** Crea el usuario si no existe, guarda su sesión del aula y le emite un token que no vence. */
54
66
  async function loginToStore(regional, credentials) {
55
67
  const store = await SqliteStore.open();
56
- const { userId } = await linkAulaAccount(store, regional, credentials);
68
+ const { userId } = await linkAulaAccount(store, regional, credentials, askSecondFactor);
57
69
  return store.issueToken(userId, { kind: "access", clientId: "local" });
58
70
  }