utn-mcp 0.8.3 → 0.9.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 +30 -3
- package/build/aula-virtual/client/client.d.ts +97 -0
- package/build/aula-virtual/client/credentials.d.ts +10 -0
- package/build/aula-virtual/client/link.d.ts +21 -0
- package/build/aula-virtual/client/local-session.d.ts +23 -0
- package/build/aula-virtual/client/moodle-api.d.ts +25 -0
- package/build/aula-virtual/client/regionals.d.ts +47 -0
- package/build/aula-virtual/client/session.d.ts +12 -0
- package/build/aula-virtual/client/sso-login.d.ts +60 -0
- package/build/aula-virtual/client/stored-session.d.ts +32 -0
- package/build/aula-virtual/client/types.d.ts +173 -0
- package/build/aula-virtual/format/actions.d.ts +57 -0
- package/build/aula-virtual/format/activities.d.ts +86 -0
- package/build/aula-virtual/format/address.d.ts +36 -0
- package/build/aula-virtual/format/fields.d.ts +11 -0
- package/build/aula-virtual/format/files.d.ts +32 -0
- package/build/aula-virtual/format/navigate.d.ts +213 -0
- package/build/aula-virtual/format/study-pack.d.ts +33 -0
- package/build/aula-virtual/format/submission.d.ts +40 -0
- package/build/aula-virtual/login.d.ts +6 -0
- package/build/aula-virtual/tools.d.ts +4 -0
- package/build/autogestion/client/catalog.d.ts +20 -0
- package/build/autogestion/client/catalog.js +22 -0
- package/build/autogestion/client/connector.d.ts +71 -0
- package/build/autogestion/client/connector.js +31 -0
- package/build/autogestion/client/credentials.d.ts +5 -0
- package/build/autogestion/client/credentials.js +40 -0
- package/build/autogestion/client/install.d.ts +19 -0
- package/build/autogestion/client/install.js +91 -0
- package/build/autogestion/client/installed-systems.d.ts +7 -0
- package/build/autogestion/client/installed-systems.js +58 -0
- package/build/autogestion/client/local-session.d.ts +17 -0
- package/build/autogestion/client/local-session.js +37 -0
- package/build/autogestion/client/session.d.ts +18 -0
- package/build/autogestion/client/session.js +1 -0
- package/build/autogestion/client/stored-session.d.ts +42 -0
- package/build/autogestion/client/stored-session.js +126 -0
- package/build/autogestion/client/types.d.ts +49 -0
- package/build/autogestion/client/types.js +1 -0
- package/build/autogestion/format/correlatividades.d.ts +4 -0
- package/build/autogestion/format/correlatividades.js +4 -0
- package/build/autogestion/format/estado-academico.d.ts +4 -0
- package/build/autogestion/format/estado-academico.js +4 -0
- package/build/autogestion/format/examenes.d.ts +3 -0
- package/build/autogestion/format/examenes.js +8 -0
- package/build/autogestion/format/notas-parciales.d.ts +3 -0
- package/build/autogestion/format/notas-parciales.js +8 -0
- package/build/autogestion/link-page.d.ts +7 -0
- package/build/autogestion/link-page.js +62 -0
- package/build/autogestion/login.d.ts +11 -0
- package/build/autogestion/login.js +59 -0
- package/build/autogestion/tools.d.ts +3 -0
- package/build/autogestion/tools.js +73 -0
- package/build/http.d.ts +26 -0
- package/build/http.js +7 -2
- package/build/index.d.ts +2 -0
- package/build/index.js +35 -4
- package/build/oauth/access.d.ts +18 -0
- package/build/oauth/admin.d.ts +8 -0
- package/build/oauth/admin.js +1 -1
- package/build/oauth/pending-login.d.ts +20 -0
- package/build/oauth/redirects.d.ts +20 -0
- package/build/oauth/server.d.ts +22 -0
- package/build/oauth/server.js +1 -1
- package/build/server.d.ts +11 -0
- package/build/server.js +19 -2
- package/build/shared/documents/index.d.ts +33 -0
- package/build/shared/documents/merge.d.ts +32 -0
- package/build/shared/documents/office.d.ts +9 -0
- package/build/shared/documents/pdf.d.ts +2 -0
- package/build/shared/documents/png.d.ts +2 -0
- package/build/shared/documents/zip.d.ts +18 -0
- package/build/shared/expiring.d.ts +26 -0
- package/build/{oauth → shared}/expiring.js +4 -3
- package/build/shared/html.d.ts +6 -0
- package/build/shared/html.js +7 -1
- package/build/shared/http.d.ts +6 -0
- package/build/shared/input.d.ts +7 -0
- package/build/shared/load-env.d.ts +1 -0
- package/build/shared/logger.d.ts +5 -0
- package/build/shared/pages.d.ts +6 -0
- package/build/shared/paths.d.ts +10 -0
- package/build/shared/store/session-store.d.ts +77 -0
- package/build/shared/store/sqlite-store.d.ts +36 -0
- package/build/shared/uploads.d.ts +62 -0
- package/package.json +8 -1
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
import { type CatalogEntry } from "./catalog.js";
|
|
2
|
+
/** Donde quedan los conectores instalados, uno por carpeta con el nombre de su paquete. */
|
|
3
|
+
export declare const connectorsDir: () => string;
|
|
4
|
+
export declare const connectorDir: (entry: CatalogEntry) => string;
|
|
5
|
+
/** El package.json del conector instalado, o null si no está. */
|
|
6
|
+
export declare function installedManifest(entry: CatalogEntry): Promise<{
|
|
7
|
+
version?: string;
|
|
8
|
+
main?: string;
|
|
9
|
+
} | null>;
|
|
10
|
+
/**
|
|
11
|
+
* Para el modo HTTP, que atiende usuarios de cualquier regional: instala los conectores del catálogo que
|
|
12
|
+
* falten o hayan quedado viejos. Uno que no se puede bajar no frena el servidor: queda sin esas tools.
|
|
13
|
+
*/
|
|
14
|
+
export declare function installCatalog(): Promise<void>;
|
|
15
|
+
/**
|
|
16
|
+
* Baja el `.tgz` del catálogo, comprueba su hash y lo extrae en su carpeta. Sin `npm install`: los
|
|
17
|
+
* conectores no tienen dependencias, así que alcanza con desempaquetarlos, y no hace falta npm en la máquina.
|
|
18
|
+
*/
|
|
19
|
+
export declare function installConnector(entry: CatalogEntry): Promise<void>;
|
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
import { createHash } from "node:crypto";
|
|
2
|
+
import { mkdir, readFile, rename, rm, writeFile } from "node:fs/promises";
|
|
3
|
+
import { dirname, isAbsolute, join } from "node:path";
|
|
4
|
+
import { gunzipSync } from "node:zlib";
|
|
5
|
+
import { HttpError } from "../../shared/http.js";
|
|
6
|
+
import { logger } from "../../shared/logger.js";
|
|
7
|
+
import { configDir } from "../../shared/paths.js";
|
|
8
|
+
import { CATALOG } from "./catalog.js";
|
|
9
|
+
/** Donde quedan los conectores instalados, uno por carpeta con el nombre de su paquete. */
|
|
10
|
+
export const connectorsDir = () => join(configDir(), "connectors");
|
|
11
|
+
export const connectorDir = (entry) => join(connectorsDir(), entry.package);
|
|
12
|
+
/** El package.json del conector instalado, o null si no está. */
|
|
13
|
+
export async function installedManifest(entry) {
|
|
14
|
+
try {
|
|
15
|
+
return JSON.parse(await readFile(join(connectorDir(entry), "package.json"), "utf8"));
|
|
16
|
+
}
|
|
17
|
+
catch {
|
|
18
|
+
return null;
|
|
19
|
+
}
|
|
20
|
+
}
|
|
21
|
+
/**
|
|
22
|
+
* Para el modo HTTP, que atiende usuarios de cualquier regional: instala los conectores del catálogo que
|
|
23
|
+
* falten o hayan quedado viejos. Uno que no se puede bajar no frena el servidor: queda sin esas tools.
|
|
24
|
+
*/
|
|
25
|
+
export async function installCatalog() {
|
|
26
|
+
for (const entry of CATALOG) {
|
|
27
|
+
if ((await installedManifest(entry))?.version === entry.version)
|
|
28
|
+
continue;
|
|
29
|
+
try {
|
|
30
|
+
await installConnector(entry);
|
|
31
|
+
logger.info(`Conector de ${entry.name} ${entry.version} instalado.`);
|
|
32
|
+
}
|
|
33
|
+
catch (e) {
|
|
34
|
+
logger.warn(`No se pudo instalar el conector de ${entry.name}: ${e.message}`);
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
/** Bloques del formato tar: encabezado y datos van de a 512 bytes. */
|
|
39
|
+
const TAR_BLOCK = 512;
|
|
40
|
+
/**
|
|
41
|
+
* Baja el `.tgz` del catálogo, comprueba su hash y lo extrae en su carpeta. Sin `npm install`: los
|
|
42
|
+
* conectores no tienen dependencias, así que alcanza con desempaquetarlos, y no hace falta npm en la máquina.
|
|
43
|
+
*/
|
|
44
|
+
export async function installConnector(entry) {
|
|
45
|
+
let res;
|
|
46
|
+
try {
|
|
47
|
+
res = await fetch(entry.tarball, { signal: AbortSignal.timeout(60_000) });
|
|
48
|
+
}
|
|
49
|
+
catch (e) {
|
|
50
|
+
throw new HttpError(`No se pudo bajar el conector de ${entry.name}: ${e.message}`);
|
|
51
|
+
}
|
|
52
|
+
if (!res.ok)
|
|
53
|
+
throw new HttpError(`HTTP ${res.status} al bajar el conector de ${entry.name} (${entry.tarball}).`, res.status);
|
|
54
|
+
const tgz = Buffer.from(await res.arrayBuffer());
|
|
55
|
+
const integrity = `sha512-${createHash("sha512").update(tgz).digest("base64")}`;
|
|
56
|
+
if (integrity !== entry.integrity) {
|
|
57
|
+
throw new Error(`El conector de ${entry.name} que se bajó no es el que espera esta versión de utn-mcp: no se instala.`);
|
|
58
|
+
}
|
|
59
|
+
const target = connectorDir(entry);
|
|
60
|
+
const staging = `${target}.tmp-${process.pid}`;
|
|
61
|
+
await rm(staging, { recursive: true, force: true });
|
|
62
|
+
await extractPackage(tgz, staging);
|
|
63
|
+
await rm(target, { recursive: true, force: true });
|
|
64
|
+
await rename(staging, target);
|
|
65
|
+
}
|
|
66
|
+
/** Extrae los archivos de `package/` de un tarball de `npm pack`; cualquier ruta que salga de ahí se rechaza. */
|
|
67
|
+
async function extractPackage(tgz, dest) {
|
|
68
|
+
const tar = gunzipSync(tgz);
|
|
69
|
+
const text = (start, length) => tar.subarray(start, start + length).toString("utf8").replace(/\0.*$/s, "");
|
|
70
|
+
for (let offset = 0; offset + TAR_BLOCK <= tar.length;) {
|
|
71
|
+
if (tar.subarray(offset, offset + TAR_BLOCK).every((b) => b === 0))
|
|
72
|
+
break;
|
|
73
|
+
const name = text(offset, 100);
|
|
74
|
+
const prefix = text(offset + 345, 155);
|
|
75
|
+
const size = parseInt(text(offset + 124, 12).trim() || "0", 8);
|
|
76
|
+
// "0" o NUL es un archivo común; los demás (carpetas, encabezados pax) no traen nada que escribir.
|
|
77
|
+
const type = String.fromCharCode(tar[offset + 156]);
|
|
78
|
+
const path = prefix ? `${prefix}/${name}` : name;
|
|
79
|
+
const data = offset + TAR_BLOCK;
|
|
80
|
+
if (type === "0" || type === "\0") {
|
|
81
|
+
const relative = path.replace(/^package\//, "");
|
|
82
|
+
if (!path.startsWith("package/") || isAbsolute(relative) || relative.split(/[\\/]/).includes("..")) {
|
|
83
|
+
throw new Error(`El conector trae una ruta inválida (${path}): no se instala.`);
|
|
84
|
+
}
|
|
85
|
+
const file = join(dest, relative);
|
|
86
|
+
await mkdir(dirname(file), { recursive: true });
|
|
87
|
+
await writeFile(file, tar.subarray(data, data + size));
|
|
88
|
+
}
|
|
89
|
+
offset = data + Math.ceil(size / TAR_BLOCK) * TAR_BLOCK;
|
|
90
|
+
}
|
|
91
|
+
}
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
import type { AutogestionSystem } from "./connector.js";
|
|
2
|
+
/**
|
|
3
|
+
* Los conectores disponibles: los del catálogo que `utn-mcp login` instaló en la carpeta de datos y,
|
|
4
|
+
* a mano, los paquetes que nombre `UTN_MCP_CONNECTORS`, resueltos desde la carpeta en la que arranca
|
|
5
|
+
* el proceso. Esa variable es para probar un conector propio: cargarlo ejecuta su código.
|
|
6
|
+
*/
|
|
7
|
+
export declare function loadInstalledSystems(): Promise<AutogestionSystem[]>;
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
import { createRequire } from "node:module";
|
|
2
|
+
import { join } from "node:path";
|
|
3
|
+
import { pathToFileURL } from "node:url";
|
|
4
|
+
import { logger } from "../../shared/logger.js";
|
|
5
|
+
import { CATALOG } from "./catalog.js";
|
|
6
|
+
import { connectorDir, installedManifest } from "./install.js";
|
|
7
|
+
/**
|
|
8
|
+
* Los conectores disponibles: los del catálogo que `utn-mcp login` instaló en la carpeta de datos y,
|
|
9
|
+
* a mano, los paquetes que nombre `UTN_MCP_CONNECTORS`, resueltos desde la carpeta en la que arranca
|
|
10
|
+
* el proceso. Esa variable es para probar un conector propio: cargarlo ejecuta su código.
|
|
11
|
+
*/
|
|
12
|
+
export async function loadInstalledSystems() {
|
|
13
|
+
const systems = [];
|
|
14
|
+
for (const entry of CATALOG) {
|
|
15
|
+
const system = await loadFromCatalog(entry);
|
|
16
|
+
if (system)
|
|
17
|
+
systems.push(system);
|
|
18
|
+
}
|
|
19
|
+
const manual = process.env.UTN_MCP_CONNECTORS?.split(",").map((name) => name.trim()).filter(Boolean) ?? [];
|
|
20
|
+
const require = createRequire(join(process.cwd(), "package.json"));
|
|
21
|
+
for (const name of new Set(manual)) {
|
|
22
|
+
const system = validSystem(await import(pathToFileURL(require.resolve(name)).href), name);
|
|
23
|
+
if (systems.some((existing) => existing.id === system.id || existing.regionales.some((id) => system.regionales.includes(id)))) {
|
|
24
|
+
throw new Error(`El conector ${name} repite un sistema o una regional que ya cubre otro conector.`);
|
|
25
|
+
}
|
|
26
|
+
systems.push(system);
|
|
27
|
+
}
|
|
28
|
+
return systems;
|
|
29
|
+
}
|
|
30
|
+
/** El conector del catálogo si está instalado y es la versión que espera este core; si no, null. */
|
|
31
|
+
async function loadFromCatalog(entry) {
|
|
32
|
+
const manifest = await installedManifest(entry);
|
|
33
|
+
if (!manifest)
|
|
34
|
+
return null;
|
|
35
|
+
if (manifest.version !== entry.version) {
|
|
36
|
+
logger.warn(`El conector de ${entry.name} instalado es la versión ${manifest.version} y esta versión de utn-mcp usa la ` +
|
|
37
|
+
`${entry.version}: corré \`utn-mcp login autogestion\` para actualizarlo.`);
|
|
38
|
+
return null;
|
|
39
|
+
}
|
|
40
|
+
const system = validSystem(await import(pathToFileURL(join(connectorDir(entry), manifest.main ?? "index.js")).href), entry.package);
|
|
41
|
+
if (system.id !== entry.id)
|
|
42
|
+
throw new Error(`El paquete ${entry.package} no es el conector ${entry.id}.`);
|
|
43
|
+
return system;
|
|
44
|
+
}
|
|
45
|
+
function validSystem(module, source) {
|
|
46
|
+
const system = module.default;
|
|
47
|
+
const nonEmpty = (value) => typeof value === "string" && value.trim() !== "";
|
|
48
|
+
if (!system ||
|
|
49
|
+
!nonEmpty(system.id) ||
|
|
50
|
+
!nonEmpty(system.name) ||
|
|
51
|
+
!Array.isArray(system.regionales) ||
|
|
52
|
+
system.regionales.length === 0 ||
|
|
53
|
+
!system.regionales.every(nonEmpty) ||
|
|
54
|
+
typeof system.create !== "function") {
|
|
55
|
+
throw new Error(`El paquete ${source} no exporta un conector de autogestión válido.`);
|
|
56
|
+
}
|
|
57
|
+
return system;
|
|
58
|
+
}
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
import { type AutogestionConnector, type AutogestionSystem } from "./connector.js";
|
|
2
|
+
import type { AutogestionSession } from "./session.js";
|
|
3
|
+
/**
|
|
4
|
+
* Sesión del modo local: un solo usuario, el de la compu. Recibe el sistema ya resuelto para la
|
|
5
|
+
* regional (la misma que eligió para el aula) y loguea el conector con las credenciales del
|
|
6
|
+
* keychain o las env vars, una vez por sistema y bajo demanda: el primer uso es el que pide login.
|
|
7
|
+
*/
|
|
8
|
+
export declare class LocalAutogestionSession implements AutogestionSession {
|
|
9
|
+
private readonly system;
|
|
10
|
+
private readonly regionalId;
|
|
11
|
+
/** Conectores ya logueados, por sistema: un login alcanza para todas las tools. */
|
|
12
|
+
private readonly connectors;
|
|
13
|
+
/** El sistema llega resuelto desde afuera: las plataformas no se conocen entre sí. */
|
|
14
|
+
constructor(system: AutogestionSystem, regionalId: string);
|
|
15
|
+
getConnector(): Promise<AutogestionConnector>;
|
|
16
|
+
private login;
|
|
17
|
+
}
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
import { connectorKit, LoginRequiredError } from "./connector.js";
|
|
2
|
+
import { loadAutogestionCredentials } from "./credentials.js";
|
|
3
|
+
/**
|
|
4
|
+
* Sesión del modo local: un solo usuario, el de la compu. Recibe el sistema ya resuelto para la
|
|
5
|
+
* regional (la misma que eligió para el aula) y loguea el conector con las credenciales del
|
|
6
|
+
* keychain o las env vars, una vez por sistema y bajo demanda: el primer uso es el que pide login.
|
|
7
|
+
*/
|
|
8
|
+
export class LocalAutogestionSession {
|
|
9
|
+
system;
|
|
10
|
+
regionalId;
|
|
11
|
+
/** Conectores ya logueados, por sistema: un login alcanza para todas las tools. */
|
|
12
|
+
connectors = new Map();
|
|
13
|
+
/** El sistema llega resuelto desde afuera: las plataformas no se conocen entre sí. */
|
|
14
|
+
constructor(system, regionalId) {
|
|
15
|
+
this.system = system;
|
|
16
|
+
this.regionalId = regionalId;
|
|
17
|
+
}
|
|
18
|
+
async getConnector() {
|
|
19
|
+
const system = this.system;
|
|
20
|
+
let connector = this.connectors.get(system.id);
|
|
21
|
+
if (!connector) {
|
|
22
|
+
connector = this.login(system.id, system.create(this.regionalId, connectorKit)).catch((error) => {
|
|
23
|
+
this.connectors.delete(system.id);
|
|
24
|
+
throw error;
|
|
25
|
+
});
|
|
26
|
+
this.connectors.set(system.id, connector);
|
|
27
|
+
}
|
|
28
|
+
return connector;
|
|
29
|
+
}
|
|
30
|
+
async login(systemId, connector) {
|
|
31
|
+
const credentials = await loadAutogestionCredentials(systemId);
|
|
32
|
+
if (!credentials)
|
|
33
|
+
throw new LoginRequiredError("la autogestión de tu regional");
|
|
34
|
+
await connector.login(credentials);
|
|
35
|
+
return connector;
|
|
36
|
+
}
|
|
37
|
+
}
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
import type { AutogestionConnector, AutogestionSystem } from "./connector.js";
|
|
2
|
+
/**
|
|
3
|
+
* La autogestión del usuario. Todo lo que habla con el sistema recibe esto y no sabe de dónde sale:
|
|
4
|
+
* `LocalAutogestionSession` (STDIO, el usuario de la compu, credenciales del keychain) o la hosteada
|
|
5
|
+
* (HTTP, el usuario entra desde un link y la contraseña no se guarda).
|
|
6
|
+
*/
|
|
7
|
+
export interface AutogestionSession {
|
|
8
|
+
/**
|
|
9
|
+
* El conector del sistema que usa la regional del usuario, logueado y listo.
|
|
10
|
+
* Rechaza con un error que dice cómo iniciar sesión si todavía no se puede.
|
|
11
|
+
*/
|
|
12
|
+
getConnector(): Promise<AutogestionConnector>;
|
|
13
|
+
}
|
|
14
|
+
/** Los conectores disponibles y cómo armar la sesión de un usuario: lo que recibe `createServer`. */
|
|
15
|
+
export interface AutogestionAccess {
|
|
16
|
+
systems: AutogestionSystem[];
|
|
17
|
+
sessionFor(system: AutogestionSystem, regionalId: string): AutogestionSession;
|
|
18
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
import type { SessionStore } from "../../shared/store/session-store.js";
|
|
2
|
+
import { type AutogestionConnector, type AutogestionCredentials, type AutogestionSystem } from "./connector.js";
|
|
3
|
+
import type { AutogestionAccess } from "./session.js";
|
|
4
|
+
/** Con qué nombre guarda la autogestión su sesión en el store. */
|
|
5
|
+
export declare const AUTOGESTION_PLATFORM = "autogestion";
|
|
6
|
+
/** Un link abierto para que el usuario entre a su autogestión desde el navegador. */
|
|
7
|
+
export interface PendingLink {
|
|
8
|
+
userId: number;
|
|
9
|
+
regionalId: string;
|
|
10
|
+
system: AutogestionSystem;
|
|
11
|
+
}
|
|
12
|
+
/** Falta entrar a la autogestión: el mensaje trae el link para el usuario. */
|
|
13
|
+
export declare class AutogestionLinkRequiredError extends Error {
|
|
14
|
+
constructor(systemName: string, url: string, reason?: string);
|
|
15
|
+
}
|
|
16
|
+
/**
|
|
17
|
+
* La autogestión del modo HTTP, para todos los usuarios del servidor. Igual que el aula: la contraseña se
|
|
18
|
+
* usa una sola vez, en la página del link, y no se guarda; en el store queda cifrada la sesión del
|
|
19
|
+
* sistema (cookies), que el conector exporta. Cuando el sistema la vence, el usuario vuelve a entrar.
|
|
20
|
+
*/
|
|
21
|
+
export declare class StoredAutogestion {
|
|
22
|
+
private readonly baseUrl;
|
|
23
|
+
private readonly store;
|
|
24
|
+
/** Los links viven en memoria: si el servidor se reinicia, el agente pide otro. */
|
|
25
|
+
private readonly links;
|
|
26
|
+
/** Solo los conectores que pueden exportar su sesión: los otros necesitarían guardar la contraseña. */
|
|
27
|
+
readonly systems: AutogestionSystem[];
|
|
28
|
+
constructor(baseUrl: string, store: SessionStore, systems: AutogestionSystem[]);
|
|
29
|
+
/** Lo que recibe `createServer` para un usuario. */
|
|
30
|
+
forUser(userId: number): AutogestionAccess;
|
|
31
|
+
/** El conector del usuario con su sesión guardada, o null si no hay una de ese sistema. */
|
|
32
|
+
restore(userId: number, system: AutogestionSystem, regionalId: string): Promise<AutogestionConnector | null>;
|
|
33
|
+
/** Guarda la sesión del conector (cifrada, como todo el store); las cookies pueden cambiar en cada pedido. */
|
|
34
|
+
save(userId: number, connector: AutogestionConnector): Promise<void>;
|
|
35
|
+
/** El store no borra sesiones: una vacía es "no hay". */
|
|
36
|
+
forget(userId: number): Promise<void>;
|
|
37
|
+
/** Un link para que el usuario entre; los que se abrieron antes siguen valiendo hasta que vencen o se usa uno. */
|
|
38
|
+
openLink(userId: number, system: AutogestionSystem, regionalId: string): string;
|
|
39
|
+
link(id: string): PendingLink | undefined;
|
|
40
|
+
/** Entra con lo que el usuario escribió en la página del link y guarda la sesión; la contraseña se descarta. */
|
|
41
|
+
complete(id: string, credentials: AutogestionCredentials): Promise<PendingLink>;
|
|
42
|
+
}
|
|
@@ -0,0 +1,126 @@
|
|
|
1
|
+
import { Expiring } from "../../shared/expiring.js";
|
|
2
|
+
import { connectorKit, InvalidCredentialsError, LoginRequiredError, } from "./connector.js";
|
|
3
|
+
/** Con qué nombre guarda la autogestión su sesión en el store. */
|
|
4
|
+
export const AUTOGESTION_PLATFORM = "autogestion";
|
|
5
|
+
/** Cuánto dura un link para entrar, en milisegundos. */
|
|
6
|
+
const LINK_TTL_MS = 30 * 60 * 1000;
|
|
7
|
+
/** Falta entrar a la autogestión: el mensaje trae el link para el usuario. */
|
|
8
|
+
export class AutogestionLinkRequiredError extends Error {
|
|
9
|
+
constructor(systemName, url, reason) {
|
|
10
|
+
super(`${reason ? `${reason} ` : ""}Falta iniciar sesión en ${systemName}. Pasale al usuario este link para que entre ` +
|
|
11
|
+
`con su legajo y contraseña de ${systemName} (vence en 30 minutos): ${url} . Cuando te avise que entró, ` +
|
|
12
|
+
"reintentá. La contraseña no pasa por el chat: no se la pidas.");
|
|
13
|
+
this.name = "AutogestionLinkRequiredError";
|
|
14
|
+
}
|
|
15
|
+
}
|
|
16
|
+
/**
|
|
17
|
+
* La autogestión del modo HTTP, para todos los usuarios del servidor. Igual que el aula: la contraseña se
|
|
18
|
+
* usa una sola vez, en la página del link, y no se guarda; en el store queda cifrada la sesión del
|
|
19
|
+
* sistema (cookies), que el conector exporta. Cuando el sistema la vence, el usuario vuelve a entrar.
|
|
20
|
+
*/
|
|
21
|
+
export class StoredAutogestion {
|
|
22
|
+
baseUrl;
|
|
23
|
+
store;
|
|
24
|
+
/** Los links viven en memoria: si el servidor se reinicia, el agente pide otro. */
|
|
25
|
+
links = new Expiring(LINK_TTL_MS);
|
|
26
|
+
/** Solo los conectores que pueden exportar su sesión: los otros necesitarían guardar la contraseña. */
|
|
27
|
+
systems;
|
|
28
|
+
constructor(baseUrl, store, systems) {
|
|
29
|
+
this.baseUrl = baseUrl;
|
|
30
|
+
this.store = store;
|
|
31
|
+
this.systems = systems.filter((system) => {
|
|
32
|
+
const connector = system.create(system.regionales[0], connectorKit);
|
|
33
|
+
return typeof connector.exportSession === "function" && typeof connector.restoreSession === "function";
|
|
34
|
+
});
|
|
35
|
+
}
|
|
36
|
+
/** Lo que recibe `createServer` para un usuario. */
|
|
37
|
+
forUser(userId) {
|
|
38
|
+
return {
|
|
39
|
+
systems: this.systems,
|
|
40
|
+
sessionFor: (system, regionalId) => new StoredAutogestionSession(this, userId, system, regionalId),
|
|
41
|
+
};
|
|
42
|
+
}
|
|
43
|
+
/** El conector del usuario con su sesión guardada, o null si no hay una de ese sistema. */
|
|
44
|
+
async restore(userId, system, regionalId) {
|
|
45
|
+
const raw = await this.store.loadSession(userId, AUTOGESTION_PLATFORM);
|
|
46
|
+
if (!raw)
|
|
47
|
+
return null;
|
|
48
|
+
const data = JSON.parse(raw);
|
|
49
|
+
if (data.systemId !== system.id)
|
|
50
|
+
return null;
|
|
51
|
+
const connector = system.create(regionalId, connectorKit);
|
|
52
|
+
connector.restoreSession(data.session);
|
|
53
|
+
return connector;
|
|
54
|
+
}
|
|
55
|
+
/** Guarda la sesión del conector (cifrada, como todo el store); las cookies pueden cambiar en cada pedido. */
|
|
56
|
+
async save(userId, connector) {
|
|
57
|
+
const data = { systemId: connector.systemId, session: connector.exportSession() };
|
|
58
|
+
await this.store.saveSession(userId, AUTOGESTION_PLATFORM, JSON.stringify(data));
|
|
59
|
+
}
|
|
60
|
+
/** El store no borra sesiones: una vacía es "no hay". */
|
|
61
|
+
async forget(userId) {
|
|
62
|
+
await this.store.saveSession(userId, AUTOGESTION_PLATFORM, "");
|
|
63
|
+
}
|
|
64
|
+
/** Un link para que el usuario entre; los que se abrieron antes siguen valiendo hasta que vencen o se usa uno. */
|
|
65
|
+
openLink(userId, system, regionalId) {
|
|
66
|
+
return `${this.baseUrl}/autogestion/${this.links.add({ userId, regionalId, system })}`;
|
|
67
|
+
}
|
|
68
|
+
link(id) {
|
|
69
|
+
return this.links.get(id);
|
|
70
|
+
}
|
|
71
|
+
/** Entra con lo que el usuario escribió en la página del link y guarda la sesión; la contraseña se descarta. */
|
|
72
|
+
async complete(id, credentials) {
|
|
73
|
+
const link = this.link(id);
|
|
74
|
+
if (!link)
|
|
75
|
+
throw new Error("El link venció o ya se usó. Pedile al agente uno nuevo.");
|
|
76
|
+
const connector = link.system.create(link.regionalId, connectorKit);
|
|
77
|
+
await connector.login(credentials);
|
|
78
|
+
await this.save(link.userId, connector);
|
|
79
|
+
this.links.take(id);
|
|
80
|
+
return link;
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
/** La sesión de un usuario en un request: el conector con la sesión guardada o, si no hay, el link para entrar. */
|
|
84
|
+
class StoredAutogestionSession {
|
|
85
|
+
stored;
|
|
86
|
+
userId;
|
|
87
|
+
system;
|
|
88
|
+
regionalId;
|
|
89
|
+
constructor(stored, userId, system, regionalId) {
|
|
90
|
+
this.stored = stored;
|
|
91
|
+
this.userId = userId;
|
|
92
|
+
this.system = system;
|
|
93
|
+
this.regionalId = regionalId;
|
|
94
|
+
}
|
|
95
|
+
async getConnector() {
|
|
96
|
+
const connector = await this.stored.restore(this.userId, this.system, this.regionalId);
|
|
97
|
+
if (!connector)
|
|
98
|
+
throw this.linkRequired();
|
|
99
|
+
// Después de cada pedido se guarda la sesión, por si el sistema rotó las cookies. Si la venció, se
|
|
100
|
+
// descarta y se pide entrar de nuevo: los mensajes del conector hablan del login por CLI, que acá no existe.
|
|
101
|
+
const guard = async (call) => {
|
|
102
|
+
try {
|
|
103
|
+
const result = await call();
|
|
104
|
+
await this.stored.save(this.userId, connector);
|
|
105
|
+
return result;
|
|
106
|
+
}
|
|
107
|
+
catch (e) {
|
|
108
|
+
if (!(e instanceof InvalidCredentialsError || e instanceof LoginRequiredError))
|
|
109
|
+
throw e;
|
|
110
|
+
await this.stored.forget(this.userId);
|
|
111
|
+
throw this.linkRequired(`La sesión de ${this.system.name} venció.`);
|
|
112
|
+
}
|
|
113
|
+
};
|
|
114
|
+
return {
|
|
115
|
+
systemId: connector.systemId,
|
|
116
|
+
login: (credentials) => connector.login(credentials),
|
|
117
|
+
estadoAcademico: () => guard(() => connector.estadoAcademico()),
|
|
118
|
+
correlatividades: () => guard(() => connector.correlatividades()),
|
|
119
|
+
notasParciales: connector.notasParciales && (() => guard(() => connector.notasParciales())),
|
|
120
|
+
examenes: connector.examenes && (() => guard(() => connector.examenes())),
|
|
121
|
+
};
|
|
122
|
+
}
|
|
123
|
+
linkRequired(reason) {
|
|
124
|
+
return new AutogestionLinkRequiredError(this.system.name, this.stored.openLink(this.userId, this.system, this.regionalId), reason);
|
|
125
|
+
}
|
|
126
|
+
}
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
/** Una materia del estado académico, con lo que el sistema informa de ella. */
|
|
2
|
+
export interface EstadoMateria {
|
|
3
|
+
materia: string;
|
|
4
|
+
estado?: string;
|
|
5
|
+
plan?: string;
|
|
6
|
+
anio?: string;
|
|
7
|
+
nota?: string;
|
|
8
|
+
fecha?: string;
|
|
9
|
+
/** Celdas que no entraron en una columna conocida: no se tira información. */
|
|
10
|
+
extra?: string[];
|
|
11
|
+
}
|
|
12
|
+
/** Una materia con las correlativas que exige, tal como las publica la autogestión. */
|
|
13
|
+
export interface CorrelatividadMateria {
|
|
14
|
+
materia: string;
|
|
15
|
+
/** Texto de la columna de correlativas, tal cual viene: "Puede cursar", "No aprobó X", etc. */
|
|
16
|
+
correlativas: string;
|
|
17
|
+
plan?: string;
|
|
18
|
+
anio?: string;
|
|
19
|
+
extra?: string[];
|
|
20
|
+
}
|
|
21
|
+
/**
|
|
22
|
+
* Correlatividades de la carrera según la autogestión, separadas como las publica el sistema:
|
|
23
|
+
* `cursar` siempre; `rendir` cuando el sistema la tiene en otra página (Sysacad sí, verificado
|
|
24
|
+
* con cuenta real oct 2026). Es la fuente dinámica de las correlatividades en lugar de datos
|
|
25
|
+
* fijos del repo.
|
|
26
|
+
*/
|
|
27
|
+
export interface Correlatividades {
|
|
28
|
+
cursar: CorrelatividadMateria[];
|
|
29
|
+
rendir?: CorrelatividadMateria[];
|
|
30
|
+
}
|
|
31
|
+
export interface NotasParcialesMateria {
|
|
32
|
+
materia: string;
|
|
33
|
+
anio?: string;
|
|
34
|
+
comision?: string;
|
|
35
|
+
aula?: string;
|
|
36
|
+
horarios?: string;
|
|
37
|
+
notas?: string;
|
|
38
|
+
inasistencias?: string;
|
|
39
|
+
obs?: string;
|
|
40
|
+
cursando: boolean;
|
|
41
|
+
}
|
|
42
|
+
export interface ExamenMateria {
|
|
43
|
+
materia: string;
|
|
44
|
+
fecha?: string;
|
|
45
|
+
nota?: string;
|
|
46
|
+
especialidad?: string;
|
|
47
|
+
plan?: string;
|
|
48
|
+
codigo?: string;
|
|
49
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,4 @@
|
|
|
1
|
+
import type { AutogestionSession } from "../client/session.js";
|
|
2
|
+
import type { Correlatividades } from "../client/types.js";
|
|
3
|
+
/** Correlatividades de cursar y, si el conector la publica, de rendir. */
|
|
4
|
+
export declare function getCorrelatividades(session: AutogestionSession): Promise<Correlatividades>;
|
|
@@ -0,0 +1,4 @@
|
|
|
1
|
+
import type { AutogestionSession } from "../client/session.js";
|
|
2
|
+
import type { EstadoMateria } from "../client/types.js";
|
|
3
|
+
/** Estado académico del alumno en su autogestión, delegado al conector de su regional. */
|
|
4
|
+
export declare function getEstadoAcademico(session: AutogestionSession): Promise<EstadoMateria[]>;
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
import { HttpError } from "../../shared/http.js";
|
|
2
|
+
export async function getExamenes(session) {
|
|
3
|
+
const connector = await session.getConnector();
|
|
4
|
+
if (!connector.examenes) {
|
|
5
|
+
throw new HttpError("El sistema de autogestión de tu regional todavía no tiene conector para el historial de exámenes.");
|
|
6
|
+
}
|
|
7
|
+
return connector.examenes();
|
|
8
|
+
}
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
import { HttpError } from "../../shared/http.js";
|
|
2
|
+
export async function getCursandoAhora(session) {
|
|
3
|
+
const connector = await session.getConnector();
|
|
4
|
+
if (!connector.notasParciales) {
|
|
5
|
+
throw new HttpError("El sistema de autogestión de tu regional todavía no tiene conector para la cursada actual.");
|
|
6
|
+
}
|
|
7
|
+
return (await connector.notasParciales()).filter((m) => m.cursando);
|
|
8
|
+
}
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
import { type Express, type RequestHandler } from "express";
|
|
2
|
+
import type { StoredAutogestion } from "./client/stored-session.js";
|
|
3
|
+
/**
|
|
4
|
+
* `/autogestion/:id`: la página del link que el agente le pasa al usuario para entrar a su autogestión
|
|
5
|
+
* en modo HTTP. Sin bearer, como `/subir`: el id del link es el secreto y lo abre un navegador.
|
|
6
|
+
*/
|
|
7
|
+
export declare function mountAutogestionLink(app: Express, stored: StoredAutogestion, sameOrigin: RequestHandler): void;
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
import express from "express";
|
|
2
|
+
import { RateLimiter } from "../shared/expiring.js";
|
|
3
|
+
import { logger } from "../shared/logger.js";
|
|
4
|
+
import { escapeHtml, page } from "../shared/pages.js";
|
|
5
|
+
import { InvalidCredentialsError } from "./client/connector.js";
|
|
6
|
+
/** Intentos de login por IP cada 15 minutos (en milisegundos): cada uno es un login contra el sistema de la regional. */
|
|
7
|
+
const MAX_ATTEMPTS = 10;
|
|
8
|
+
const ATTEMPTS_WINDOW_MS = 15 * 60 * 1000;
|
|
9
|
+
/**
|
|
10
|
+
* `/autogestion/:id`: la página del link que el agente le pasa al usuario para entrar a su autogestión
|
|
11
|
+
* en modo HTTP. Sin bearer, como `/subir`: el id del link es el secreto y lo abre un navegador.
|
|
12
|
+
*/
|
|
13
|
+
export function mountAutogestionLink(app, stored, sameOrigin) {
|
|
14
|
+
const form = express.urlencoded({ extended: false, limit: "16kb" });
|
|
15
|
+
const limiter = new RateLimiter(MAX_ATTEMPTS, ATTEMPTS_WINDOW_MS);
|
|
16
|
+
app.get("/autogestion/:id", sameOrigin, (req, res) => {
|
|
17
|
+
const link = stored.link(String(req.params.id));
|
|
18
|
+
if (!link)
|
|
19
|
+
return void expired(res);
|
|
20
|
+
res.type("html").send(linkPage(link));
|
|
21
|
+
});
|
|
22
|
+
app.post("/autogestion/:id", sameOrigin, form, async (req, res) => {
|
|
23
|
+
const id = String(req.params.id);
|
|
24
|
+
const link = stored.link(id);
|
|
25
|
+
if (!link)
|
|
26
|
+
return void expired(res);
|
|
27
|
+
const retry = (error) => void res.type("html").send(linkPage(link, error));
|
|
28
|
+
if (!limiter.allow(req.ip ?? "desconocida"))
|
|
29
|
+
return retry("Demasiados intentos. Esperá unos minutos y probá de nuevo.");
|
|
30
|
+
const username = String(req.body?.usuario ?? "").trim();
|
|
31
|
+
const password = String(req.body?.contrasena ?? "");
|
|
32
|
+
if (!username || !password)
|
|
33
|
+
return retry("Completá legajo y contraseña.");
|
|
34
|
+
try {
|
|
35
|
+
await stored.complete(id, { username, password });
|
|
36
|
+
}
|
|
37
|
+
catch (e) {
|
|
38
|
+
if (e instanceof InvalidCredentialsError)
|
|
39
|
+
return retry(`${link.system.name} rechazó el legajo o la contraseña.`);
|
|
40
|
+
logger.warn(`Autogestión: ${e.message}`);
|
|
41
|
+
return retry(e.message);
|
|
42
|
+
}
|
|
43
|
+
res.type("html").send(page("Listo", `<p>Entraste a ${escapeHtml(link.system.name)}. Volvé al chat y pedile al agente que reintente.</p>`));
|
|
44
|
+
});
|
|
45
|
+
}
|
|
46
|
+
function expired(res) {
|
|
47
|
+
res.status(404).type("html").send(page("Link vencido", "<p>Este link venció o ya se usó. Pedile al agente uno nuevo.</p>"));
|
|
48
|
+
}
|
|
49
|
+
function linkPage(link, error) {
|
|
50
|
+
const name = escapeHtml(link.system.name);
|
|
51
|
+
const hint = link.system.usernameHint?.(link.regionalId);
|
|
52
|
+
return page(`Entrar a ${link.system.name}`, `<p>Tu agente necesita entrar a ${name} para leer tu información académica. Usá el legajo y la contraseña ` +
|
|
53
|
+
`de ${name}, que no siempre son los del aula virtual.</p>` +
|
|
54
|
+
(error ? `<p class=error>${escapeHtml(error)}</p>` : "") +
|
|
55
|
+
'<form method="post">' +
|
|
56
|
+
`<label>Legajo${hint ? ` (${escapeHtml(hint)})` : ""}<input name="usuario" autocomplete="username" autocapitalize="none" required></label>` +
|
|
57
|
+
'<label>Contraseña<input name="contrasena" type="password" autocomplete="current-password" required></label>' +
|
|
58
|
+
'<button type="submit">Entrar</button></form>' +
|
|
59
|
+
"<p class=nota>La contraseña se usa una sola vez para entrar y no se guarda: queda guardada, cifrada, " +
|
|
60
|
+
`solo la sesión de ${name}. Cuando ${name} la vence, te va a pedir que entres de nuevo. ` +
|
|
61
|
+
'El código es abierto: <a href="https://github.com/nialvape/utn-mcp">github.com/nialvape/utn-mcp</a>.</p>', "Autogestión");
|
|
62
|
+
}
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `utn-mcp login autogestion`: instala el conector de la regional si falta o quedó viejo, pide legajo
|
|
3
|
+
* y contraseña, los prueba contra el sistema y recién ahí los guarda en el keychain. La misma regional
|
|
4
|
+
* que se usa para el aula decide qué sistema de autogestión toca.
|
|
5
|
+
*/
|
|
6
|
+
export declare function runAutogestionLogin(regionalId: string): Promise<void>;
|
|
7
|
+
/**
|
|
8
|
+
* Después del login del aula: si la regional tiene un conector en el catálogo, ofrece conectarlo.
|
|
9
|
+
* Un error acá no deshace el login del aula, que ya quedó guardado.
|
|
10
|
+
*/
|
|
11
|
+
export declare function offerAutogestionLogin(regionalId: string): Promise<void>;
|