utn-mcp 0.8.2 → 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 +3 -1
- package/build/aula-virtual/client/client.js +21 -0
- package/build/aula-virtual/client/moodle-api.js +28 -4
- package/build/aula-virtual/format/address.js +11 -0
- package/build/aula-virtual/format/files.js +2 -2
- package/build/aula-virtual/format/study-pack.js +199 -0
- package/build/aula-virtual/tools.js +40 -0
- package/build/oauth/admin.js +51 -10
- package/build/server.js +1 -1
- package/build/shared/documents/merge.js +197 -0
- package/build/shared/paths.js +4 -0
- package/package.json +2 -1
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. |
|
|
@@ -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;
|
|
@@ -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
|
-
|
|
41
|
-
|
|
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 (
|
|
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,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
|
+
}
|
|
@@ -3,6 +3,7 @@ import { getSiteInfo } from "./client/client.js";
|
|
|
3
3
|
import { addCalendarEvent, answerChoice, deleteForumPost, editForumPost, postToForum, sendMessage, } from "./format/actions.js";
|
|
4
4
|
import { ROOT } from "./format/address.js";
|
|
5
5
|
import { readAulaFile } from "./format/files.js";
|
|
6
|
+
import { buildStudyPack } from "./format/study-pack.js";
|
|
6
7
|
import { open } from "./format/navigate.js";
|
|
7
8
|
import { createUploadLink, submitAssignment } from "./format/submission.js";
|
|
8
9
|
// JSON compacto: indentar estas respuestas cuesta ~25% más de contexto y no se lee mejor.
|
|
@@ -125,6 +126,45 @@ export function registerAulaVirtualTools(server, session, files) {
|
|
|
125
126
|
return failure(e);
|
|
126
127
|
}
|
|
127
128
|
});
|
|
129
|
+
// El pack se guarda en el disco: solo en STDIO, donde el servidor corre en la compu del usuario.
|
|
130
|
+
if (files.local) {
|
|
131
|
+
server.registerTool("aula_study_pack", {
|
|
132
|
+
description: "Une PDF del aula en un solo compilado de estudio con índice clickeable (cada " +
|
|
133
|
+
"entrada abre la página donde empieza ese material, y cada página tiene arriba " +
|
|
134
|
+
"un enlace para volver), guardado en la compu del usuario. Une ÚNICAMENTE PDF; " +
|
|
135
|
+
"si una URL pedida no es PDF, queda en `omitidos` con el motivo.\n\n" +
|
|
136
|
+
"Conviene llamar antes a aula_open del curso: devuelve cada módulo con sus " +
|
|
137
|
+
"archivos (nombre, url, sección y tipo) y permite elegir los apuntes sin bajar " +
|
|
138
|
+
"nada. Es un compilado íntegro, no un resumen.\n\n" +
|
|
139
|
+
"Hay topes por memoria: 150 archivos, 300 MB de descarga total y 30 MB el PDF " +
|
|
140
|
+
"final (estimado con los tamaños de entrada, para no bajar archivos de más). Lo " +
|
|
141
|
+
"que no entra queda en `omitidos` con el motivo: se pide el resto en otro pack. " +
|
|
142
|
+
"La respuesta trae la ruta en `archivo` y `materiales` con sección, módulo, " +
|
|
143
|
+
"páginas y página de inicio de cada uno.\n\n" +
|
|
144
|
+
"La `direccion` del curso sale de aula_list_courses. Para leer o pasarle al " +
|
|
145
|
+
"usuario archivos que no son PDF, usá aula_open y aula_read_file.",
|
|
146
|
+
inputSchema: z.object({
|
|
147
|
+
direccion: z
|
|
148
|
+
.string()
|
|
149
|
+
.describe('Dirección del curso, de aula_list_courses. Ej: "/curso/691".'),
|
|
150
|
+
archivos: z
|
|
151
|
+
.array(z.string())
|
|
152
|
+
.optional()
|
|
153
|
+
.describe("URL de los PDF a unir, tal como las devuelve aula_open del curso (conviene " +
|
|
154
|
+
"llamarlo antes y elegir: cada archivo trae nombre, sección y tipo). El pack " +
|
|
155
|
+
"sigue el orden del aula, no el de esta lista. Sin `archivos`, une todos los " +
|
|
156
|
+
"PDF del curso."),
|
|
157
|
+
}),
|
|
158
|
+
annotations: { readOnlyHint: false, destructiveHint: false, openWorldHint: false },
|
|
159
|
+
}, async ({ direccion, archivos }) => {
|
|
160
|
+
try {
|
|
161
|
+
return json(await buildStudyPack(session, direccion, archivos));
|
|
162
|
+
}
|
|
163
|
+
catch (e) {
|
|
164
|
+
return failure(e);
|
|
165
|
+
}
|
|
166
|
+
});
|
|
167
|
+
}
|
|
128
168
|
// --- Escritura: lo que sigue cambia algo en el aula, y casi todo queda a la vista de otras personas. ---
|
|
129
169
|
const CONFIRM_FIRST = "\n\nAntes de llamarla, mostrale al usuario exactamente lo que se va a hacer y esperá que confirme.";
|
|
130
170
|
server.registerTool("aula_forum_post", {
|
package/build/oauth/admin.js
CHANGED
|
@@ -2,13 +2,16 @@ import { timingSafeEqual } from "node:crypto";
|
|
|
2
2
|
import express from "express";
|
|
3
3
|
import { linkAulaAccount } from "../aula-virtual/client/link.js";
|
|
4
4
|
import { availableRegionals } from "../aula-virtual/client/regionals.js";
|
|
5
|
-
import { InvalidCredentialsError, MfaError
|
|
5
|
+
import { InvalidCredentialsError, MfaError } from "../aula-virtual/client/sso-login.js";
|
|
6
6
|
import { AULA_PLATFORM } from "../aula-virtual/client/stored-session.js";
|
|
7
7
|
import { logger } from "../shared/logger.js";
|
|
8
|
-
import { RateLimiter } from "./expiring.js";
|
|
8
|
+
import { Expiring, RateLimiter } from "./expiring.js";
|
|
9
|
+
import { PendingLogin } from "./pending-login.js";
|
|
9
10
|
/** Intentos de `/admin/verificar` por cuenta del aula cada 15 minutos: que no sirva para probar contraseñas. */
|
|
10
11
|
const VERIFY_ATTEMPTS = 5;
|
|
11
12
|
const VERIFY_WINDOW_MS = 15 * 60_000;
|
|
13
|
+
/** Cuánto tiene el usuario para completar el segundo factor, en milisegundos: lo mismo que en el login de OAuth. */
|
|
14
|
+
const SECOND_FACTOR_TTL_MS = 10 * 60_000;
|
|
12
15
|
const SUPPORT = { verified: "verificada", candidate: "candidata", unsupported: "sin_soporte" };
|
|
13
16
|
/** Segundos desde epoch, como los guarda el store, a ISO. */
|
|
14
17
|
const isoDate = (seconds) => new Date(seconds * 1000).toISOString();
|
|
@@ -20,6 +23,7 @@ const isoDate = (seconds) => new Date(seconds * 1000).toISOString();
|
|
|
20
23
|
export function mountAdmin(app, store, secret) {
|
|
21
24
|
const json = express.json({ limit: "4kb" });
|
|
22
25
|
const verifyLimiter = new RateLimiter(VERIFY_ATTEMPTS, VERIFY_WINDOW_MS);
|
|
26
|
+
const secondFactors = new Expiring(SECOND_FACTOR_TTL_MS);
|
|
23
27
|
const requireSecret = (req, res, next) => {
|
|
24
28
|
const got = Buffer.from(req.headers.authorization ?? "");
|
|
25
29
|
const want = Buffer.from(`Bearer ${secret}`);
|
|
@@ -79,6 +83,7 @@ export function mountAdmin(app, store, secret) {
|
|
|
79
83
|
* `{ regional, usuario, contrasena }` → entra al aula como el login de OAuth: crea el usuario si no
|
|
80
84
|
* existe y le guarda la sesión, así ya puede usar las tools. Devuelve `{ usuario, nombre, email,
|
|
81
85
|
* primero? }`, con `primero` si fue el primero en verificar la regional; si no entra, `{ error, motivo }`.
|
|
86
|
+
* Si Microsoft pide MFA, devuelve `{ segundoFactor }` y el login sigue con `/admin/segundo-factor`.
|
|
82
87
|
*/
|
|
83
88
|
app.post("/admin/verificar", requireSecret, json, async (req, res) => {
|
|
84
89
|
const id = typeof req.body?.regional === "string" ? req.body.regional : "";
|
|
@@ -94,8 +99,33 @@ export function mountAdmin(app, store, secret) {
|
|
|
94
99
|
if (!verifyLimiter.allow(`${regional.id}:${username.toLowerCase()}`)) {
|
|
95
100
|
return void res.status(429).json({ error: "intentos", motivo: "Demasiados intentos. Esperá unos minutos." });
|
|
96
101
|
}
|
|
102
|
+
const flow = new PendingLogin((secondFactor) => linkAulaAccount(store, regional, { username, password }, secondFactor));
|
|
103
|
+
await advance(res, { flow, regional, username });
|
|
104
|
+
});
|
|
105
|
+
/**
|
|
106
|
+
* `{ login, respuesta }` → contesta lo que pidió el segundo factor: el `id` del método, el código o
|
|
107
|
+
* cualquier cosa cuando el usuario ya aprobó. Devuelve lo mismo que `/admin/verificar`.
|
|
108
|
+
*/
|
|
109
|
+
app.post("/admin/segundo-factor", requireSecret, json, async (req, res) => {
|
|
110
|
+
const id = typeof req.body?.login === "string" ? req.body.login : "";
|
|
111
|
+
const verification = id ? secondFactors.get(id) : undefined;
|
|
112
|
+
if (!verification) {
|
|
113
|
+
return void res.status(410).json({ error: "vencido", motivo: "Pasó demasiado tiempo. Volvé a iniciar sesión." });
|
|
114
|
+
}
|
|
115
|
+
verification.flow.answer(typeof req.body?.respuesta === "string" ? req.body.respuesta : "");
|
|
116
|
+
await advance(res, verification, id);
|
|
117
|
+
});
|
|
118
|
+
/** Sigue el login hasta que termina o pide el segundo factor; `id` es el de uno que ya estaba esperando. */
|
|
119
|
+
async function advance(res, verification, id) {
|
|
120
|
+
const { flow, regional, username } = verification;
|
|
97
121
|
try {
|
|
98
|
-
const
|
|
122
|
+
const step = await flow.next();
|
|
123
|
+
if ("prompt" in step) {
|
|
124
|
+
return void res.json({ segundoFactor: secondFactorJson(id ?? secondFactors.add(verification), step.prompt) });
|
|
125
|
+
}
|
|
126
|
+
if (id)
|
|
127
|
+
secondFactors.take(id);
|
|
128
|
+
const account = step.done;
|
|
99
129
|
logger.info(`Admin: ${account.username} (${regional.id}) vinculó su aula.`);
|
|
100
130
|
res.json({
|
|
101
131
|
usuario: account.username,
|
|
@@ -105,17 +135,28 @@ export function mountAdmin(app, store, secret) {
|
|
|
105
135
|
});
|
|
106
136
|
}
|
|
107
137
|
catch (e) {
|
|
138
|
+
if (id)
|
|
139
|
+
secondFactors.take(id);
|
|
108
140
|
if (e instanceof InvalidCredentialsError) {
|
|
109
141
|
return void res.status(401).json({ error: "credenciales", motivo: "El aula rechazó el usuario o la contraseña." });
|
|
110
142
|
}
|
|
111
|
-
if (e instanceof
|
|
112
|
-
return void res.status(422).json({
|
|
113
|
-
error: "mfa",
|
|
114
|
-
motivo: "La cuenta de Microsoft pide un segundo factor (MFA): que entre por la página de login.",
|
|
115
|
-
});
|
|
116
|
-
}
|
|
143
|
+
if (e instanceof MfaError)
|
|
144
|
+
return void res.status(422).json({ error: "mfa", motivo: e.message });
|
|
117
145
|
logger.warn(`Verificación de ${username} (${regional.id}): ${e.message}`);
|
|
118
146
|
res.status(502).json({ error: "aula", motivo: `No pudimos entrar al aula de ${regional.name}.` });
|
|
119
147
|
}
|
|
120
|
-
}
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
/**
|
|
151
|
+
* Lo que pide el segundo factor, para que el panel se lo muestre al usuario. `login` es el id con el que
|
|
152
|
+
* se contesta; `paso` es "elegir" (con `metodos`), "codigo" (con `reintento` si el anterior estaba mal)
|
|
153
|
+
* o "aprobar" (con `numero` si Authenticator pide tipearlo).
|
|
154
|
+
*/
|
|
155
|
+
function secondFactorJson(login, prompt) {
|
|
156
|
+
if (prompt.step === "choose") {
|
|
157
|
+
return { login, paso: "elegir", metodos: prompt.methods.map((m) => ({ id: m.id, nombre: m.label })) };
|
|
158
|
+
}
|
|
159
|
+
if (prompt.step === "code")
|
|
160
|
+
return { login, paso: "codigo", metodo: prompt.method.label, reintento: prompt.retry || undefined };
|
|
161
|
+
return { login, paso: "aprobar", metodo: prompt.method.label, numero: prompt.number };
|
|
121
162
|
}
|
package/build/server.js
CHANGED
|
@@ -7,7 +7,7 @@ import { registerAulaVirtualTools } from "./aula-virtual/tools.js";
|
|
|
7
7
|
export function createServer(aula, files) {
|
|
8
8
|
const server = new McpServer({
|
|
9
9
|
name: "UTN-MCP",
|
|
10
|
-
version: "0.8.
|
|
10
|
+
version: "0.8.3",
|
|
11
11
|
});
|
|
12
12
|
registerAulaVirtualTools(server, aula, files);
|
|
13
13
|
return server;
|
|
@@ -0,0 +1,197 @@
|
|
|
1
|
+
import { PDFArray, PDFDocument, PDFName, StandardFonts, degrees, rgb } from "pdf-lib";
|
|
2
|
+
/** Filas del índice que entran en una página A4 con letra de 11pt. */
|
|
3
|
+
const INDEX_ROWS_PER_PAGE = 40;
|
|
4
|
+
/**
|
|
5
|
+
* Alto de la franja que se AGREGA arriba de cada página de contenido para el enlace de
|
|
6
|
+
* vuelta al índice: la página queda un poco más alta, así el enlace nunca tapa el material.
|
|
7
|
+
*/
|
|
8
|
+
const BACK_STRIP = 18;
|
|
9
|
+
const BACK_LABEL = "Volver al índice";
|
|
10
|
+
/**
|
|
11
|
+
* Agrega a `page` un enlace rectangular (anotación Link sin borde) que muestra `dest` entera.
|
|
12
|
+
*/
|
|
13
|
+
function addPageLink(out, page, rect, dest) {
|
|
14
|
+
const link = out.context.register(out.context.obj({
|
|
15
|
+
Type: "Annot",
|
|
16
|
+
Subtype: "Link",
|
|
17
|
+
Rect: rect,
|
|
18
|
+
Border: [0, 0, 0],
|
|
19
|
+
Dest: [dest.ref, "Fit"],
|
|
20
|
+
}));
|
|
21
|
+
let annots = page.node.lookupMaybe(PDFName.of("Annots"), PDFArray);
|
|
22
|
+
if (!annots) {
|
|
23
|
+
annots = out.context.obj([]);
|
|
24
|
+
page.node.set(PDFName.of("Annots"), annots);
|
|
25
|
+
}
|
|
26
|
+
annots.push(link);
|
|
27
|
+
}
|
|
28
|
+
/**
|
|
29
|
+
* Une PDFs en uno solo, con un índice clickeable al principio: cada entrada abre la página
|
|
30
|
+
* donde empieza ese material, y cada página de contenido tiene arriba un enlace "Volver al
|
|
31
|
+
* índice" (en una franja agregada, sin tapar nada). El que no se puede abrir (roto o con
|
|
32
|
+
* clave) se salta y queda en `skipped`: pdf-lib no desencripta, así que un PDF con clave
|
|
33
|
+
* entraría con páginas rotas. El índice se arma al final, cuando se sabe qué archivos
|
|
34
|
+
* entraron, y se inserta al frente.
|
|
35
|
+
*/
|
|
36
|
+
export async function mergePdfs(files, documentTitle) {
|
|
37
|
+
const out = await PDFDocument.create();
|
|
38
|
+
if (documentTitle)
|
|
39
|
+
out.setTitle(documentTitle);
|
|
40
|
+
const merged = [];
|
|
41
|
+
/** En qué página empieza cada uno sin contar el índice, que todavía no se sabe cuánto ocupa. */
|
|
42
|
+
const contentStarts = [];
|
|
43
|
+
const skipped = [];
|
|
44
|
+
for (let index = 0; index < files.length; index++) {
|
|
45
|
+
const file = files[index];
|
|
46
|
+
let src;
|
|
47
|
+
try {
|
|
48
|
+
// ignoreEncryption solo para que `load` no rechace el archivo: así lo listamos
|
|
49
|
+
// en `skipped` con el motivo en vez de romper todo el pack.
|
|
50
|
+
src = await PDFDocument.load(file.bytes, { ignoreEncryption: true });
|
|
51
|
+
}
|
|
52
|
+
catch {
|
|
53
|
+
skipped.push({ index, title: file.title, reason: "no se pudo abrir (está roto)" });
|
|
54
|
+
continue;
|
|
55
|
+
}
|
|
56
|
+
if (src.isEncrypted) {
|
|
57
|
+
skipped.push({ index, title: file.title, reason: "tiene clave" });
|
|
58
|
+
continue;
|
|
59
|
+
}
|
|
60
|
+
let pages;
|
|
61
|
+
try {
|
|
62
|
+
pages = await out.copyPages(src, src.getPageIndices());
|
|
63
|
+
}
|
|
64
|
+
catch {
|
|
65
|
+
// Abre bien pero tiene objetos que no se pueden copiar: se salta como los rotos.
|
|
66
|
+
skipped.push({ index, title: file.title, reason: "no se pudieron copiar sus páginas" });
|
|
67
|
+
continue;
|
|
68
|
+
}
|
|
69
|
+
if (!pages.length) {
|
|
70
|
+
skipped.push({ index, title: file.title, reason: "no tiene páginas" });
|
|
71
|
+
continue;
|
|
72
|
+
}
|
|
73
|
+
contentStarts.push(out.getPageCount() + 1);
|
|
74
|
+
for (const page of pages)
|
|
75
|
+
out.addPage(page);
|
|
76
|
+
merged.push({ index, title: file.title, pages: pages.length });
|
|
77
|
+
}
|
|
78
|
+
if (!merged.length)
|
|
79
|
+
throw new Error("No se pudo unir ninguno de los PDF.");
|
|
80
|
+
const indexPages = Math.ceil(merged.length / INDEX_ROWS_PER_PAGE);
|
|
81
|
+
const withStart = merged.map((m, i) => ({ ...m, startPage: contentStarts[i] + indexPages }));
|
|
82
|
+
const font = await out.embedFont(StandardFonts.Helvetica);
|
|
83
|
+
await writeIndex(out, withStart, indexPages, font);
|
|
84
|
+
addBackLinks(out, indexPages, font);
|
|
85
|
+
const bytes = await out.save();
|
|
86
|
+
return { bytes, pages: out.getPageCount(), merged: withStart, skipped };
|
|
87
|
+
}
|
|
88
|
+
/** Escribe las páginas del índice y las inserta al frente, con cada renglón enlazado a su material. */
|
|
89
|
+
async function writeIndex(out, merged, indexPages, font) {
|
|
90
|
+
const bold = await out.embedFont(StandardFonts.HelveticaBold);
|
|
91
|
+
const pageSize = [595, 842]; // A4
|
|
92
|
+
const margin = 50;
|
|
93
|
+
const lineHeight = 16;
|
|
94
|
+
const pages = [];
|
|
95
|
+
for (let n = 0; n < indexPages; n++) {
|
|
96
|
+
const page = out.insertPage(n, pageSize);
|
|
97
|
+
page.drawText(winAnsi("Índice"), { x: margin, y: pageSize[1] - margin, size: 16, font: bold });
|
|
98
|
+
pages.push(page);
|
|
99
|
+
}
|
|
100
|
+
merged.forEach((row, i) => {
|
|
101
|
+
const page = pages[Math.floor(i / INDEX_ROWS_PER_PAGE)];
|
|
102
|
+
const y = pageSize[1] - margin - lineHeight * (2 + (i % INDEX_ROWS_PER_PAGE));
|
|
103
|
+
const pageNo = String(row.startPage);
|
|
104
|
+
page.drawText(pageNo, { x: margin + 30 - font.widthOfTextAtSize(pageNo, 11), y, size: 11, font });
|
|
105
|
+
page.drawText(truncate(winAnsi(row.title), font, 11, pageSize[0] - margin * 2 - 40), {
|
|
106
|
+
x: margin + 40,
|
|
107
|
+
y,
|
|
108
|
+
size: 11,
|
|
109
|
+
font,
|
|
110
|
+
});
|
|
111
|
+
addPageLink(out, page, [margin, y - 4, pageSize[0] - margin, y + 11], out.getPage(row.startPage - 1));
|
|
112
|
+
});
|
|
113
|
+
}
|
|
114
|
+
/**
|
|
115
|
+
* Agrega a cada página de contenido una franja vacía con "Volver al índice" enlazado a la
|
|
116
|
+
* primera página del índice. La franja AGRANDA la página por el borde que queda arriba al
|
|
117
|
+
* leerla (arriba sin rotación; a la izquierda, abajo o a la derecha según la rotación de la
|
|
118
|
+
* página) y el texto se dibuja ya rotado: se lee horizontal en cualquier orientación y, al
|
|
119
|
+
* ser espacio agregado, nunca tapa el material. Toda la franja es clickeable.
|
|
120
|
+
*/
|
|
121
|
+
function addBackLinks(out, indexPages, font) {
|
|
122
|
+
const firstIndexPage = out.getPage(0);
|
|
123
|
+
const size = 9;
|
|
124
|
+
const color = rgb(0.1, 0.3, 0.7);
|
|
125
|
+
const textWidth = font.widthOfTextAtSize(BACK_LABEL, size);
|
|
126
|
+
const total = out.getPageCount();
|
|
127
|
+
for (let i = indexPages; i < total; i++) {
|
|
128
|
+
const page = out.getPage(i);
|
|
129
|
+
// Los dos box se leen ANTES de escribir: sin CropBox propio, getCropBox devuelve el
|
|
130
|
+
// MediaBox, y leerlo después de agrandarlo agrandaría el CropBox dos veces.
|
|
131
|
+
const media = page.getMediaBox();
|
|
132
|
+
const crop = page.getCropBox();
|
|
133
|
+
const { x: X, y: Y, width: w, height: h } = media;
|
|
134
|
+
const rotation = (((page.getRotation().angle % 360) + 360) % 360);
|
|
135
|
+
// Posición del texto y dirección del subrayado según el borde por donde crece la página.
|
|
136
|
+
let textX, textY, lineStart, lineEnd;
|
|
137
|
+
let strip;
|
|
138
|
+
let rotate;
|
|
139
|
+
switch (rotation) {
|
|
140
|
+
case 90:
|
|
141
|
+
// Al leerla rotada 90° el borde izquierdo queda arriba: la franja crece a la izquierda.
|
|
142
|
+
page.setMediaBox(X - BACK_STRIP, Y, w + BACK_STRIP, h);
|
|
143
|
+
page.setCropBox(crop.x - BACK_STRIP, crop.y, crop.width + BACK_STRIP, crop.height);
|
|
144
|
+
strip = [X - BACK_STRIP, Y, X, Y + h];
|
|
145
|
+
rotate = degrees(90);
|
|
146
|
+
textX = X - BACK_STRIP + 5 + size;
|
|
147
|
+
textY = Y + h - 30 - textWidth;
|
|
148
|
+
lineStart = { x: textX + 1.5, y: textY };
|
|
149
|
+
lineEnd = { x: textX + 1.5, y: textY + textWidth };
|
|
150
|
+
break;
|
|
151
|
+
case 180:
|
|
152
|
+
page.setMediaBox(X, Y - BACK_STRIP, w, h + BACK_STRIP);
|
|
153
|
+
page.setCropBox(crop.x, crop.y - BACK_STRIP, crop.width, crop.height + BACK_STRIP);
|
|
154
|
+
strip = [X, Y - BACK_STRIP, X + w, Y];
|
|
155
|
+
rotate = degrees(180);
|
|
156
|
+
textX = X + 30 + textWidth;
|
|
157
|
+
textY = Y - BACK_STRIP + 5 + size;
|
|
158
|
+
lineStart = { x: textX, y: textY + 1.5 };
|
|
159
|
+
lineEnd = { x: textX - textWidth, y: textY + 1.5 };
|
|
160
|
+
break;
|
|
161
|
+
case 270:
|
|
162
|
+
page.setMediaBox(X, Y, w + BACK_STRIP, h);
|
|
163
|
+
page.setCropBox(crop.x, crop.y, crop.width + BACK_STRIP, crop.height);
|
|
164
|
+
strip = [X + w, Y, X + w + BACK_STRIP, Y + h];
|
|
165
|
+
rotate = degrees(270);
|
|
166
|
+
textX = X + w + BACK_STRIP - 5 - size;
|
|
167
|
+
textY = Y + 30 + textWidth;
|
|
168
|
+
lineStart = { x: textX - 1.5, y: textY };
|
|
169
|
+
lineEnd = { x: textX - 1.5, y: textY - textWidth };
|
|
170
|
+
break;
|
|
171
|
+
default:
|
|
172
|
+
page.setMediaBox(X, Y, w, h + BACK_STRIP);
|
|
173
|
+
page.setCropBox(crop.x, crop.y, crop.width, crop.height + BACK_STRIP);
|
|
174
|
+
strip = [X, Y + h, X + w, Y + h + BACK_STRIP];
|
|
175
|
+
rotate = degrees(0);
|
|
176
|
+
textX = X + w - 30 - textWidth;
|
|
177
|
+
textY = Y + h + 5;
|
|
178
|
+
lineStart = { x: textX, y: textY - 1.5 };
|
|
179
|
+
lineEnd = { x: textX + textWidth, y: textY - 1.5 };
|
|
180
|
+
break;
|
|
181
|
+
}
|
|
182
|
+
page.drawText(BACK_LABEL, { x: textX, y: textY, size, font, color, rotate });
|
|
183
|
+
page.drawLine({ start: lineStart, end: lineEnd, thickness: 0.5, color });
|
|
184
|
+
addPageLink(out, page, strip, firstIndexPage);
|
|
185
|
+
}
|
|
186
|
+
}
|
|
187
|
+
/** Lo que la letra base del PDF sabe dibujar (WinAnsi); lo demás se cambia por "?". */
|
|
188
|
+
const winAnsi = (text) => text.replace(/[^\x20-\x7E\xA0-\xFF\u2013\u2014\u2018\u2019\u201C\u201D\u2022\u2026]/g, "?");
|
|
189
|
+
/** Recorta el título para que entre en el renglón, con "…" si se cortó. */
|
|
190
|
+
function truncate(text, font, size, maxWidth) {
|
|
191
|
+
if (font.widthOfTextAtSize(text, size) <= maxWidth)
|
|
192
|
+
return text;
|
|
193
|
+
let cut = text;
|
|
194
|
+
while (cut && font.widthOfTextAtSize(cut + "…", size) > maxWidth)
|
|
195
|
+
cut = cut.slice(0, -1);
|
|
196
|
+
return cut + "…";
|
|
197
|
+
}
|
package/build/shared/paths.js
CHANGED
|
@@ -15,3 +15,7 @@ export const sessionFile = () => join(configDir(), "session.json");
|
|
|
15
15
|
export const storeFile = () => process.env.UTN_MCP_DB ?? join(configDir(), "utn-mcp.db");
|
|
16
16
|
/** Clave con la que se cifran las sesiones en la base, si no viene en UTN_MCP_KEY. */
|
|
17
17
|
export const storeKeyFile = () => join(configDir(), "store.key");
|
|
18
|
+
/** Dónde quedan los archivos que arma el servidor (packs de estudio): UTN_MCP_EXPORTS o exports/ en la configuración. */
|
|
19
|
+
export function exportsDir() {
|
|
20
|
+
return process.env.UTN_MCP_EXPORTS ?? join(configDir(), "exports");
|
|
21
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "utn-mcp",
|
|
3
|
-
"version": "0.8.
|
|
3
|
+
"version": "0.8.3",
|
|
4
4
|
"description": "Este es el Model Context Protocol server NO OFICIAL de la UTN para buscar informacion o archivos en el aula virtual.",
|
|
5
5
|
"main": "build/index.js",
|
|
6
6
|
"bin": {
|
|
@@ -42,6 +42,7 @@
|
|
|
42
42
|
"@modelcontextprotocol/server": "^2.3.0",
|
|
43
43
|
"@napi-rs/keyring": "^2.1.0",
|
|
44
44
|
"express": "^5.2.1",
|
|
45
|
+
"pdf-lib": "^1.17.1",
|
|
45
46
|
"tough-cookie": "^6.0.2",
|
|
46
47
|
"zod": "^4.6.5"
|
|
47
48
|
},
|