utn-mcp 0.6.7 → 0.7.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -1,146 +1,180 @@
1
- # UTN-MCP — El puente entre tu agente y los sistemas de la Universidad.
2
- Este es el Model Context Protocol server NO OFICIAL de la UTN. Con él podrás pedirle a tu agente de preferencia (ChatGPT, Claude, etc.) que busque informacion o archivos en el aula virtual.
3
-
4
- ## Qué podés pedirle
5
- - *"¿Cuándo es el recuperatorio del primer parcial de Álgebra?"*
6
- - *"¿Qué avisó la profesora de Física esta semana?"*
7
- - *"Resumime la guía de la unidad 3 de Sistemas Operativos."*
8
- - *"Pasame el link para bajar el apunte de calorimetría."*
9
- - *"¿Qué nota me pusieron en el TP1 y qué me corrigieron?"*
10
- - *"Preguntá en el foro de consultas si el parcial incluye la unidad 4."*
11
- - *"Anotame en el grupo 6."*
12
- - *"Agendame el recuperatorio del 14 a las 18."*
13
-
14
- El agente recorre el aula como lo harías vos: entra al curso, mira los foros y abre los archivos que hagan falta.
15
-
16
- ## Tools
17
- | Tool | Qué hace |
18
- |---|---|
19
- | `aula_list_courses` | Lista tus cursos. Es el punto de partida. |
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
- | `aula_read_file` | Lee un archivo del aula, por partes cuando es largo. |
22
- | `aula_get_site_info` | Datos de tu usuario en el aula virtual. |
23
- | `aula_forum_post` | Abre un hilo en un foro o responde en una discusión. |
24
- | `aula_forum_edit_post` | Edita una publicación tuya. |
25
- | `aula_forum_delete_post` | Borra una publicación tuya. |
26
- | `aula_choice_answer` | Responde una elección o te anota en un grupo. |
27
- | `aula_send_message` | Manda un mensaje privado a un docente o compañero. |
28
- | `aula_calendar_add_event` | Agrega un recordatorio a tu calendario del aula. |
29
-
30
- Al abrir una tarea, `aula_open` muestra si la entregaste, cuándo vence y la devolución con la nota. Las tools que escriben le piden al agente que te muestre lo que va a hacer y espere tu confirmación.
31
-
32
- ### Archivos que puede leer
33
- - **PDF**: el texto, página por página. Los **escaneados** se mandan como imagen de cada página, y el agente los lee igual.
34
- - **Word (.docx), PowerPoint (.pptx) y Excel (.xlsx)**: el texto, con las tablas fila por fila.
35
- - **Imágenes** (PNG, JPG, GIF) y texto plano.
36
-
37
- Los formatos viejos de Office (.doc, .ppt, .pps), los comprimidos (.zip, .rar) y los videos no se leen. En ese caso el agente te pasa el link para que lo bajes.
38
-
39
- ### Links de descarga
40
- Cada archivo viene con un link del aula virtual que podés abrir en tu navegador. Funciona con tu sesión del aula (si no estás logueado te manda al login) y **nunca incluye tu token**: ese queda siempre en tu computadora.
41
-
42
- ## Cómo instalar
43
- Necesitás [Node.js](https://nodejs.org) 20 o superior. No hace falta clonar el repo ni instalar nada a mano: `npx` descarga el paquete solo.
44
-
45
- ### 1. Iniciar sesión
46
- ```bash
47
- npx -y utn-mcp login
48
- ```
49
- Te pide tu usuario institucional y tu contraseña de la UTN (los mismos de "Usuarios Institucionales" en el aula virtual). Se hace una sola vez: después el servidor renueva la sesión solo.
50
-
51
- ### 2. Agregarlo a tu cliente
52
-
53
- **Claude Code**
54
- ```bash
55
- claude mcp add utn -- npx -y utn-mcp
56
- ```
57
-
58
- **Claude Desktop**: en *Settings → Developer → Edit Config*, agregá dentro de `mcpServers`:
59
- ```json
60
- "utn": {
61
- "command": "npx",
62
- "args": ["-y", "utn-mcp"]
63
- }
64
- ```
65
- En Windows, si no arranca, usá `"command": "cmd"` y `"args": ["/c", "npx", "-y", "utn-mcp"]`. Después reiniciá Claude Desktop por completo.
66
-
67
- **Codex**
68
- ```bash
69
- codex mcp add utn -- npx -y utn-mcp
70
- ```
71
-
72
- **Gemini CLI**
73
- ```bash
74
- gemini mcp add utn npx -y utn-mcp
75
- ```
76
-
77
- **OpenClaw**
78
- ```bash
79
- openclaw mcp add utn --command npx --arg -y --arg utn-mcp
80
- ```
81
-
82
- **Hermes**: en `~/.hermes/config.yaml`:
83
- ```yaml
84
- mcp_servers:
85
- utn:
86
- command: "npx"
87
- args: ["-y", "utn-mcp"]
88
- ```
89
-
90
- **Cursor**: en `~/.cursor/mcp.json`:
91
- ```json
92
- {
93
- "mcpServers": {
94
- "utn": { "command": "npx", "args": ["-y", "utn-mcp"] }
95
- }
96
- }
97
- ```
98
-
99
- **VS Code**: en `.vscode/mcp.json` (o con *MCP: Add Server* desde la paleta de comandos):
100
- ```json
101
- {
102
- "servers": {
103
- "utn": { "type": "stdio", "command": "npx", "args": ["-y", "utn-mcp"] }
104
- }
105
- }
106
- ```
107
-
108
- **ChatGPT**: todavía no. ChatGPT sólo se conecta a servidores MCP remotos por HTTPS, y utn-mcp corre en tu computadora. Va a funcionar con la versión hosteada.
109
-
110
- **Otros clientes (Windsurf, opencode, etc.)**: cualquier cliente MCP que lance servidores por STDIO sirve con el comando `npx -y utn-mcp`.
111
-
112
- ### Instalación global (opcional)
113
- Si preferís tener el comando `utn-mcp` siempre disponible:
114
- ```bash
115
- npm i -g utn-mcp
116
- utn-mcp login
117
- ```
118
- y en la config del cliente usás `"command": "utn-mcp"` sin argumentos.
119
-
120
- ### Modo HTTP (opcional)
121
- ```bash
122
- npx -y utn-mcp --http --port 3000
123
- ```
124
- Levanta el servidor en `http://127.0.0.1:3000/mcp`, solo accesible desde tu propia máquina. Tenés que dejar la terminal abierta mientras lo uses.
125
-
126
- ## Dónde se guardan tus datos
127
- Todo queda en tu computadora:
128
- - **Usuario y contraseña**: en el gestor de credenciales del sistema (Windows Credential Manager, Keychain en macOS, Secret Service en Linux).
129
- - **Token del aula virtual y cookies de sesión**: en `%APPDATA%\utn-mcp\session.json` (Windows) o `~/.config/utn-mcp/session.json` (Linux y macOS).
130
-
131
- En Linux sin gestor de credenciales (servidores, Docker, WSL) podés definir las variables de entorno `UTN_USER` y `UTN_PASS` en lugar de usar `login`.
132
-
133
- ## Desarrollo
134
- ```bash
135
- git clone https://github.com/nialvape/utn-mcp.git
136
- cd utn-mcp
137
- npm install
138
- npm run login # una vez
139
- npm run dev # servidor por STDIO desde src/, con tsx
140
- npm run build # compila a build/
141
- ```
142
-
143
- pdf.js no es una dependencia del paquete publicado. Está como `devDependency`, y `npm install` corre el script `prepare`, que copia a `vendor/pdfjs/` solo los archivos que usa el servidor (~4 MB). Así, quien instala `utn-mcp` no baja pdf.js entero ni su canvas nativo opcional (~37 MB). Si instalaste con `--ignore-scripts`, generalo a mano con `npm run prepare`.
144
-
145
- ## Aviso
146
- Este proyecto no está afiliado ni avalado por la UTN. Usalo bajo tu responsabilidad.
1
+ # UTN-MCP — El puente entre tu agente y los sistemas de la Universidad.
2
+ Este es el Model Context Protocol server NO OFICIAL de la UTN. Con él podrás pedirle a tu agente de preferencia (ChatGPT, Claude, etc.) que busque informacion o archivos en el aula virtual.
3
+
4
+ ## Qué podés pedirle
5
+ - *"¿Cuándo es el recuperatorio del primer parcial de Álgebra?"*
6
+ - *"¿Qué avisó la profesora de Física esta semana?"*
7
+ - *"Resumime la guía de la unidad 3 de Sistemas Operativos."*
8
+ - *"Pasame el link para bajar el apunte de calorimetría."*
9
+ - *"¿Qué nota me pusieron en el TP1 y qué me corrigieron?"*
10
+ - *"Preguntá en el foro de consultas si el parcial incluye la unidad 4."*
11
+ - *"Anotame en el grupo 6."*
12
+ - *"Agendame el recuperatorio del 14 a las 18."*
13
+
14
+ El agente recorre el aula como lo harías vos: entra al curso, mira los foros y abre los archivos que hagan falta.
15
+
16
+ ## Tools
17
+ | Tool | Qué hace |
18
+ |---|---|
19
+ | `aula_list_courses` | Lista tus cursos. Es el punto de partida. |
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
+ | `aula_read_file` | Lee un archivo del aula, por partes cuando es largo. |
22
+ | `aula_get_site_info` | Datos de tu usuario en el aula virtual. |
23
+ | `aula_forum_post` | Abre un hilo en un foro o responde en una discusión. |
24
+ | `aula_forum_edit_post` | Edita una publicación tuya. |
25
+ | `aula_forum_delete_post` | Borra una publicación tuya. |
26
+ | `aula_assign_submit` | Entrega una tarea: sube archivos de tu compu, escribe el texto en línea y confirma el envío. |
27
+ | `aula_assign_upload_link` | Solo en modo HTTP: un link para subir los archivos de una entrega, que usa el agente con `curl` o vos desde el navegador. |
28
+ | `aula_choice_answer` | Responde una elección o te anota en un grupo. |
29
+ | `aula_send_message` | Manda un mensaje privado a un docente o compañero. |
30
+ | `aula_calendar_add_event` | Agrega un recordatorio a tu calendario del aula. |
31
+
32
+ Al abrir una tarea, `aula_open` muestra si la entregaste, cuándo vence y la devolución con la nota. Las tools que escriben le piden al agente que te muestre lo que va a hacer y espere tu confirmación.
33
+
34
+ ### Archivos que puede leer
35
+ - **PDF**: el texto, página por página. Los **escaneados** se mandan como imagen de cada página, y el agente los lee igual.
36
+ - **Word (.docx), PowerPoint (.pptx) y Excel (.xlsx)**: el texto, con las tablas fila por fila.
37
+ - **Imágenes** (PNG, JPG, GIF) y texto plano.
38
+
39
+ Los formatos viejos de Office (.doc, .ppt, .pps), los comprimidos (.zip, .rar) y los videos no se leen. En ese caso el agente te pasa el link para que lo bajes.
40
+
41
+ ### Links de descarga
42
+ Cada archivo viene con un link del aula virtual que podés abrir en tu navegador. Funciona con tu sesión del aula (si no estás logueado te manda al login) y **nunca incluye tu token**: ese queda siempre en tu computadora.
43
+
44
+ ## Cómo instalar
45
+ Necesitás [Node.js](https://nodejs.org) 20 o superior. No hace falta clonar el repo ni instalar nada a mano: `npx` descarga el paquete solo.
46
+
47
+ ### 1. Iniciar sesión
48
+ ```bash
49
+ npx -y utn-mcp login
50
+ ```
51
+ Te pide tu usuario institucional y tu contraseña de la UTN (los mismos de "Usuarios Institucionales" en el aula virtual). Se hace una sola vez: después el servidor renueva la sesión solo.
52
+
53
+ ### 2. Agregarlo a tu cliente
54
+
55
+ **Claude Code**
56
+ ```bash
57
+ claude mcp add utn -- npx -y utn-mcp
58
+ ```
59
+
60
+ **Claude Desktop**: en *Settings → Developer → Edit Config*, agregá dentro de `mcpServers`:
61
+ ```json
62
+ "utn": {
63
+ "command": "npx",
64
+ "args": ["-y", "utn-mcp"]
65
+ }
66
+ ```
67
+ En Windows, si no arranca, usá `"command": "cmd"` y `"args": ["/c", "npx", "-y", "utn-mcp"]`. Después reiniciá Claude Desktop por completo.
68
+
69
+ **Codex**
70
+ ```bash
71
+ codex mcp add utn -- npx -y utn-mcp
72
+ ```
73
+
74
+ **Gemini CLI**
75
+ ```bash
76
+ gemini mcp add utn npx -y utn-mcp
77
+ ```
78
+
79
+ **OpenClaw**
80
+ ```bash
81
+ openclaw mcp add utn --command npx --arg -y --arg utn-mcp
82
+ ```
83
+
84
+ **Hermes**: en `~/.hermes/config.yaml`:
85
+ ```yaml
86
+ mcp_servers:
87
+ utn:
88
+ command: "npx"
89
+ args: ["-y", "utn-mcp"]
90
+ ```
91
+
92
+ **Cursor**: en `~/.cursor/mcp.json`:
93
+ ```json
94
+ {
95
+ "mcpServers": {
96
+ "utn": { "command": "npx", "args": ["-y", "utn-mcp"] }
97
+ }
98
+ }
99
+ ```
100
+
101
+ **VS Code**: en `.vscode/mcp.json` (o con *MCP: Add Server* desde la paleta de comandos):
102
+ ```json
103
+ {
104
+ "servers": {
105
+ "utn": { "type": "stdio", "command": "npx", "args": ["-y", "utn-mcp"] }
106
+ }
107
+ }
108
+ ```
109
+
110
+ **ChatGPT**: todavía no. ChatGPT sólo se conecta a servidores MCP remotos por HTTPS, y utn-mcp corre en tu computadora. Va a funcionar con la versión hosteada.
111
+
112
+ **Otros clientes (Windsurf, opencode, etc.)**: cualquier cliente MCP que lance servidores por STDIO sirve con el comando `npx -y utn-mcp`.
113
+
114
+ ### Instalación global (opcional)
115
+ Si preferís tener el comando `utn-mcp` siempre disponible:
116
+ ```bash
117
+ npm i -g utn-mcp
118
+ utn-mcp login
119
+ ```
120
+ y en la config del cliente usás `"command": "utn-mcp"` sin argumentos.
121
+
122
+ ### Modo HTTP (opcional)
123
+ Necesita Node 22.13 o más nuevo. Primero, una vez:
124
+ ```bash
125
+ npx -y utn-mcp login --http
126
+ ```
127
+ Te muestra un token: configurá tu cliente MCP para que mande el header `Authorization: Bearer <token>`. Después:
128
+ ```bash
129
+ npx -y utn-mcp --http --port 3000
130
+ ```
131
+ Levanta el servidor en `http://127.0.0.1:3000/mcp`, solo accesible desde tu propia máquina. Tenés que dejar la terminal abierta mientras lo uses.
132
+
133
+ En este modo el servidor no lee archivos de tu disco: para entregar, el agente pide un link con `aula_assign_upload_link` y sube ahí los archivos con `curl` si tiene terminal, o te pasa el link para que los elijas desde el navegador.
134
+
135
+ ### Hostearlo para otros
136
+ En modo HTTP el servidor también es servidor de autorización OAuth: un cliente MCP (Claude, ChatGPT) que se conecta a `https://tu-dominio/mcp` recibe un 401, descubre el OAuth y abre una página donde el alumno entra con su usuario de la UTN. Ese login vincula su aula: la contraseña se usa una sola vez para sacar el token de Moodle y **no se guarda**. Cuando la sesión del aula vence, el agente le pasa el link de `/login` para renovarla, sin reconectar el cliente.
137
+
138
+ Quién puede entrar lo decidís vos: con `UTN_MCP_ALLOWED_EMAILS` (una lista), o con `UTN_MCP_ACCESS_URL`, que apunta a un servicio tuyo. El servidor le hace `POST` con `{ "email", "usuario" }` y el header `Authorization: Bearer <UTN_MCP_ACCESS_SECRET>`, y espera `{ "permitido": true }` o `{ "permitido": false, "motivo": "...", "url": "..." }`. Se consulta al conectar y en cada renovación del token (cada hora), así que una baja se aplica sola.
139
+
140
+ La configuración va en variables de entorno o en un `.env` en la carpeta desde donde lo corrés (copiá [`.env.example`](.env.example)):
141
+
142
+ | Variable | Para qué | Por defecto |
143
+ |---|---|---|
144
+ | `HOST`, `PORT` | Dónde escucha. | `127.0.0.1`, `3000` |
145
+ | `UTN_MCP_PUBLIC_URL` | Dirección pública (`https://mcp.tu-dominio.com`). Es el issuer del OAuth, la base de los links de subida y el único host, además de localhost, que se acepta en `Host` y `Origin`. | `http://127.0.0.1:<PORT>` |
146
+ | `UTN_MCP_ACCESS_URL`, `UTN_MCP_ACCESS_SECRET` | Servicio que decide quién puede entrar. | — |
147
+ | `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 |
148
+ | `UTN_MCP_ADMIN_SECRET` | Habilita `POST /admin/clientes` y `/admin/clientes/revocar`, para que un panel tuyo liste y desconecte las apps de cada usuario (por email). | — |
149
+ | `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` |
150
+ | `UTN_MCP_DB`, `UTN_MCP_KEY` | Base de sesiones y su clave de cifrado (32 bytes en base64). | En la carpeta de config |
151
+
152
+ 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):
153
+ ```bash
154
+ docker build -t utn-mcp .
155
+ docker run -d --name utn-mcp --network host --restart unless-stopped -e HOST=127.0.0.1 -e PORT=8100 -e UTN_MCP_PUBLIC_URL=https://tu-dominio -e UTN_MCP_KEY=$(openssl rand -base64 32) -v utn-mcp-data:/data utn-mcp
156
+ ```
157
+ Guardá esa `UTN_MCP_KEY`: sin ella no se pueden descifrar las sesiones guardadas.
158
+
159
+ ## Dónde se guardan tus datos
160
+ Todo queda en tu computadora:
161
+ - **Usuario y contraseña**: en el gestor de credenciales del sistema (Windows Credential Manager, Keychain en macOS, Secret Service en Linux).
162
+ - **Token del aula virtual y cookies de sesión**: en `%APPDATA%\utn-mcp\session.json` (Windows) o `~/.config/utn-mcp/session.json` (Linux y macOS).
163
+ - **En modo HTTP**: la contraseña no se guarda; la sesión va cifrada en `utn-mcp.db`, en la misma carpeta, con la clave de `store.key` (o la de `UTN_MCP_KEY`). Del token del cliente solo se guarda el hash.
164
+
165
+ En Linux sin gestor de credenciales (servidores, Docker, WSL) podés definir las variables de entorno `UTN_USER` y `UTN_PASS` en lugar de usar `login`.
166
+
167
+ ## Desarrollo
168
+ ```bash
169
+ git clone https://github.com/nialvape/utn-mcp.git
170
+ cd utn-mcp
171
+ npm install
172
+ npm run login # una vez
173
+ npm run dev # servidor por STDIO desde src/, con tsx
174
+ npm run build # compila a build/
175
+ ```
176
+
177
+ pdf.js no es una dependencia del paquete publicado. Está como `devDependency`, y `npm install` corre el script `prepare`, que copia a `vendor/pdfjs/` solo los archivos que usa el servidor (~4 MB). Así, quien instala `utn-mcp` no baja pdf.js entero ni su canvas nativo opcional (~37 MB). Si instalaste con `--ignore-scripts`, generalo a mano con `npm run prepare`.
178
+
179
+ ## Aviso
180
+ Este proyecto no está afiliado ni avalado por la UTN. Usalo bajo tu responsabilidad.
@@ -2,6 +2,15 @@ import { callWs, MoodleError } from "./moodle-api.js";
2
2
  export function getSiteInfo(session) {
3
3
  return callWs(session, "core_webservice_get_site_info");
4
4
  }
5
+ /** El usuario dueño del token, con su email institucional (site_info no lo trae). */
6
+ export async function getOwnUser(session) {
7
+ const { userid, username } = await getSiteInfo(session);
8
+ const [user] = await callWs(session, "core_user_get_users_by_field", {
9
+ field: "id",
10
+ "values[0]": userid,
11
+ });
12
+ return { username, email: user?.email || null };
13
+ }
5
14
  export async function getUserCourses(session) {
6
15
  const { userid } = await getSiteInfo(session);
7
16
  return callWs(session, "core_enrol_get_users_courses", { userid });
@@ -73,6 +82,52 @@ export async function getAssignments(session, courseId) {
73
82
  export function getSubmissionStatus(session, assignid) {
74
83
  return callWs(session, "mod_assign_get_submission_status", { assignid });
75
84
  }
85
+ /** Un borrador vacío: el `itemid` que se manda para dejar un área sin archivos. */
86
+ export async function getUnusedDraftItemId(session) {
87
+ const { itemid } = await callWs(session, "core_files_get_unused_draft_itemid");
88
+ return itemid;
89
+ }
90
+ /** Moodle no tira excepción cuando no guarda o no envía: lo dice en `warnings`. */
91
+ function throwOnWarnings(warnings, fallback) {
92
+ if (warnings?.length)
93
+ throw new MoodleError(warnings.map((w) => w.message).join(" ") || fallback);
94
+ }
95
+ /**
96
+ * Guarda la entrega. Cada plugin habilitado reemplaza lo que tenía con lo que se le pasa: el de
97
+ * archivos toma el borrador `filesItemId` entero y el de texto, `text`.
98
+ */
99
+ export async function saveSubmission(session, assignmentid, data) {
100
+ const params = { assignmentid };
101
+ if (data.filesItemId !== undefined)
102
+ params["plugindata[files_filemanager]"] = data.filesItemId;
103
+ if (data.text) {
104
+ params["plugindata[onlinetext_editor][text]"] = data.text.html;
105
+ params["plugindata[onlinetext_editor][format]"] = data.text.format;
106
+ params["plugindata[onlinetext_editor][itemid]"] = data.text.itemid;
107
+ }
108
+ const warnings = await callWs(session, "mod_assign_save_submission", params);
109
+ throwOnWarnings(warnings, "Moodle no guardó la entrega.");
110
+ }
111
+ /** Confirma el envío de un borrador. Después ya no se puede editar. */
112
+ export async function submitForGrading(session, assignmentid, acceptStatement) {
113
+ const warnings = await callWs(session, "mod_assign_submit_for_grading", {
114
+ assignmentid,
115
+ acceptsubmissionstatement: acceptStatement ? 1 : 0,
116
+ });
117
+ throwOnWarnings(warnings, "Moodle no envió la entrega.");
118
+ }
119
+ /**
120
+ * Vacía la entrega del usuario (o la de su grupo). Es la única forma de dejarla sin nada:
121
+ * mod_assign_save_submission rechaza una entrega vacía.
122
+ */
123
+ export async function removeSubmission(session, assignid) {
124
+ const { userid } = await getSiteInfo(session);
125
+ const warnings = await callWs(session, "mod_assign_remove_submission", {
126
+ userid,
127
+ assignid,
128
+ });
129
+ throwOnWarnings(warnings, "Moodle no quitó la entrega.");
130
+ }
76
131
  // --- Elecciones (choice) y selección de grupos (choicegroup) ---
77
132
  export async function getChoices(session, courseId) {
78
133
  const { choices } = await callWs(session, "mod_choice_get_choices_by_courses", {
@@ -0,0 +1,27 @@
1
+ import { CookieJar } from "tough-cookie";
2
+ import { getOwnUser } from "./client.js";
3
+ import { fetchToken } from "./sso-login.js";
4
+ import { AULA_PLATFORM } from "./stored-session.js";
5
+ /**
6
+ * Entra al aula con usuario y contraseña, crea el usuario del store si no existe y le guarda la
7
+ * sesión (token y cookies del SSO). La contraseña se usa solo acá: no se guarda en ningún lado.
8
+ * Lo usan `utn-mcp login --http` y el login por OAuth de la versión hosteada.
9
+ */
10
+ export async function linkAulaAccount(store, credentials) {
11
+ const jar = new CookieJar();
12
+ const token = await fetchToken(jar, credentials);
13
+ // El token recién emitido: si falla, no hay con qué renovarlo.
14
+ const fresh = {
15
+ getToken: async () => token,
16
+ refreshToken: async () => {
17
+ throw new Error("El aula rechazó el token recién emitido.");
18
+ },
19
+ };
20
+ const own = await getOwnUser(fresh);
21
+ // El de Moodle, en minúscula, igual que en `users`: es con lo que se compara al renovar.
22
+ const username = own.username.toLowerCase();
23
+ const userId = await store.findOrCreateUser(username, own.email ?? undefined);
24
+ const data = { username, token, cookies: await jar.serialize() };
25
+ await store.saveSession(userId, AULA_PLATFORM, JSON.stringify(data));
26
+ return { userId, username, email: own.email };
27
+ }
@@ -1,6 +1,7 @@
1
1
  import { postForm } from "../../shared/http.js";
2
2
  import { AULA_BASE_URL } from "./constants.js";
3
3
  const WS_URL = `${AULA_BASE_URL}/webservice/rest/server.php`;
4
+ const UPLOAD_URL = `${AULA_BASE_URL}/webservice/upload.php`;
4
5
  const TOKEN_ERRORS = new Set(["invalidtoken", "expiredtoken"]);
5
6
  export class MoodleError extends Error {
6
7
  errorcode;
@@ -60,6 +61,39 @@ export async function fetchFile(session, url, maxBytes) {
60
61
  return { bytes: await readWithLimit(res, maxBytes), contentType };
61
62
  }
62
63
  }
64
+ /**
65
+ * Sube archivos al área de borrador del usuario por `webservice/upload.php` y devuelve su `itemid`,
66
+ * que es lo que piden las funciones que guardan archivos. Todos quedan en el mismo borrador.
67
+ */
68
+ export async function uploadDraftFiles(session, files) {
69
+ let token = await session.getToken();
70
+ for (let attempt = 0;; attempt++) {
71
+ const form = new FormData();
72
+ form.set("token", token);
73
+ form.set("filearea", "draft");
74
+ form.set("itemid", "0");
75
+ files.forEach((f, i) => form.set(`file_${i + 1}`, new Blob([new Uint8Array(f.bytes)]), f.name));
76
+ let res;
77
+ try {
78
+ res = await fetch(UPLOAD_URL, { method: "POST", body: form, signal: AbortSignal.timeout(120_000) });
79
+ }
80
+ catch (e) {
81
+ throw new MoodleError(`No se pudo subir el archivo: ${e.message}`);
82
+ }
83
+ if (!res.ok)
84
+ throw new MoodleError(`HTTP ${res.status} al subir el archivo`);
85
+ // Si sale bien, un registro por archivo; si no, `{ error, errorcode }`.
86
+ const data = (await res.json());
87
+ if (Array.isArray(data) && data[0])
88
+ return data[0].itemid;
89
+ const err = Array.isArray(data) ? {} : data;
90
+ if (attempt === 0 && err.errorcode && TOKEN_ERRORS.has(err.errorcode)) {
91
+ token = await session.refreshToken();
92
+ continue;
93
+ }
94
+ throw new MoodleError(err.error ?? "Moodle no aceptó el archivo.", err.errorcode);
95
+ }
96
+ }
63
97
  /** Lee el cuerpo cortando apenas pasa el límite, sin esperar a bajarlo entero. */
64
98
  async function readWithLimit(res, maxBytes) {
65
99
  const tooBig = `El archivo pesa más de ${Math.round(maxBytes / 1024 / 1024)} MB; es demasiado para leerlo.`;
@@ -1,2 +1 @@
1
- // para version hosteada y multiples usuarios/sesiones
2
1
  export {};
@@ -0,0 +1,65 @@
1
+ import { CookieJar } from "tough-cookie";
2
+ import { loadCredentials } from "./credentials.js";
3
+ import { fetchToken, LoginRequiredError } from "./sso-login.js";
4
+ /** Con qué nombre guarda el aula su sesión en el store. */
5
+ export const AULA_PLATFORM = "aula";
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.
10
+ */
11
+ export class StoredAulaSession {
12
+ store;
13
+ userId;
14
+ loginUrl;
15
+ refreshing = null;
16
+ /** `loginUrl` es la página donde el usuario vuelve a iniciar sesión, si el servidor tiene una. */
17
+ constructor(store, userId, loginUrl) {
18
+ this.store = store;
19
+ this.userId = userId;
20
+ this.loginUrl = loginUrl;
21
+ }
22
+ async getToken() {
23
+ return (await this.read()).token;
24
+ }
25
+ refreshToken() {
26
+ this.refreshing ??= this.renew().finally(() => {
27
+ this.refreshing = null;
28
+ });
29
+ return this.refreshing;
30
+ }
31
+ 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.
36
+ 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() };
49
+ await this.store.saveSession(this.userId, AULA_PLATFORM, JSON.stringify(data));
50
+ return token;
51
+ }
52
+ async read() {
53
+ const raw = await this.store.loadSession(this.userId, AULA_PLATFORM);
54
+ if (!raw)
55
+ throw this.loginRequired("No hay sesión del aula para este usuario.");
56
+ return JSON.parse(raw);
57
+ }
58
+ /** Hosteado, el usuario vuelve a entrar por la página del servidor; sin ella, por el CLI. */
59
+ loginRequired(reason) {
60
+ const next = this.loginUrl
61
+ ? `Pedile al usuario que vuelva a iniciar sesión en ${this.loginUrl} y después reintentá.`
62
+ : "Volvé a iniciar sesión con `utn-mcp login --http` en la máquina del servidor.";
63
+ return new LoginRequiredError(`${reason} ${next}`);
64
+ }
65
+ }
@@ -45,6 +45,7 @@ export async function viewSubmission(session, courseId, module) {
45
45
  // Con borradores, lo subido no cuenta hasta confirmar el envío.
46
46
  requiereConfirmarEnvio: assignment?.submissiondrafts === 1 || undefined,
47
47
  requiereDeclaracion: assignment?.requiresubmissionstatement === 1 || undefined,
48
+ declaracion: (assignment?.requiresubmissionstatement === 1 && htmlToText(assignment.submissionstatement)) || undefined,
48
49
  puedeEditar: attempt?.canedit ?? false,
49
50
  puedeEnviar: attempt?.cansubmit || undefined,
50
51
  bloqueada: attempt?.locked || undefined,