utn-mcp 0.1.0 → 0.2.1
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 +134 -56
- package/build/aula-virtual/address.js +39 -0
- package/build/aula-virtual/client/client.js +28 -10
- package/build/aula-virtual/client/moodle-api.js +53 -0
- package/build/aula-virtual/files.js +89 -0
- package/build/aula-virtual/navigate.js +144 -0
- package/build/aula-virtual/tools.js +86 -4
- package/build/http.js +0 -5
- package/build/server.js +1 -1
- package/build/shared/documents/index.js +68 -0
- package/build/shared/documents/office.js +118 -0
- package/build/shared/documents/pdf.js +164 -0
- package/build/shared/documents/png.js +51 -0
- package/build/shared/documents/zip.js +72 -0
- package/build/shared/html.js +19 -0
- package/package.json +6 -3
- package/vendor/pdfjs/LICENSE +177 -0
- package/vendor/pdfjs/pdf.mjs +34672 -0
- package/vendor/pdfjs/pdf.worker.mjs +71124 -0
- package/vendor/pdfjs/wasm/LICENSE_PDFJS_JBIG2 +13 -0
- package/vendor/pdfjs/wasm/LICENSE_PDFJS_OPENJPEG +22 -0
- package/vendor/pdfjs/wasm/jbig2_nowasm_fallback.js +15 -0
- package/vendor/pdfjs/wasm/openjpeg_nowasm_fallback.js +17 -0
package/README.md
CHANGED
|
@@ -1,56 +1,134 @@
|
|
|
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
|
-
##
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
**
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
```
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
En
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
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
|
+
|
|
10
|
+
El agente recorre el aula como lo harías vos: entra al curso, mira los foros y abre los archivos que hagan falta.
|
|
11
|
+
|
|
12
|
+
## Tools
|
|
13
|
+
| Tool | Qué hace |
|
|
14
|
+
|---|---|
|
|
15
|
+
| `aula_list_courses` | Lista tus cursos. Es el punto de partida. |
|
|
16
|
+
| `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. |
|
|
17
|
+
| `aula_read_file` | Lee un archivo del aula, por partes cuando es largo. |
|
|
18
|
+
| `aula_get_site_info` | Datos de tu usuario en el aula virtual. |
|
|
19
|
+
|
|
20
|
+
### Archivos que puede leer
|
|
21
|
+
- **PDF**: el texto, página por página. Los **escaneados** se mandan como imagen de cada página, y el agente los lee igual.
|
|
22
|
+
- **Word (.docx), PowerPoint (.pptx) y Excel (.xlsx)**: el texto, con las tablas fila por fila.
|
|
23
|
+
- **Imágenes** (PNG, JPG, GIF) y texto plano.
|
|
24
|
+
|
|
25
|
+
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.
|
|
26
|
+
|
|
27
|
+
### Links de descarga
|
|
28
|
+
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.
|
|
29
|
+
|
|
30
|
+
## Cómo instalar
|
|
31
|
+
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.
|
|
32
|
+
|
|
33
|
+
### 1. Iniciar sesión
|
|
34
|
+
```bash
|
|
35
|
+
npx -y utn-mcp login
|
|
36
|
+
```
|
|
37
|
+
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.
|
|
38
|
+
|
|
39
|
+
### 2. Agregarlo a tu cliente
|
|
40
|
+
|
|
41
|
+
**Claude Code**
|
|
42
|
+
```bash
|
|
43
|
+
claude mcp add utn -- npx -y utn-mcp
|
|
44
|
+
```
|
|
45
|
+
|
|
46
|
+
**Claude Desktop**: en *Settings → Developer → Edit Config*, agregá dentro de `mcpServers`:
|
|
47
|
+
```json
|
|
48
|
+
"utn": {
|
|
49
|
+
"command": "npx",
|
|
50
|
+
"args": ["-y", "utn-mcp"]
|
|
51
|
+
}
|
|
52
|
+
```
|
|
53
|
+
En Windows, si no arranca, usá `"command": "cmd"` y `"args": ["/c", "npx", "-y", "utn-mcp"]`. Después reiniciá Claude Desktop por completo.
|
|
54
|
+
|
|
55
|
+
**Codex**
|
|
56
|
+
```bash
|
|
57
|
+
codex mcp add utn -- npx -y utn-mcp
|
|
58
|
+
```
|
|
59
|
+
|
|
60
|
+
**Gemini CLI**
|
|
61
|
+
```bash
|
|
62
|
+
gemini mcp add utn npx -y utn-mcp
|
|
63
|
+
```
|
|
64
|
+
|
|
65
|
+
**OpenClaw**
|
|
66
|
+
```bash
|
|
67
|
+
openclaw mcp add utn --command npx --arg -y --arg utn-mcp
|
|
68
|
+
```
|
|
69
|
+
|
|
70
|
+
**Hermes**: en `~/.hermes/config.yaml`:
|
|
71
|
+
```yaml
|
|
72
|
+
mcp_servers:
|
|
73
|
+
utn:
|
|
74
|
+
command: "npx"
|
|
75
|
+
args: ["-y", "utn-mcp"]
|
|
76
|
+
```
|
|
77
|
+
|
|
78
|
+
**Cursor**: en `~/.cursor/mcp.json`:
|
|
79
|
+
```json
|
|
80
|
+
{
|
|
81
|
+
"mcpServers": {
|
|
82
|
+
"utn": { "command": "npx", "args": ["-y", "utn-mcp"] }
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
```
|
|
86
|
+
|
|
87
|
+
**VS Code**: en `.vscode/mcp.json` (o con *MCP: Add Server* desde la paleta de comandos):
|
|
88
|
+
```json
|
|
89
|
+
{
|
|
90
|
+
"servers": {
|
|
91
|
+
"utn": { "type": "stdio", "command": "npx", "args": ["-y", "utn-mcp"] }
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
```
|
|
95
|
+
|
|
96
|
+
**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.
|
|
97
|
+
|
|
98
|
+
**Otros clientes (Windsurf, opencode, etc.)**: cualquier cliente MCP que lance servidores por STDIO sirve con el comando `npx -y utn-mcp`.
|
|
99
|
+
|
|
100
|
+
### Instalación global (opcional)
|
|
101
|
+
Si preferís tener el comando `utn-mcp` siempre disponible:
|
|
102
|
+
```bash
|
|
103
|
+
npm i -g utn-mcp
|
|
104
|
+
utn-mcp login
|
|
105
|
+
```
|
|
106
|
+
y en la config del cliente usás `"command": "utn-mcp"` sin argumentos.
|
|
107
|
+
|
|
108
|
+
### Modo HTTP (opcional)
|
|
109
|
+
```bash
|
|
110
|
+
npx -y utn-mcp --http --port 3000
|
|
111
|
+
```
|
|
112
|
+
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.
|
|
113
|
+
|
|
114
|
+
## Dónde se guardan tus datos
|
|
115
|
+
Todo queda en tu computadora:
|
|
116
|
+
- **Usuario y contraseña**: en el gestor de credenciales del sistema (Windows Credential Manager, Keychain en macOS, Secret Service en Linux).
|
|
117
|
+
- **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).
|
|
118
|
+
|
|
119
|
+
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`.
|
|
120
|
+
|
|
121
|
+
## Desarrollo
|
|
122
|
+
```bash
|
|
123
|
+
git clone https://github.com/nialvape/utn-mcp.git
|
|
124
|
+
cd utn-mcp
|
|
125
|
+
npm install
|
|
126
|
+
npm run login # una vez
|
|
127
|
+
npm run dev # servidor por STDIO desde src/, con tsx
|
|
128
|
+
npm run build # compila a build/
|
|
129
|
+
```
|
|
130
|
+
|
|
131
|
+
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`.
|
|
132
|
+
|
|
133
|
+
## Aviso
|
|
134
|
+
Este proyecto no está afiliado ni avalado por la UTN. Usalo bajo tu responsabilidad.
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
export const RAIZ = "/";
|
|
2
|
+
export const cursoAddr = (courseId) => `/curso/${courseId}`;
|
|
3
|
+
export const moduloAddr = (courseId, cmid) => `/curso/${courseId}/mod/${cmid}`;
|
|
4
|
+
export const discusionAddr = (courseId, cmid, discussionId) => `/curso/${courseId}/mod/${cmid}/disc/${discussionId}`;
|
|
5
|
+
const FORMATOS = [
|
|
6
|
+
"/",
|
|
7
|
+
"/curso/{courseId}",
|
|
8
|
+
"/curso/{courseId}/mod/{cmid}",
|
|
9
|
+
"/curso/{courseId}/mod/{cmid}/disc/{discussionId}",
|
|
10
|
+
].join(", ");
|
|
11
|
+
const PATRON = /^\/curso\/(\d+)(?:\/mod\/(\d+)(?:\/disc\/(\d+))?)?$/;
|
|
12
|
+
export function parseAddress(raw) {
|
|
13
|
+
const path = raw.trim().replace(/\/+$/, "") || "/";
|
|
14
|
+
if (path === "/")
|
|
15
|
+
return { nivel: "raiz" };
|
|
16
|
+
const m = PATRON.exec(path);
|
|
17
|
+
if (!m)
|
|
18
|
+
throw new Error(`Dirección inválida: "${raw}". Formatos válidos: ${FORMATOS}`);
|
|
19
|
+
const [, curso, cmid, disc] = m;
|
|
20
|
+
const courseId = Number(curso);
|
|
21
|
+
if (!cmid)
|
|
22
|
+
return { nivel: "curso", courseId };
|
|
23
|
+
if (!disc)
|
|
24
|
+
return { nivel: "modulo", courseId, cmid: Number(cmid) };
|
|
25
|
+
return { nivel: "discusion", courseId, cmid: Number(cmid), discussionId: Number(disc) };
|
|
26
|
+
}
|
|
27
|
+
/** La dirección de un nivel más arriba, o null si ya estamos en la raíz. */
|
|
28
|
+
export function padre(address) {
|
|
29
|
+
switch (address.nivel) {
|
|
30
|
+
case "raiz":
|
|
31
|
+
return null;
|
|
32
|
+
case "curso":
|
|
33
|
+
return RAIZ;
|
|
34
|
+
case "modulo":
|
|
35
|
+
return cursoAddr(address.courseId);
|
|
36
|
+
case "discusion":
|
|
37
|
+
return moduloAddr(address.courseId, address.cmid);
|
|
38
|
+
}
|
|
39
|
+
}
|
|
@@ -2,15 +2,33 @@ import { callWs } from "./moodle-api.js";
|
|
|
2
2
|
export function getSiteInfo(session) {
|
|
3
3
|
return callWs(session, "core_webservice_get_site_info");
|
|
4
4
|
}
|
|
5
|
-
export async function
|
|
5
|
+
export async function getUserCourses(session) {
|
|
6
6
|
const { userid } = await getSiteInfo(session);
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
7
|
+
return callWs(session, "core_enrol_get_users_courses", { userid });
|
|
8
|
+
}
|
|
9
|
+
/**
|
|
10
|
+
* Secciones del curso con sus módulos. Con `cmid` Moodle devuelve igual todas las secciones
|
|
11
|
+
* pero solo popula el módulo pedido, así que no hay que traerse el curso entero para abrir una cosa.
|
|
12
|
+
*/
|
|
13
|
+
export function getCourseSections(session, courseId, cmid) {
|
|
14
|
+
const params = { courseid: courseId };
|
|
15
|
+
if (cmid !== undefined) {
|
|
16
|
+
params["options[0][name]"] = "cmid";
|
|
17
|
+
params["options[0][value]"] = cmid;
|
|
18
|
+
}
|
|
19
|
+
return callWs(session, "core_course_get_contents", params);
|
|
20
|
+
}
|
|
21
|
+
// --- Foros ---
|
|
22
|
+
/** Cuántas discusiones trae una página del índice de un foro. */
|
|
23
|
+
export const POR_PAGINA = 50;
|
|
24
|
+
/**
|
|
25
|
+
* Discusiones de un foro, las más recientes primero. Moodle manda el mensaje completo de la
|
|
26
|
+
* primera publicación de cada una; el que lee decide si lo recorta.
|
|
27
|
+
*/
|
|
28
|
+
export function getForumDiscussions(session, forumid, page = 0) {
|
|
29
|
+
return callWs(session, "mod_forum_get_forum_discussions", { forumid, page, perpage: POR_PAGINA });
|
|
30
|
+
}
|
|
31
|
+
/** Publicaciones de una discusión, incluidas las respuestas. */
|
|
32
|
+
export function getDiscussionPosts(session, discussionid) {
|
|
33
|
+
return callWs(session, "mod_forum_get_discussion_posts", { discussionid });
|
|
16
34
|
}
|
|
@@ -30,3 +30,56 @@ export async function callWs(session, wsfunction, params = {}) {
|
|
|
30
30
|
throw new MoodleError(data.message ?? data.exception, data.errorcode);
|
|
31
31
|
}
|
|
32
32
|
}
|
|
33
|
+
/**
|
|
34
|
+
* Baja un archivo de `webservice/pluginfile.php` con el token; lo renueva una vez si venció.
|
|
35
|
+
* Los errores llegan como JSON (`{ error, errorcode }`) en lugar del archivo.
|
|
36
|
+
*/
|
|
37
|
+
export async function fetchFile(session, url, maxBytes) {
|
|
38
|
+
let token = await session.getToken();
|
|
39
|
+
for (let attempt = 0;; attempt++) {
|
|
40
|
+
const conToken = new URL(url);
|
|
41
|
+
conToken.searchParams.set("token", token);
|
|
42
|
+
let res;
|
|
43
|
+
try {
|
|
44
|
+
res = await fetch(conToken, { signal: AbortSignal.timeout(60_000) });
|
|
45
|
+
}
|
|
46
|
+
catch (e) {
|
|
47
|
+
throw new MoodleError(`No se pudo bajar el archivo: ${e.message}`);
|
|
48
|
+
}
|
|
49
|
+
const contentType = res.headers.get("content-type");
|
|
50
|
+
if (contentType?.startsWith("application/json")) {
|
|
51
|
+
const data = (await res.json());
|
|
52
|
+
if (attempt === 0 && data.errorcode && TOKEN_ERRORS.has(data.errorcode)) {
|
|
53
|
+
token = await session.refreshToken();
|
|
54
|
+
continue;
|
|
55
|
+
}
|
|
56
|
+
throw new MoodleError(data.error ?? `HTTP ${res.status}`, data.errorcode);
|
|
57
|
+
}
|
|
58
|
+
if (!res.ok || !res.body)
|
|
59
|
+
throw new MoodleError(`HTTP ${res.status} al bajar el archivo`);
|
|
60
|
+
return { bytes: await leerConLimite(res, maxBytes), contentType };
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
/** Lee el cuerpo cortando apenas pasa el límite, sin esperar a bajarlo entero. */
|
|
64
|
+
async function leerConLimite(res, maxBytes) {
|
|
65
|
+
const limite = `El archivo pesa más de ${Math.round(maxBytes / 1024 / 1024)} MB; es demasiado para leerlo.`;
|
|
66
|
+
if (Number(res.headers.get("content-length")) > maxBytes) {
|
|
67
|
+
await res.body?.cancel();
|
|
68
|
+
throw new MoodleError(limite);
|
|
69
|
+
}
|
|
70
|
+
const partes = [];
|
|
71
|
+
let total = 0;
|
|
72
|
+
for await (const parte of res.body) {
|
|
73
|
+
total += parte.length;
|
|
74
|
+
if (total > maxBytes)
|
|
75
|
+
throw new MoodleError(limite);
|
|
76
|
+
partes.push(parte);
|
|
77
|
+
}
|
|
78
|
+
const bytes = new Uint8Array(total);
|
|
79
|
+
let i = 0;
|
|
80
|
+
for (const p of partes) {
|
|
81
|
+
bytes.set(p, i);
|
|
82
|
+
i += p.length;
|
|
83
|
+
}
|
|
84
|
+
return bytes;
|
|
85
|
+
}
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
import { abrirDocumento, FormatoNoSoportado } from "../shared/documents/index.js";
|
|
2
|
+
import { AULA_BASE_URL } from "./client/constants.js";
|
|
3
|
+
import { fetchFile } from "./client/moodle-api.js";
|
|
4
|
+
const ORIGEN = new URL(AULA_BASE_URL).origin;
|
|
5
|
+
const RUTA_WS = "/webservice/pluginfile.php/";
|
|
6
|
+
const RUTA_WEB = "/pluginfile.php/";
|
|
7
|
+
/** Cuánto se devuelve por llamada, en caracteres de texto. */
|
|
8
|
+
const PRESUPUESTO = 30_000;
|
|
9
|
+
/** Lo que cuesta una página como imagen (~1.600 tokens), medido en caracteres de texto. */
|
|
10
|
+
const COSTO_IMAGEN = 6_000;
|
|
11
|
+
/** El más grande que hay hoy en los cursos pesa 12 MB. */
|
|
12
|
+
const MAX_BYTES = 30 * 1024 * 1024;
|
|
13
|
+
/**
|
|
14
|
+
* Moodle devuelve las URLs del web service, que piden token. Al usuario le sirve la misma ruta sin
|
|
15
|
+
* `/webservice`, que se abre con la sesión de su navegador. Es la única que mostramos: el token no
|
|
16
|
+
* sale nunca del servidor.
|
|
17
|
+
*/
|
|
18
|
+
export function urlParaUsuario(fileurl) {
|
|
19
|
+
return fileurl.replace(`${ORIGEN}${RUTA_WS}`, `${ORIGEN}${RUTA_WEB}`);
|
|
20
|
+
}
|
|
21
|
+
/**
|
|
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.
|
|
24
|
+
*/
|
|
25
|
+
function urlDeDescarga(url) {
|
|
26
|
+
let u;
|
|
27
|
+
try {
|
|
28
|
+
u = new URL(url);
|
|
29
|
+
}
|
|
30
|
+
catch {
|
|
31
|
+
throw new Error(`URL inválida: "${url}". Usá la \`url\` de un archivo tal como la devuelve aula_open.`);
|
|
32
|
+
}
|
|
33
|
+
const ruta = u.pathname.startsWith(RUTA_WS) ? u.pathname : u.pathname.startsWith(RUTA_WEB) ? RUTA_WS + u.pathname.slice(RUTA_WEB.length) : null;
|
|
34
|
+
if (u.origin !== ORIGEN || !ruta) {
|
|
35
|
+
throw new Error(`Solo se pueden leer archivos del aula virtual (${ORIGEN}${RUTA_WEB}...).`);
|
|
36
|
+
}
|
|
37
|
+
u.pathname = ruta;
|
|
38
|
+
u.searchParams.delete("token");
|
|
39
|
+
u.searchParams.delete("wstoken");
|
|
40
|
+
return u;
|
|
41
|
+
}
|
|
42
|
+
/** Baja un archivo del aula y devuelve desde la parte `desde` tantas como entren en el presupuesto. */
|
|
43
|
+
export async function leerArchivo(session, url, desde = 1) {
|
|
44
|
+
const descarga = urlDeDescarga(url);
|
|
45
|
+
const archivo = decodeURIComponent(descarga.pathname.split("/").pop() ?? "");
|
|
46
|
+
const paraUsuario = urlParaUsuario(descarga.href);
|
|
47
|
+
const { bytes } = await fetchFile(session, descarga, MAX_BYTES);
|
|
48
|
+
let documento;
|
|
49
|
+
try {
|
|
50
|
+
documento = await abrirDocumento(bytes, archivo);
|
|
51
|
+
}
|
|
52
|
+
catch (e) {
|
|
53
|
+
if (e instanceof FormatoNoSoportado) {
|
|
54
|
+
throw new Error(`${e.message} El usuario lo puede bajar desde ${paraUsuario}`);
|
|
55
|
+
}
|
|
56
|
+
throw e;
|
|
57
|
+
}
|
|
58
|
+
try {
|
|
59
|
+
if (desde > documento.total) {
|
|
60
|
+
throw new Error(`"${archivo}" termina en ${documento.unidad} ${documento.total}; se pidió desde ${desde}.`);
|
|
61
|
+
}
|
|
62
|
+
const partes = [];
|
|
63
|
+
let gastado = 0;
|
|
64
|
+
let n = desde;
|
|
65
|
+
for (; n <= documento.total; n++) {
|
|
66
|
+
const parte = await documento.parte(n);
|
|
67
|
+
const costo = "texto" in parte ? parte.texto.length : COSTO_IMAGEN;
|
|
68
|
+
// Siempre al menos una parte, aunque sola se pase del presupuesto.
|
|
69
|
+
if (partes.length > 0 && gastado + costo > PRESUPUESTO)
|
|
70
|
+
break;
|
|
71
|
+
partes.push({ ...parte, numero: n });
|
|
72
|
+
gastado += costo;
|
|
73
|
+
}
|
|
74
|
+
return {
|
|
75
|
+
archivo,
|
|
76
|
+
formato: documento.formato,
|
|
77
|
+
unidad: documento.unidad,
|
|
78
|
+
total: documento.total,
|
|
79
|
+
desde,
|
|
80
|
+
hasta: n - 1,
|
|
81
|
+
siguiente: n <= documento.total ? n : undefined,
|
|
82
|
+
urlParaUsuario: paraUsuario,
|
|
83
|
+
partes,
|
|
84
|
+
};
|
|
85
|
+
}
|
|
86
|
+
finally {
|
|
87
|
+
await documento.cerrar();
|
|
88
|
+
}
|
|
89
|
+
}
|
|
@@ -0,0 +1,144 @@
|
|
|
1
|
+
import { htmlToSnippet, htmlToText } from "../shared/html.js";
|
|
2
|
+
import { cursoAddr, discusionAddr, moduloAddr, padre, parseAddress, RAIZ } from "./address.js";
|
|
3
|
+
import { getCourseSections, getDiscussionPosts, getForumDiscussions, getUserCourses, POR_PAGINA, } from "./client/client.js";
|
|
4
|
+
import { urlParaUsuario } from "./files.js";
|
|
5
|
+
const isoDate = (seconds) => (seconds ? new Date(seconds * 1000).toISOString() : null);
|
|
6
|
+
/**
|
|
7
|
+
* Entra a una dirección del aula y devuelve su contenido, el padre y las direcciones de lo que hay
|
|
8
|
+
* adentro. Es el único lugar que decide qué ve el modelo y cuánto texto le cuesta.
|
|
9
|
+
*/
|
|
10
|
+
export async function open(session, direccion, pagina = 0) {
|
|
11
|
+
const address = parseAddress(direccion);
|
|
12
|
+
const base = { direccion: formatear(address), padre: padre(address) };
|
|
13
|
+
switch (address.nivel) {
|
|
14
|
+
case "raiz":
|
|
15
|
+
return { ...base, cursos: await verCursos(session) };
|
|
16
|
+
case "curso":
|
|
17
|
+
return { ...base, secciones: await verCurso(session, address.courseId) };
|
|
18
|
+
case "modulo":
|
|
19
|
+
return { ...base, ...(await verModulo(session, address, pagina)) };
|
|
20
|
+
case "discusion":
|
|
21
|
+
return { ...base, ...(await verDiscusion(session, address.discussionId)) };
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
function formatear(address) {
|
|
25
|
+
switch (address.nivel) {
|
|
26
|
+
case "raiz":
|
|
27
|
+
return RAIZ;
|
|
28
|
+
case "curso":
|
|
29
|
+
return cursoAddr(address.courseId);
|
|
30
|
+
case "modulo":
|
|
31
|
+
return moduloAddr(address.courseId, address.cmid);
|
|
32
|
+
case "discusion":
|
|
33
|
+
return discusionAddr(address.courseId, address.cmid, address.discussionId);
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
async function verCursos(session) {
|
|
37
|
+
const courses = await getUserCourses(session);
|
|
38
|
+
return courses.map((c) => ({
|
|
39
|
+
direccion: cursoAddr(c.id),
|
|
40
|
+
id: c.id,
|
|
41
|
+
nombre: c.fullname,
|
|
42
|
+
nombreCorto: c.shortname,
|
|
43
|
+
visible: c.visible === 1,
|
|
44
|
+
progreso: c.progress ?? null,
|
|
45
|
+
ultimoAcceso: isoDate(c.lastaccess),
|
|
46
|
+
}));
|
|
47
|
+
}
|
|
48
|
+
/** El mapa del curso: secciones y módulos, con las descripciones recortadas. */
|
|
49
|
+
async function verCurso(session, courseId) {
|
|
50
|
+
const sections = await getCourseSections(session, courseId);
|
|
51
|
+
return sections
|
|
52
|
+
.filter((s) => s.uservisible !== false)
|
|
53
|
+
.map((s) => ({
|
|
54
|
+
nombre: s.name,
|
|
55
|
+
numero: s.section,
|
|
56
|
+
resumen: htmlToText(s.summary) || undefined,
|
|
57
|
+
modulos: s.modules.filter((m) => m.uservisible).map((m) => toModule(m, courseId, true)),
|
|
58
|
+
}))
|
|
59
|
+
.filter((s) => s.resumen || s.modulos.length > 0);
|
|
60
|
+
}
|
|
61
|
+
async function verModulo(session, address, pagina) {
|
|
62
|
+
const { seccion, module } = await buscarModulo(session, address.courseId, address.cmid);
|
|
63
|
+
const modulo = toModule(module, address.courseId);
|
|
64
|
+
if (module.modname !== "forum")
|
|
65
|
+
return { seccion, modulo };
|
|
66
|
+
// Un foro es un índice: asuntos y un fragmento de cada uno. El mensaje completo sale al abrir la discusión.
|
|
67
|
+
const { discussions } = await getForumDiscussions(session, module.instance, pagina);
|
|
68
|
+
return {
|
|
69
|
+
seccion,
|
|
70
|
+
modulo,
|
|
71
|
+
discusiones: discussions.map((d) => ({
|
|
72
|
+
direccion: discusionAddr(address.courseId, address.cmid, d.discussion),
|
|
73
|
+
asunto: d.subject,
|
|
74
|
+
autor: d.userfullname,
|
|
75
|
+
fecha: isoDate(d.created),
|
|
76
|
+
ultimaActividad: isoDate(d.timemodified),
|
|
77
|
+
respuestas: d.numreplies,
|
|
78
|
+
fijada: d.pinned || undefined,
|
|
79
|
+
adjuntos: d.attachment || undefined,
|
|
80
|
+
resumen: htmlToSnippet(d.message),
|
|
81
|
+
})),
|
|
82
|
+
pagina,
|
|
83
|
+
hayMasDiscusiones: discussions.length === POR_PAGINA || undefined,
|
|
84
|
+
};
|
|
85
|
+
}
|
|
86
|
+
async function verDiscusion(session, discussionId) {
|
|
87
|
+
const { posts } = await getDiscussionPosts(session, discussionId);
|
|
88
|
+
const visibles = posts.filter((p) => !p.isdeleted);
|
|
89
|
+
return {
|
|
90
|
+
asunto: visibles[0]?.subject,
|
|
91
|
+
publicaciones: visibles.map((p) => ({
|
|
92
|
+
autor: p.author.fullname,
|
|
93
|
+
fecha: isoDate(p.timecreated),
|
|
94
|
+
respuestaA: p.parentid ?? undefined,
|
|
95
|
+
id: p.id,
|
|
96
|
+
mensaje: htmlToText(p.message),
|
|
97
|
+
adjuntos: p.attachments.length ? p.attachments.map(toFile) : undefined,
|
|
98
|
+
})),
|
|
99
|
+
};
|
|
100
|
+
}
|
|
101
|
+
/** Ubica un módulo por su cmid y se queda con la sección que lo contiene. */
|
|
102
|
+
async function buscarModulo(session, courseId, cmid) {
|
|
103
|
+
const sections = await getCourseSections(session, courseId, cmid);
|
|
104
|
+
for (const section of sections) {
|
|
105
|
+
const module = section.modules.find((m) => m.id === cmid);
|
|
106
|
+
if (!module)
|
|
107
|
+
continue;
|
|
108
|
+
if (!module.uservisible)
|
|
109
|
+
throw new Error(`No tenés acceso al módulo ${cmid}.`);
|
|
110
|
+
return { seccion: section.name, module };
|
|
111
|
+
}
|
|
112
|
+
throw new Error(`No existe el módulo ${cmid} en el curso ${courseId}.`);
|
|
113
|
+
}
|
|
114
|
+
/** `resumido` recorta la descripción: en el mapa del curso alcanza para decidir si entrar. */
|
|
115
|
+
function toModule(m, courseId, resumido = false) {
|
|
116
|
+
const contents = m.contents ?? [];
|
|
117
|
+
const archivos = contents.filter((c) => c.type === "file");
|
|
118
|
+
const enlaces = contents.filter((c) => c.type === "url");
|
|
119
|
+
return {
|
|
120
|
+
direccion: moduloAddr(courseId, m.id),
|
|
121
|
+
id: m.id,
|
|
122
|
+
nombre: m.name,
|
|
123
|
+
tipo: m.modname,
|
|
124
|
+
url: m.url,
|
|
125
|
+
descripcion: (resumido ? htmlToSnippet(m.description) : htmlToText(m.description)) || undefined,
|
|
126
|
+
restriccion: htmlToText(m.availabilityinfo) || undefined,
|
|
127
|
+
fechas: m.dates?.length
|
|
128
|
+
? m.dates.map((d) => ({ etiqueta: d.label, fecha: isoDate(d.timestamp) }))
|
|
129
|
+
: undefined,
|
|
130
|
+
archivos: archivos.length ? archivos.map(toFile) : undefined,
|
|
131
|
+
enlaces: enlaces.length ? enlaces.map((l) => l.fileurl) : undefined,
|
|
132
|
+
};
|
|
133
|
+
}
|
|
134
|
+
function toFile(f) {
|
|
135
|
+
return {
|
|
136
|
+
nombre: f.filename,
|
|
137
|
+
ruta: f.filepath || undefined,
|
|
138
|
+
tamanio: f.filesize,
|
|
139
|
+
mimetype: f.mimetype,
|
|
140
|
+
modificado: isoDate(f.timemodified),
|
|
141
|
+
// Sirve para pasársela al usuario y también para leerlo con aula_read_file.
|
|
142
|
+
url: urlParaUsuario(f.fileurl),
|
|
143
|
+
};
|
|
144
|
+
}
|
|
@@ -1,6 +1,11 @@
|
|
|
1
|
-
import
|
|
1
|
+
import * as z from "zod";
|
|
2
|
+
import { getSiteInfo } from "./client/client.js";
|
|
3
|
+
import { RAIZ } from "./address.js";
|
|
4
|
+
import { leerArchivo } from "./files.js";
|
|
5
|
+
import { open } from "./navigate.js";
|
|
6
|
+
// JSON compacto: indentar estas respuestas cuesta ~25% más de contexto y no se lee mejor.
|
|
2
7
|
const json = (data) => ({
|
|
3
|
-
content: [{ type: "text", text: JSON.stringify(data
|
|
8
|
+
content: [{ type: "text", text: JSON.stringify(data) }],
|
|
4
9
|
});
|
|
5
10
|
const failure = (e) => ({
|
|
6
11
|
content: [{ type: "text", text: `Error: ${e.message}` }],
|
|
@@ -16,9 +21,86 @@ export function registerAulaVirtualTools(server, session) {
|
|
|
16
21
|
return failure(e);
|
|
17
22
|
}
|
|
18
23
|
});
|
|
19
|
-
server.registerTool("aula_list_courses", {
|
|
24
|
+
server.registerTool("aula_list_courses", {
|
|
25
|
+
description: "Lista los cursos (materias) en los que está inscripto el usuario. Es el punto de entrada: " +
|
|
26
|
+
"cada curso viene con su `direccion`, que se le pasa a aula_open para entrar.",
|
|
27
|
+
}, async () => {
|
|
20
28
|
try {
|
|
21
|
-
return json(await
|
|
29
|
+
return json(await open(session, RAIZ));
|
|
30
|
+
}
|
|
31
|
+
catch (e) {
|
|
32
|
+
return failure(e);
|
|
33
|
+
}
|
|
34
|
+
});
|
|
35
|
+
server.registerTool("aula_open", {
|
|
36
|
+
description: "Entra a una dirección del aula virtual y devuelve lo que hay ahí: un curso con sus secciones " +
|
|
37
|
+
"y módulos, un módulo con sus archivos y fechas, un foro con el índice de sus discusiones, o " +
|
|
38
|
+
"una discusión con sus mensajes completos.\n\n" +
|
|
39
|
+
"No hace falta armar la dirección: cada respuesta trae las direcciones de lo que hay adentro " +
|
|
40
|
+
"(`direccion`) y la de un nivel más arriba (`padre`), así que se navega pasando de vuelta una " +
|
|
41
|
+
"de esas. Para empezar, usar aula_list_courses o la dirección \"/\".\n\n" +
|
|
42
|
+
"Buscar algo que puede estar en cualquier parte (una fecha de parcial, un aviso) se hace " +
|
|
43
|
+
"entrando al curso y abriendo lo que corresponda: el índice de un foro muestra los asuntos sin " +
|
|
44
|
+
"volcar los mensajes enteros, y archivos como un cronograma se leen con aula_read_file.\n\n" +
|
|
45
|
+
"Los archivos vienen con una `url`, que sirve para leerlos con aula_read_file y también para " +
|
|
46
|
+
"dársela al usuario si quiere bajarlo: se abre con su sesión del aula.",
|
|
47
|
+
inputSchema: z.object({
|
|
48
|
+
direccion: z
|
|
49
|
+
.string()
|
|
50
|
+
.describe('Dirección a abrir, sacada de una respuesta anterior. Ej: "/", "/curso/691", ' +
|
|
51
|
+
'"/curso/691/mod/34806", "/curso/691/mod/34806/disc/421348".'),
|
|
52
|
+
pagina: z
|
|
53
|
+
.number()
|
|
54
|
+
.int()
|
|
55
|
+
.min(0)
|
|
56
|
+
.optional()
|
|
57
|
+
.describe("Solo para foros: página del índice de discusiones, de 50 en 50. La respuesta avisa " +
|
|
58
|
+
"con `hayMasDiscusiones` cuando hay otra."),
|
|
59
|
+
}),
|
|
60
|
+
}, async ({ direccion, pagina }) => {
|
|
61
|
+
try {
|
|
62
|
+
return json(await open(session, direccion, pagina));
|
|
63
|
+
}
|
|
64
|
+
catch (e) {
|
|
65
|
+
return failure(e);
|
|
66
|
+
}
|
|
67
|
+
});
|
|
68
|
+
server.registerTool("aula_read_file", {
|
|
69
|
+
description: "Lee un archivo del aula virtual: PDF, Word (docx), PowerPoint (pptx), Excel (xlsx), imágenes y " +
|
|
70
|
+
"texto. Los PDF escaneados se devuelven como imagen de cada página.\n\n" +
|
|
71
|
+
"Devuelve el archivo por partes (páginas, diapositivas o fragmentos), tantas como entren en una " +
|
|
72
|
+
"respuesta. Si quedan más, `siguiente` dice desde cuál pedir; también se puede saltar directo " +
|
|
73
|
+
"a una parte con `desde`.\n\n" +
|
|
74
|
+
"Formatos que no sabe leer (.doc, .ppt viejos, .zip, .rar, videos): devuelve un error con la URL " +
|
|
75
|
+
"para que el usuario lo baje.",
|
|
76
|
+
inputSchema: z.object({
|
|
77
|
+
url: z.string().describe("La `url` del archivo tal como la devuelve aula_open."),
|
|
78
|
+
desde: z
|
|
79
|
+
.number()
|
|
80
|
+
.int()
|
|
81
|
+
.min(1)
|
|
82
|
+
.optional()
|
|
83
|
+
.describe("Número de página, diapositiva o fragmento desde el cual leer. Por defecto, 1."),
|
|
84
|
+
}),
|
|
85
|
+
}, async ({ url, desde }) => {
|
|
86
|
+
try {
|
|
87
|
+
const { partes, ...lectura } = await leerArchivo(session, url, desde);
|
|
88
|
+
const etiqueta = (n, titulo) => `--- ${lectura.unidad} ${n} de ${lectura.total}${titulo ? ` (${titulo})` : ""} ---`;
|
|
89
|
+
return {
|
|
90
|
+
content: [
|
|
91
|
+
{ type: "text", text: JSON.stringify(lectura) },
|
|
92
|
+
...partes.flatMap((p) => "texto" in p
|
|
93
|
+
? [{ type: "text", text: `${etiqueta(p.numero, p.titulo)}\n${p.texto}` }]
|
|
94
|
+
: [
|
|
95
|
+
{ type: "text", text: etiqueta(p.numero, p.titulo) },
|
|
96
|
+
{
|
|
97
|
+
type: "image",
|
|
98
|
+
data: Buffer.from(p.imagen).toString("base64"),
|
|
99
|
+
mimeType: p.mimeType,
|
|
100
|
+
},
|
|
101
|
+
]),
|
|
102
|
+
],
|
|
103
|
+
};
|
|
22
104
|
}
|
|
23
105
|
catch (e) {
|
|
24
106
|
return failure(e);
|