utn-mcp 0.7.0 → 0.8.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,103 @@
1
+ /**
2
+ * El soporte de las que no son `verified` sale de `tool_mobile_get_public_config`, que Moodle contesta
3
+ * sin login (octubre de 2026). Falta Villa María (FRVM): no encontramos su aula.
4
+ */
5
+ const REGIONALS = [
6
+ { id: "fra", name: "Avellaneda (FRA)", baseUrl: "https://www.utnfravirtual.org.ar", support: "candidate" },
7
+ { id: "frbb", name: "Bahía Blanca (FRBB)", baseUrl: "https://aulavirtual.frbb.utn.edu.ar", support: "candidate" },
8
+ {
9
+ id: "frba",
10
+ name: "Buenos Aires (FRBA)",
11
+ baseUrl: "https://aulasvirtuales.frba.utn.edu.ar",
12
+ ssoHost: "auth.frba.utn.edu.ar",
13
+ support: "verified",
14
+ },
15
+ { id: "frch", name: "Chubut (FRCh)", baseUrl: "https://frch.cvg.utn.edu.ar", support: "candidate" },
16
+ { id: "frcu", name: "Concepción del Uruguay (FRCU)", baseUrl: "https://virtual.frcu.utn.edu.ar", support: "candidate" },
17
+ { id: "frcon", name: "Concordia (FRCon)", baseUrl: "https://frcon.cvg.utn.edu.ar", support: "candidate" },
18
+ { id: "frc", name: "Córdoba (FRC)", baseUrl: "https://uv.frc.utn.edu.ar", support: "verified" },
19
+ { id: "frd", name: "Delta (FRD)", baseUrl: "https://frd.cvg.utn.edu.ar", support: "candidate" },
20
+ { id: "frgp", name: "General Pacheco (FRGP)", baseUrl: "https://frgp.cvg.utn.edu.ar", support: "candidate" },
21
+ // Office 365.
22
+ { id: "frh", name: "Haedo (FRH)", baseUrl: "https://frh.cvg.utn.edu.ar", support: "unsupported" },
23
+ { id: "frlp", name: "La Plata (FRLP)", baseUrl: "https://frlp.cvg.utn.edu.ar", support: "candidate" },
24
+ { id: "frlr", name: "La Rioja (FRLR)", baseUrl: "https://frlr.cvg.utn.edu.ar", support: "candidate" },
25
+ // Tiene también frmdp.cvg.utn.edu.ar, pero ese no tiene el servicio habilitado y entra con Office 365.
26
+ { id: "frmdp", name: "Mar del Plata (FRMDP)", baseUrl: "https://campus.mdp.utn.edu.ar", support: "candidate" },
27
+ // Sin el servicio habilitado.
28
+ { id: "frm", name: "Mendoza (FRM)", baseUrl: "https://campusvirtual.frm.utn.edu.ar", support: "unsupported" },
29
+ { id: "frn", name: "Neuquén (FRN)", baseUrl: "https://frn.cvg.utn.edu.ar", support: "candidate" },
30
+ { id: "frp", name: "Paraná (FRP)", baseUrl: "https://frp.cvg.utn.edu.ar", support: "candidate" },
31
+ { id: "frra", name: "Rafaela (FRRa)", baseUrl: "https://frra.cvg.utn.edu.ar", support: "candidate" },
32
+ { id: "frrq", name: "Reconquista (FRRq)", baseUrl: "https://frrq.cvg.utn.edu.ar", support: "candidate" },
33
+ { id: "frre", name: "Resistencia (FRRE)", baseUrl: "https://frre.cvg.utn.edu.ar", support: "verified" },
34
+ { id: "frro", name: "Rosario (FRRo)", baseUrl: "https://frro.cvg.utn.edu.ar", support: "candidate" },
35
+ { id: "frsfco", name: "San Francisco (FRSFco)", baseUrl: "https://frsfco.cvg.utn.edu.ar", support: "candidate" },
36
+ // Office 365.
37
+ { id: "frsn", name: "San Nicolás (FRSN)", baseUrl: "https://frsn.cvg.utn.edu.ar", support: "unsupported" },
38
+ { id: "frsr", name: "San Rafael (FRSR)", baseUrl: "https://campus.frsr.utn.edu.ar/moodle", support: "candidate" },
39
+ { id: "frsc", name: "Santa Cruz (FRSC)", baseUrl: "https://frsc.cvg.utn.edu.ar", support: "candidate" },
40
+ { id: "frsf", name: "Santa Fe (FRSF)", baseUrl: "https://campusvirtual.frsf.utn.edu.ar", support: "candidate" },
41
+ // Sin el servicio habilitado.
42
+ { id: "frtdf", name: "Tierra del Fuego (FRTDF)", baseUrl: "https://cvg.frtdf.utn.edu.ar", support: "unsupported" },
43
+ { id: "frtl", name: "Trenque Lauquen (FRTL)", baseUrl: "https://frtl.cvg.utn.edu.ar", support: "candidate" },
44
+ { id: "frt", name: "Tucumán (FRT)", baseUrl: "https://frt.cvg.utn.edu.ar", support: "verified" },
45
+ // Sin el servicio habilitado.
46
+ { id: "frvt", name: "Venado Tuerto (FRVT)", baseUrl: "https://frvt.cvg.utn.edu.ar", support: "unsupported" },
47
+ ];
48
+ const DEFAULT_REGIONAL = "frba";
49
+ /** `UTN_REGIONAL` no está en el registro: sin cortar acá, las credenciales irían al SSO de FRBA. */
50
+ export class UnknownRegionalError extends Error {
51
+ name = "UnknownRegionalError";
52
+ constructor(regional) {
53
+ super(`"${regional}" no es una regional conocida. Las válidas son: ${availableRegionals().map((r) => r.id).join(", ")}. ` +
54
+ "Si tu campus no está en la lista, borrá UTN_REGIONAL y definí UTN_AULA_BASE_URL con la URL del aula " +
55
+ "(y UTN_SSO_HOST si usa Keycloak).");
56
+ }
57
+ }
58
+ /** Su aula no tiene el servicio habilitado o entra por un SSO que no sabemos usar: ni se intenta entrar. */
59
+ export class UnsupportedRegionalError extends Error {
60
+ regional;
61
+ name = "UnsupportedRegionalError";
62
+ constructor(regional) {
63
+ super(`Todavía no hay soporte para ${regional.name}: su aula no tiene habilitado el servicio que usa ` +
64
+ "UTN-MCP o entra por un login que todavía no sabemos usar.");
65
+ this.regional = regional;
66
+ }
67
+ }
68
+ /** El aula de UTN_AULA_BASE_URL, para un campus que no está en el registro. */
69
+ function customRegional() {
70
+ const raw = process.env.UTN_AULA_BASE_URL?.trim();
71
+ if (!raw)
72
+ return undefined;
73
+ const baseUrl = raw.replace(/\/+$/, "");
74
+ const host = new URL(baseUrl).host;
75
+ return { id: host, name: host, baseUrl, ssoHost: process.env.UTN_SSO_HOST?.trim() || undefined, support: "verified" };
76
+ }
77
+ /**
78
+ * Las que se pueden elegir al iniciar sesión: el catálogo y, si está definida, la de UTN_AULA_BASE_URL.
79
+ * `verifiedIds` son las `candidate` que ya se verificaron en el servidor (las guarda el store).
80
+ */
81
+ export function availableRegionals(verifiedIds = []) {
82
+ const verified = new Set(verifiedIds);
83
+ const custom = customRegional();
84
+ return [...REGIONALS, ...(custom ? [custom] : [])].map((r) => r.support === "candidate" && verified.has(r.id) ? { ...r, support: "verified" } : r);
85
+ }
86
+ /** La regional con ese id; si no está entre las disponibles, `UnknownRegionalError`. */
87
+ export function findRegional(id) {
88
+ const wanted = id.trim().toLowerCase();
89
+ const found = availableRegionals().find((r) => r.id === wanted);
90
+ if (!found)
91
+ throw new UnknownRegionalError(id);
92
+ return found;
93
+ }
94
+ /**
95
+ * La que viene elegida si nadie dice otra: la de UTN_REGIONAL; si no, la de UTN_AULA_BASE_URL; si no,
96
+ * FRBA. Es la de quien usa UTN_USER/UTN_PASS sin `utn-mcp login`, y la opción por defecto al elegir.
97
+ */
98
+ export function defaultRegional() {
99
+ const fromEnv = process.env.UTN_REGIONAL?.trim();
100
+ if (fromEnv)
101
+ return findRegional(fromEnv);
102
+ return customRegional() ?? findRegional(DEFAULT_REGIONAL);
103
+ }
@@ -1,5 +1,7 @@
1
1
  import { randomInt } from "node:crypto";
2
- import { AULA_BASE_URL, SSO_HOST } from "./constants.js";
2
+ import { CookieJar } from "tough-cookie";
3
+ import { UnsupportedRegionalError } from "./regionals.js";
4
+ import { MoodleError } from "./moodle-api.js";
3
5
  export class LoginRequiredError extends Error {
4
6
  constructor(message = "Hace falta iniciar sesión: corré `utn-mcp login`.") {
5
7
  super(message);
@@ -7,56 +9,135 @@ export class LoginRequiredError extends Error {
7
9
  }
8
10
  }
9
11
  export class InvalidCredentialsError extends Error {
10
- constructor() {
11
- super("El SSO rechazó usuario o contraseña. Corré `utn-mcp login` para actualizarlos.");
12
+ constructor(reason) {
13
+ const base = "El aula rechazó usuario o contraseña. Corré `utn-mcp login` para actualizarlos.";
14
+ super(reason ? `${base} Motivo del aula: ${reason}` : base);
12
15
  this.name = "InvalidCredentialsError";
13
16
  }
14
17
  }
15
18
  const MAX_REDIRECTS = 20;
16
19
  const TIMEOUT_MS = 30_000;
17
20
  /**
18
- * Obtiene un token del web service siguiendo el flujo de launch.php por HTTP:
19
- * launch.php → login de Moodle → SSO (Keycloak) → Moodle → launch.php → moodlemobile://token=BASE64.
20
- * Las cookies del SSO quedan en `jar`: mientras sigan vivas no hace falta la contraseña.
21
+ * Obtiene un token del web service. Dos estrategias según la regional:
22
+ * - Regionales con SSO (Keycloak, ej. FRBA): flujo de launch.php, que usa la sesión del SSO. Si el
23
+ * SSO rechaza las credenciales, `login/token.php`: hay usuarios sin cuenta en el SSO.
24
+ * - Regionales con login directo de Moodle (ej. FRT): primero `login/token.php` (no depende del
25
+ * plugin de mobile launch, que puede estar deshabilitado); si falla, flujo de launch.php.
26
+ * Las cookies se usan solo durante el login y se descartan: las sesiones de Moodle y del SSO duran
27
+ * horas y el token, semanas, así que no sirven para renovarlo.
21
28
  */
22
- export async function fetchToken(jar, credentials) {
23
- const launchUrl = `${AULA_BASE_URL}/admin/tool/mobile/launch.php` +
29
+ export async function fetchToken(regional, credentials) {
30
+ if (regional.support === "unsupported")
31
+ throw new UnsupportedRegionalError(regional);
32
+ const { baseUrl, ssoHost } = regional;
33
+ const jar = new CookieJar();
34
+ // Regionales con login directo: intentamos token.php primero (no depende de launch.php).
35
+ if (!ssoHost) {
36
+ const viaTokenPhp = await tryTokenPhp(baseUrl, credentials);
37
+ if (viaTokenPhp)
38
+ return viaTokenPhp;
39
+ }
40
+ const launchUrl = `${baseUrl}/admin/tool/mobile/launch.php` +
24
41
  `?service=moodle_mobile_app&passport=${randomInt(1e9)}&urlscheme=moodlemobile`;
25
- // 1. Con sesión de Moodle viva, launch.php redirige directo al token.
26
42
  let landing = await follow(jar, launchUrl);
27
43
  if ("token" in landing)
28
44
  return landing.token;
29
- // 2. Login de Moodle: el botón "Usuarios Institucionales" lleva al SSO.
30
45
  const oauthUrl = landing.html.match(/href="([^"]*\/auth\/oauth2\/login\.php[^"]*)"/)?.[1];
31
- if (!oauthUrl)
32
- throw unexpected(landing.url);
33
- landing = await follow(jar, decodeHtml(oauthUrl));
34
- if ("token" in landing)
35
- return landing.token;
36
- // 3. Si el SSO no nos recuerda, muestra su formulario: lo completamos.
37
- if (new URL(landing.url).host === SSO_HOST) {
38
- if (!credentials)
39
- throw new LoginRequiredError();
40
- landing = await submitSsoForm(jar, landing.html, credentials);
46
+ if (oauthUrl) {
47
+ landing = await follow(jar, decodeHtml(oauthUrl));
48
+ if ("token" in landing)
49
+ return landing.token;
50
+ if (ssoHost && new URL(landing.url).host === ssoHost) {
51
+ landing = await submitSsoForm(jar, landing.html, credentials);
52
+ if ("token" in landing)
53
+ return landing.token;
54
+ if (ssoHost && new URL(landing.url).host === ssoHost) {
55
+ // Aspirantes del ingreso y algunas tecnicaturas no tienen cuenta en el SSO: entran con el login de Moodle.
56
+ const viaTokenPhp = await tryTokenPhp(baseUrl, credentials);
57
+ if (viaTokenPhp)
58
+ return viaTokenPhp;
59
+ throw new InvalidCredentialsError(extractLoginError(landing.html));
60
+ }
61
+ }
62
+ }
63
+ else {
64
+ const loginPageUrl = `${baseUrl}/login/index.php`;
65
+ const loginPage = await follow(jar, loginPageUrl);
66
+ if ("token" in loginPage)
67
+ return loginPage.token;
68
+ landing = await submitMoodleLoginForm(jar, loginPage.url, loginPage.html, credentials);
41
69
  if ("token" in landing)
42
70
  return landing.token;
43
- if (new URL(landing.url).host === SSO_HOST)
44
- throw new InvalidCredentialsError();
71
+ const stillOnLogin = landing.url.includes("/login") ||
72
+ /alert-danger/i.test(landing.html) ||
73
+ /loginerrors/i.test(landing.html);
74
+ if (stillOnLogin) {
75
+ const reason = extractLoginError(landing.html);
76
+ throw new InvalidCredentialsError(reason);
77
+ }
45
78
  }
46
- // 4. Ya con sesión de Moodle (el SSO nos deja en el dashboard), pedimos el token de nuevo.
47
79
  landing = await follow(jar, launchUrl);
48
80
  if ("token" in landing)
49
81
  return landing.token;
82
+ if (!ssoHost) {
83
+ const viaTokenPhp = await tryTokenPhp(baseUrl, credentials);
84
+ if (viaTokenPhp)
85
+ return viaTokenPhp;
86
+ }
50
87
  throw unexpected(landing.url);
51
88
  }
89
+ /**
90
+ * Pide el token a /login/token.php (Moodle estándar). Devuelve null si el aula no lo expone
91
+ * (no contesta JSON), para seguir con launch.php; si contesta con un error, lo propaga.
92
+ */
93
+ async function tryTokenPhp(baseUrl, credentials) {
94
+ const res = await fetch(`${baseUrl}/login/token.php`, {
95
+ method: "POST",
96
+ headers: { "content-type": "application/x-www-form-urlencoded" },
97
+ body: new URLSearchParams({
98
+ username: credentials.username,
99
+ password: credentials.password,
100
+ service: "moodle_mobile_app",
101
+ }),
102
+ signal: AbortSignal.timeout(TIMEOUT_MS),
103
+ });
104
+ let data;
105
+ try {
106
+ data = await res.json();
107
+ }
108
+ catch {
109
+ return null;
110
+ }
111
+ if (data.token)
112
+ return data.token;
113
+ if (data.errorcode === "invalidlogin" || /invalid login/i.test(data.error ?? "")) {
114
+ throw new InvalidCredentialsError(data.error);
115
+ }
116
+ // El mismo servicio lo usa launch.php: si token.php lo rechaza, seguir no sirve.
117
+ throw new MoodleError(`El aula no dio el token (${data.errorcode ?? res.status}): ${data.error ?? "respuesta sin token"}. ` +
118
+ "Si dice que el servicio web no está disponible, lo tiene que habilitar el admin del aula.", data.errorcode);
119
+ }
52
120
  async function submitSsoForm(jar, html, { username, password }) {
53
- // La página de Keycloak trae su config como JSON (kcContext); de ahí sale la URL del form.
54
121
  const action = html.match(/"loginAction"\s*:\s*("(?:[^"\\]|\\.)*")/)?.[1];
55
122
  if (!action)
56
123
  throw new Error("No se encontró el formulario del SSO; puede que haya cambiado.");
57
124
  const body = new URLSearchParams({ username, password, rememberMe: "on", credentialId: "" });
58
125
  return follow(jar, JSON.parse(action), { method: "POST", body });
59
126
  }
127
+ /** Login directo de Moodle (sin SSO): manda usuario y contraseña al formulario de /login/index.php. */
128
+ async function submitMoodleLoginForm(jar, pageUrl, html, { username, password }) {
129
+ const tokenMatch = html.match(/name="logintoken"\s+value="([^"]*)"/) ||
130
+ html.match(/value="([^"]*)"\s+name="logintoken"/);
131
+ const logintoken = tokenMatch?.[1] ?? "";
132
+ const actionMatch = html.match(/<form[^>]*action="([^"]*)"[^>]*>/i);
133
+ let action = actionMatch?.[1] ? decodeHtml(actionMatch[1]) : "";
134
+ if (!action)
135
+ action = pageUrl;
136
+ if (!action.startsWith("http"))
137
+ action = new URL(action, pageUrl).toString();
138
+ const body = new URLSearchParams({ username, password, logintoken, anchor: "" });
139
+ return follow(jar, action, { method: "POST", body });
140
+ }
60
141
  /** Hace la request y sigue redirecciones a mano, guardando cookies en cada salto. */
61
142
  async function follow(jar, url, init) {
62
143
  for (let i = 0; i < MAX_REDIRECTS; i++) {
@@ -92,6 +173,24 @@ export function decodeLaunchToken(url) {
92
173
  return parts[1];
93
174
  }
94
175
  const decodeHtml = (s) => s.replaceAll("&amp;", "&");
176
+ /** Extrae el mensaje de error que muestra Moodle/Keycloak en la página de login. */
177
+ function extractLoginError(html) {
178
+ const patterns = [
179
+ /alert-danger[^>]*>([\s\S]*?)<\/div>/i,
180
+ /alert-error[^>]*>([\s\S]*?)<\/div>/i,
181
+ /class="[^"]*error[^"]*"[^>]*>([\s\S]*?)<\/div>/i,
182
+ /<div[^>]*id="loginerrormessage"[^>]*>([\s\S]*?)<\/div>/i,
183
+ ];
184
+ for (const re of patterns) {
185
+ const m = html.match(re);
186
+ if (m?.[1]) {
187
+ const text = m[1].replace(/<[^>]+>/g, " ").replace(/\s+/g, " ").trim();
188
+ if (text)
189
+ return text;
190
+ }
191
+ }
192
+ return undefined;
193
+ }
95
194
  const unexpected = (url) => {
96
195
  const { host, pathname } = new URL(url);
97
196
  return new Error(`Flujo de login inesperado: terminó en ${host}${pathname}`);
@@ -1,24 +1,34 @@
1
- import { CookieJar } from "tough-cookie";
2
1
  import { loadCredentials } from "./credentials.js";
2
+ import { defaultRegional, findRegional } from "./regionals.js";
3
3
  import { fetchToken, LoginRequiredError } 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
  /**
7
- * Sesión del modo HTTP: la de un usuario del store. Renueva el token con las cookies del SSO y, si
8
- * ya no alcanzan, con la contraseña del keychain cuando es de este mismo usuario: eso pasa solo en
9
- * local, porque el servidor hosteado no guarda contraseñas.
7
+ * Sesión del modo HTTP: la de un usuario del store, en el aula de su regional. Renueva el token con la
8
+ * contraseña del keychain cuando es de este mismo usuario: eso pasa solo en local, porque el servidor
9
+ * hosteado no guarda contraseñas. Si no, el usuario vuelve a iniciar sesión.
10
10
  */
11
11
  export class StoredAulaSession {
12
12
  store;
13
13
  userId;
14
14
  loginUrl;
15
15
  refreshing = null;
16
+ regional = null;
16
17
  /** `loginUrl` es la página donde el usuario vuelve a iniciar sesión, si el servidor tiene una. */
17
18
  constructor(store, userId, loginUrl) {
18
19
  this.store = store;
19
20
  this.userId = userId;
20
21
  this.loginUrl = loginUrl;
21
22
  }
23
+ /** La del usuario en el store; no cambia mientras dura la sesión, así que se lee una sola vez. */
24
+ getRegional() {
25
+ this.regional ??= this.store.findUser(this.userId).then((user) => {
26
+ if (!user)
27
+ throw this.loginRequired("No hay usuario para esta sesión.");
28
+ return findRegional(user.regional);
29
+ });
30
+ return this.regional;
31
+ }
22
32
  async getToken() {
23
33
  return (await this.read()).token;
24
34
  }
@@ -29,23 +39,17 @@ export class StoredAulaSession {
29
39
  return this.refreshing;
30
40
  }
31
41
  async renew() {
32
- const stored = await this.read();
33
- const jar = await CookieJar.deserialize(stored.cookies);
34
- // Las del keychain (o UTN_USER/UTN_PASS) son de una sola persona: con otro usuario
35
- // entraríamos al aula de ella y guardaríamos su token en la sesión de este.
42
+ const [stored, regional] = await Promise.all([this.read(), this.getRegional()]);
43
+ // Las del keychain (o UTN_USER/UTN_PASS) son de una sola persona: con otro usuario (o el mismo
44
+ // nombre en otra regional) entraríamos al aula de ella y guardaríamos su token en la sesión de este.
36
45
  const credentials = await loadCredentials();
37
- const own = credentials?.username.trim().toLowerCase() === stored.username ? credentials : null;
38
- let token;
39
- try {
40
- token = await fetchToken(jar, own);
41
- }
42
- catch (e) {
43
- if (e instanceof LoginRequiredError) {
44
- throw this.loginRequired("La sesión del aula venció.");
45
- }
46
- throw e;
47
- }
48
- const data = { ...stored, token, cookies: await jar.serialize() };
46
+ const sameUser = credentials?.username.trim().toLowerCase() === stored.username &&
47
+ (credentials.regional ?? defaultRegional().id) === regional.id;
48
+ if (!credentials || !sameUser)
49
+ throw this.loginRequired("La sesión del aula venció.");
50
+ const token = await fetchToken(regional, credentials);
51
+ // Sin `...stored`: las sesiones guardadas antes traen cookies, que ya no se usan.
52
+ const data = { username: stored.username, token };
49
53
  await this.store.saveSession(this.userId, AULA_PLATFORM, JSON.stringify(data));
50
54
  return token;
51
55
  }
@@ -1,6 +1,5 @@
1
1
  import { textToHtml } from "../../shared/html.js";
2
2
  import { addDiscussion, addDiscussionPost, createUserEvent, deleteChoiceResponses, deleteChoicegroupResponses, deletePost, getChoiceOptions, getChoicegroupOptions, getDiscussionPosts, getMemberName, sendInstantMessage, submitChoiceResponse, submitChoicegroupResponse, updateDiscussionPost, } from "../client/client.js";
3
- import { AULA_BASE_URL } from "../client/constants.js";
4
3
  import { toOption } from "./activities.js";
5
4
  import { discussionAddr, formatAddress, parseAddress } from "./address.js";
6
5
  import { isoDate } from "./fields.js";
@@ -154,6 +153,6 @@ export async function addCalendarEvent(session, event) {
154
153
  inicio: isoDate(timestart),
155
154
  fin: timeduration ? isoDate(timestart + timeduration) : undefined,
156
155
  // Para que el usuario lo vea en el calendario del aula.
157
- url: `${AULA_BASE_URL}/calendar/view.php?view=day&time=${timestart}`,
156
+ url: `${(await session.getRegional()).baseUrl}/calendar/view.php?view=day&time=${timestart}`,
158
157
  };
159
158
  }
@@ -1,7 +1,5 @@
1
1
  import { openDocument, UnsupportedFormat } from "../../shared/documents/index.js";
2
- import { AULA_BASE_URL } from "../client/constants.js";
3
2
  import { fetchFile } from "../client/moodle-api.js";
4
- const ORIGIN = new URL(AULA_BASE_URL).origin;
5
3
  const WS_PATH = "/webservice/pluginfile.php/";
6
4
  const WEB_PATH = "/pluginfile.php/";
7
5
  /** Cuánto se devuelve por llamada, en caracteres de texto. */
@@ -13,16 +11,28 @@ const MAX_BYTES = 30 * 1024 * 1024;
13
11
  /**
14
12
  * Moodle devuelve las URLs del web service, que piden token. Al usuario le sirve la misma ruta sin
15
13
  * `/webservice`, que se abre con la sesión de su navegador. Es la única que mostramos: el token no
16
- * sale nunca del servidor.
14
+ * sale nunca del servidor. `fileurl` viene de Moodle: ya es del aula del usuario.
17
15
  */
18
16
  export function userFacingUrl(fileurl) {
19
- return fileurl.replace(`${ORIGIN}${WS_PATH}`, `${ORIGIN}${WEB_PATH}`);
17
+ let u;
18
+ try {
19
+ u = new URL(fileurl);
20
+ }
21
+ catch {
22
+ return fileurl;
23
+ }
24
+ // Hay aulas en una subruta (`/moodle`): el tramo a cambiar puede no estar al principio.
25
+ const at = u.pathname.indexOf(WS_PATH);
26
+ if (at < 0)
27
+ return fileurl;
28
+ u.pathname = u.pathname.slice(0, at) + WEB_PATH + u.pathname.slice(at + WS_PATH.length);
29
+ return u.href;
20
30
  }
21
31
  /**
22
- * Acepta cualquiera de las dos formas y devuelve la del web service. Solo archivos del aula: con
23
- * cualquier otra URL estaríamos mandando el token del usuario a donde diga el modelo.
32
+ * Acepta cualquiera de las dos formas y devuelve la del web service. Solo archivos del aula del
33
+ * usuario (`baseUrl`): con cualquier otra URL estaríamos mandando su token a donde diga el modelo.
24
34
  */
25
- function downloadUrl(url) {
35
+ function downloadUrl(baseUrl, url) {
26
36
  let u;
27
37
  try {
28
38
  u = new URL(url);
@@ -30,10 +40,18 @@ function downloadUrl(url) {
30
40
  catch {
31
41
  throw new Error(`URL inválida: "${url}". Usá la \`url\` de un archivo tal como la devuelve aula_open.`);
32
42
  }
33
- const path = u.pathname.startsWith(WS_PATH) ? u.pathname : u.pathname.startsWith(WEB_PATH) ? WS_PATH + u.pathname.slice(WEB_PATH.length) : null;
34
- if (u.origin !== ORIGIN || !path) {
35
- throw new Error(`Solo se pueden leer archivos del aula virtual (${ORIGIN}${WEB_PATH}...).`);
43
+ const root = new URL(baseUrl);
44
+ const prefix = root.pathname.replace(/\/+$/, "");
45
+ const rest = u.pathname.startsWith(prefix + WS_PATH)
46
+ ? u.pathname.slice(prefix.length + WS_PATH.length)
47
+ : u.pathname.startsWith(prefix + WEB_PATH)
48
+ ? u.pathname.slice(prefix.length + WEB_PATH.length)
49
+ : null;
50
+ // `origin` ignora `usuario@`: con eso `fetch` rechaza la URL y su error, con el token ya puesto, llega al modelo.
51
+ if (u.origin !== root.origin || u.username || u.password || rest === null) {
52
+ throw new Error(`Solo se pueden leer archivos del aula virtual (${root.origin}${prefix}${WEB_PATH}...).`);
36
53
  }
54
+ const path = prefix + WS_PATH + rest;
37
55
  u.pathname = path;
38
56
  u.searchParams.delete("token");
39
57
  u.searchParams.delete("wstoken");
@@ -41,7 +59,8 @@ function downloadUrl(url) {
41
59
  }
42
60
  /** Baja un archivo del aula y devuelve desde la parte `from` tantas como entren en el presupuesto. */
43
61
  export async function readAulaFile(session, url, from = 1) {
44
- const download = downloadUrl(url);
62
+ const { baseUrl } = await session.getRegional();
63
+ const download = downloadUrl(baseUrl, url);
45
64
  const fileName = decodeURIComponent(download.pathname.split("/").pop() ?? "");
46
65
  const forUser = userFacingUrl(download.href);
47
66
  const { bytes } = await fetchFile(session, download, MAX_BYTES);
@@ -1,31 +1,33 @@
1
- import { ask, askHidden } from "../shared/input.js";
1
+ import { ask, askChoice, askHidden } from "../shared/input.js";
2
2
  import { logger } from "../shared/logger.js";
3
3
  import { SqliteStore } from "../shared/store/sqlite-store.js";
4
4
  import { saveCredentials } from "./client/credentials.js";
5
5
  import { linkAulaAccount } from "./client/link.js";
6
6
  import { LocalSession } from "./client/local-session.js";
7
+ import { availableRegionals, defaultRegional } from "./client/regionals.js";
7
8
  /**
8
- * `utn-mcp login`: pide usuario y contraseña una vez, los guarda en el keychain
9
+ * `utn-mcp login`: pide la regional, usuario y contraseña una vez, los guarda en el keychain
9
10
  * y obtiene el primer token. Después el servidor renueva solo.
10
11
  * Con `--http` guarda la sesión en el store y emite el token para el cliente MCP.
11
12
  */
12
13
  export async function runLogin(http) {
13
- const username = (await ask("Usuario institucional de la UTN: ")).trim();
14
+ const regional = await askRegional();
15
+ const username = (await ask("Usuario de la UTN: ")).trim();
14
16
  if (!username)
15
17
  throw new Error("Falta el usuario.");
16
18
  const password = await askHidden("Contraseña: ");
17
- const credentials = { username, password };
19
+ const credentials = { username, password, regional: regional.id };
18
20
  let accessToken = null;
19
21
  if (http)
20
- accessToken = await loginToStore(credentials);
22
+ accessToken = await loginToStore(regional, credentials);
21
23
  else
22
- await new LocalSession().login(credentials);
23
- // Se guardan recién después de comprobar que el SSO las aceptó.
24
+ await new LocalSession().login(regional, credentials);
25
+ // Se guardan recién después de comprobar que el aula las aceptó.
24
26
  if (await saveCredentials(credentials)) {
25
27
  logger.info("Credenciales guardadas en el keychain del sistema.");
26
28
  }
27
29
  else {
28
- logger.warn("No hay keychain disponible: para renovar sin intervención definí UTN_USER y UTN_PASS.");
30
+ logger.warn("No hay keychain disponible: para renovar sin intervención definí UTN_USER, UTN_PASS y UTN_REGIONAL.");
29
31
  }
30
32
  if (accessToken) {
31
33
  logger.info("Listo. Configurá tu cliente MCP con este header; no se vuelve a mostrar:\n\n" +
@@ -35,9 +37,21 @@ export async function runLogin(http) {
35
37
  logger.info("Listo: ya podés usar el servidor MCP.");
36
38
  }
37
39
  }
40
+ const SUPPORT_LABEL = {
41
+ verified: "",
42
+ candidate: " · sin probar todavía",
43
+ unsupported: " · todavía sin soporte",
44
+ };
45
+ /** Arranca marcada la de UTN_REGIONAL (o FRBA). */
46
+ async function askRegional() {
47
+ const regionals = availableRegionals();
48
+ const initial = regionals.findIndex((r) => r.id === defaultRegional().id);
49
+ const labels = regionals.map((r) => r.name + SUPPORT_LABEL[r.support]);
50
+ return regionals[await askChoice("Regional:", labels, Math.max(initial, 0))];
51
+ }
38
52
  /** Crea el usuario si no existe, guarda su sesión del aula y le emite un token que no vence. */
39
- async function loginToStore(credentials) {
53
+ async function loginToStore(regional, credentials) {
40
54
  const store = await SqliteStore.open();
41
- const { userId } = await linkAulaAccount(store, credentials);
55
+ const { userId } = await linkAulaAccount(store, regional, credentials);
42
56
  return store.issueToken(userId, { kind: "access", clientId: "local" });
43
57
  }
@@ -17,12 +17,16 @@ const failure = (e) => ({
17
17
  const READ_ONLY = { readOnlyHint: true, openWorldHint: false };
18
18
  export function registerAulaVirtualTools(server, session, files) {
19
19
  server.registerTool("aula_get_site_info", {
20
- description: "Datos del usuario logueado en el aula virtual de la UTN FRBA y del sitio Moodle.",
20
+ description: "Datos del usuario logueado y del sitio Moodle de su aula virtual. `regional` es la regional de la UTN " +
21
+ "de esa aula (por ejemplo, \"Tucumán (FRT)\"): usala para hablarle de su facultad.",
21
22
  annotations: READ_ONLY,
22
23
  }, async () => {
23
24
  try {
24
- const { sitename, fullname, username, userid, release } = await getSiteInfo(session);
25
- return json({ sitename, fullname, username, userid, release });
25
+ const [{ sitename, fullname, username, userid, release }, regional] = await Promise.all([
26
+ getSiteInfo(session),
27
+ session.getRegional(),
28
+ ]);
29
+ return json({ regional: regional.name, sitename, fullname, username, userid, release });
26
30
  }
27
31
  catch (e) {
28
32
  return failure(e);
@@ -271,7 +275,8 @@ export function registerAulaVirtualTools(server, session, files) {
271
275
  '`-F "archivos=@..."` por archivo, todos en el mismo comando. Responde JSON con lo recibido.\n' +
272
276
  "- Si no, pasale el `url` al usuario: se abre en el navegador, también en el celu, y elige " +
273
277
  "los archivos ahí. Esperá a que te diga que los subió.\n\n" +
274
- "Subir al link no entrega nada; una subida nueva al mismo link reemplaza la anterior. " +
278
+ "Subir al link no entrega nada. Cada link acepta una sola subida: si hay que cambiar los " +
279
+ "archivos, pedí otro link. " +
275
280
  "`acepta` trae los límites de la tarea.",
276
281
  inputSchema: z.object({
277
282
  direccion: z
@@ -299,7 +304,8 @@ export function registerAulaVirtualTools(server, session, files) {
299
304
  'Ej: ["C:\\Users\\ana\\Documents\\TP3.pdf"].'
300
305
  : "Lista final de los archivos que ya están en la entrega, por su `url` de aula_open; los " +
301
306
  "de `subida` se suman. Lista vacía: quita los que estaban. " +
302
- 'Ej: ["https://aulasvirtuales.frba.utn.edu.ar/pluginfile.php/123/assignsubmission_file/submission_files/456/TP3.pdf"].'),
307
+ 'Ej: ["https://<aula>/pluginfile.php/123/assignsubmission_file/submission_files/456/TP3.pdf"], ' +
308
+ "con el dominio del aula tal como viene en la `url`."),
303
309
  texto: z
304
310
  .string()
305
311
  .optional()