utn-mcp 0.2.1 → 0.6.9
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 +22 -0
- package/build/aula-virtual/client/client.js +181 -3
- package/build/aula-virtual/client/moodle-api.js +47 -13
- package/build/aula-virtual/client/session.js +0 -1
- package/build/aula-virtual/client/stored-session.js +55 -0
- package/build/aula-virtual/format/actions.js +159 -0
- package/build/aula-virtual/format/activities.js +102 -0
- package/build/aula-virtual/format/address.js +52 -0
- package/build/aula-virtual/format/fields.js +14 -0
- package/build/aula-virtual/format/files.js +89 -0
- package/build/aula-virtual/format/navigate.js +143 -0
- package/build/aula-virtual/format/submission.js +214 -0
- package/build/aula-virtual/login.js +30 -4
- package/build/aula-virtual/tools.js +291 -13
- package/build/http.js +121 -3
- package/build/index.js +23 -11
- package/build/server.js +7 -4
- package/build/shared/documents/index.js +46 -46
- package/build/shared/documents/office.js +63 -63
- package/build/shared/documents/pdf.js +80 -60
- package/build/shared/documents/png.js +20 -20
- package/build/shared/documents/zip.js +49 -48
- package/build/shared/html.js +15 -2
- package/build/shared/paths.js +4 -0
- package/build/shared/store/session-store.js +1 -0
- package/build/shared/store/sqlite-store.js +145 -0
- package/build/shared/uploads.js +80 -0
- package/package.json +1 -1
- package/build/aula-virtual/address.js +0 -39
- package/build/aula-virtual/files.js +0 -89
- package/build/aula-virtual/navigate.js +0 -144
- /package/build/aula-virtual/{types.js → client/types.js} +0 -0
package/README.md
CHANGED
|
@@ -6,6 +6,10 @@ Este es el Model Context Protocol server NO OFICIAL de la UTN. Con él podrás p
|
|
|
6
6
|
- *"¿Qué avisó la profesora de Física esta semana?"*
|
|
7
7
|
- *"Resumime la guía de la unidad 3 de Sistemas Operativos."*
|
|
8
8
|
- *"Pasame el link para bajar el apunte de calorimetría."*
|
|
9
|
+
- *"¿Qué nota me pusieron en el TP1 y qué me corrigieron?"*
|
|
10
|
+
- *"Preguntá en el foro de consultas si el parcial incluye la unidad 4."*
|
|
11
|
+
- *"Anotame en el grupo 6."*
|
|
12
|
+
- *"Agendame el recuperatorio del 14 a las 18."*
|
|
9
13
|
|
|
10
14
|
El agente recorre el aula como lo harías vos: entra al curso, mira los foros y abre los archivos que hagan falta.
|
|
11
15
|
|
|
@@ -16,6 +20,16 @@ El agente recorre el aula como lo harías vos: entra al curso, mira los foros y
|
|
|
16
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. |
|
|
17
21
|
| `aula_read_file` | Lee un archivo del aula, por partes cuando es largo. |
|
|
18
22
|
| `aula_get_site_info` | Datos de tu usuario en el aula virtual. |
|
|
23
|
+
| `aula_forum_post` | Abre un hilo en un foro o responde en una discusión. |
|
|
24
|
+
| `aula_forum_edit_post` | Edita una publicación tuya. |
|
|
25
|
+
| `aula_forum_delete_post` | Borra una publicación tuya. |
|
|
26
|
+
| `aula_assign_submit` | Entrega una tarea: sube archivos de tu compu, escribe el texto en línea y confirma el envío. |
|
|
27
|
+
| `aula_assign_upload_link` | Solo en modo HTTP: un link para subir los archivos de una entrega, que usa el agente con `curl` o vos desde el navegador. |
|
|
28
|
+
| `aula_choice_answer` | Responde una elección o te anota en un grupo. |
|
|
29
|
+
| `aula_send_message` | Manda un mensaje privado a un docente o compañero. |
|
|
30
|
+
| `aula_calendar_add_event` | Agrega un recordatorio a tu calendario del aula. |
|
|
31
|
+
|
|
32
|
+
Al abrir una tarea, `aula_open` muestra si la entregaste, cuándo vence y la devolución con la nota. Las tools que escriben le piden al agente que te muestre lo que va a hacer y espere tu confirmación.
|
|
19
33
|
|
|
20
34
|
### Archivos que puede leer
|
|
21
35
|
- **PDF**: el texto, página por página. Los **escaneados** se mandan como imagen de cada página, y el agente los lee igual.
|
|
@@ -106,15 +120,23 @@ utn-mcp login
|
|
|
106
120
|
y en la config del cliente usás `"command": "utn-mcp"` sin argumentos.
|
|
107
121
|
|
|
108
122
|
### Modo HTTP (opcional)
|
|
123
|
+
Necesita Node 22.13 o más nuevo. Primero, una vez:
|
|
124
|
+
```bash
|
|
125
|
+
npx -y utn-mcp login --http
|
|
126
|
+
```
|
|
127
|
+
Te muestra un token: configurá tu cliente MCP para que mande el header `Authorization: Bearer <token>`. Después:
|
|
109
128
|
```bash
|
|
110
129
|
npx -y utn-mcp --http --port 3000
|
|
111
130
|
```
|
|
112
131
|
Levanta el servidor en `http://127.0.0.1:3000/mcp`, solo accesible desde tu propia máquina. Tenés que dejar la terminal abierta mientras lo uses.
|
|
113
132
|
|
|
133
|
+
En este modo el servidor no lee archivos de tu disco: para entregar, el agente pide un link con `aula_assign_upload_link` y sube ahí los archivos con `curl` si tiene terminal, o te pasa el link para que los elijas desde el navegador. Si el servidor se publica detrás de otra dirección, definí `UTN_MCP_PUBLIC_URL` para que los links la usen.
|
|
134
|
+
|
|
114
135
|
## Dónde se guardan tus datos
|
|
115
136
|
Todo queda en tu computadora:
|
|
116
137
|
- **Usuario y contraseña**: en el gestor de credenciales del sistema (Windows Credential Manager, Keychain en macOS, Secret Service en Linux).
|
|
117
138
|
- **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).
|
|
139
|
+
- **En modo HTTP**: la sesión va cifrada en `utn-mcp.db`, en la misma carpeta, con la clave de `store.key` (o la de `UTN_MCP_KEY`). Del token del cliente solo se guarda el hash.
|
|
118
140
|
|
|
119
141
|
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
142
|
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { callWs } from "./moodle-api.js";
|
|
1
|
+
import { callWs, MoodleError } from "./moodle-api.js";
|
|
2
2
|
export function getSiteInfo(session) {
|
|
3
3
|
return callWs(session, "core_webservice_get_site_info");
|
|
4
4
|
}
|
|
@@ -20,15 +20,193 @@ export function getCourseSections(session, courseId, cmid) {
|
|
|
20
20
|
}
|
|
21
21
|
// --- Foros ---
|
|
22
22
|
/** Cuántas discusiones trae una página del índice de un foro. */
|
|
23
|
-
export const
|
|
23
|
+
export const PAGE_SIZE = 50;
|
|
24
24
|
/**
|
|
25
25
|
* Discusiones de un foro, las más recientes primero. Moodle manda el mensaje completo de la
|
|
26
26
|
* primera publicación de cada una; el que lee decide si lo recorta.
|
|
27
27
|
*/
|
|
28
28
|
export function getForumDiscussions(session, forumid, page = 0) {
|
|
29
|
-
return callWs(session, "mod_forum_get_forum_discussions", { forumid, page, perpage:
|
|
29
|
+
return callWs(session, "mod_forum_get_forum_discussions", { forumid, page, perpage: PAGE_SIZE });
|
|
30
30
|
}
|
|
31
31
|
/** Publicaciones de una discusión, incluidas las respuestas. */
|
|
32
32
|
export function getDiscussionPosts(session, discussionid) {
|
|
33
33
|
return callWs(session, "mod_forum_get_discussion_posts", { discussionid });
|
|
34
34
|
}
|
|
35
|
+
/** Si el usuario puede abrir un hilo en el foro, con el grupo que le toque. */
|
|
36
|
+
export async function canAddDiscussion(session, forumid) {
|
|
37
|
+
const { status } = await callWs(session, "mod_forum_can_add_discussion", { forumid });
|
|
38
|
+
return status;
|
|
39
|
+
}
|
|
40
|
+
/** Abre un hilo. Moodle elige solo el grupo del usuario si el foro es por grupos. */
|
|
41
|
+
export async function addDiscussion(session, forumid, subject, message) {
|
|
42
|
+
const { discussionid } = await callWs(session, "mod_forum_add_discussion", {
|
|
43
|
+
forumid,
|
|
44
|
+
subject,
|
|
45
|
+
message,
|
|
46
|
+
});
|
|
47
|
+
return discussionid;
|
|
48
|
+
}
|
|
49
|
+
/** Responde a una publicación, que puede ser la primera de la discusión. */
|
|
50
|
+
export async function addDiscussionPost(session, postid, subject, message) {
|
|
51
|
+
const { postid: newPostId } = await callWs(session, "mod_forum_add_discussion_post", {
|
|
52
|
+
postid,
|
|
53
|
+
subject,
|
|
54
|
+
message,
|
|
55
|
+
});
|
|
56
|
+
return newPostId;
|
|
57
|
+
}
|
|
58
|
+
/** Edita una publicación propia; con asunto vacío Moodle deja el que tenía. */
|
|
59
|
+
export async function updateDiscussionPost(session, postid, subject, message) {
|
|
60
|
+
await callWs(session, "mod_forum_update_discussion_post", { postid, subject, message });
|
|
61
|
+
}
|
|
62
|
+
/** Borra una publicación; si es la primera, se va la discusión entera. */
|
|
63
|
+
export async function deletePost(session, postid) {
|
|
64
|
+
await callWs(session, "mod_forum_delete_post", { postid });
|
|
65
|
+
}
|
|
66
|
+
// --- Tareas ---
|
|
67
|
+
/** Las tareas de un curso con su configuración: fechas y qué tipos de entrega aceptan. */
|
|
68
|
+
export async function getAssignments(session, courseId) {
|
|
69
|
+
const { courses } = await callWs(session, "mod_assign_get_assignments", { "courseids[0]": courseId });
|
|
70
|
+
return courses[0]?.assignments ?? [];
|
|
71
|
+
}
|
|
72
|
+
/** Estado de la entrega del usuario y, si ya la corrigieron, la devolución. */
|
|
73
|
+
export function getSubmissionStatus(session, assignid) {
|
|
74
|
+
return callWs(session, "mod_assign_get_submission_status", { assignid });
|
|
75
|
+
}
|
|
76
|
+
/** Un borrador vacío: el `itemid` que se manda para dejar un área sin archivos. */
|
|
77
|
+
export async function getUnusedDraftItemId(session) {
|
|
78
|
+
const { itemid } = await callWs(session, "core_files_get_unused_draft_itemid");
|
|
79
|
+
return itemid;
|
|
80
|
+
}
|
|
81
|
+
/** Moodle no tira excepción cuando no guarda o no envía: lo dice en `warnings`. */
|
|
82
|
+
function throwOnWarnings(warnings, fallback) {
|
|
83
|
+
if (warnings?.length)
|
|
84
|
+
throw new MoodleError(warnings.map((w) => w.message).join(" ") || fallback);
|
|
85
|
+
}
|
|
86
|
+
/**
|
|
87
|
+
* Guarda la entrega. Cada plugin habilitado reemplaza lo que tenía con lo que se le pasa: el de
|
|
88
|
+
* archivos toma el borrador `filesItemId` entero y el de texto, `text`.
|
|
89
|
+
*/
|
|
90
|
+
export async function saveSubmission(session, assignmentid, data) {
|
|
91
|
+
const params = { assignmentid };
|
|
92
|
+
if (data.filesItemId !== undefined)
|
|
93
|
+
params["plugindata[files_filemanager]"] = data.filesItemId;
|
|
94
|
+
if (data.text) {
|
|
95
|
+
params["plugindata[onlinetext_editor][text]"] = data.text.html;
|
|
96
|
+
params["plugindata[onlinetext_editor][format]"] = data.text.format;
|
|
97
|
+
params["plugindata[onlinetext_editor][itemid]"] = data.text.itemid;
|
|
98
|
+
}
|
|
99
|
+
const warnings = await callWs(session, "mod_assign_save_submission", params);
|
|
100
|
+
throwOnWarnings(warnings, "Moodle no guardó la entrega.");
|
|
101
|
+
}
|
|
102
|
+
/** Confirma el envío de un borrador. Después ya no se puede editar. */
|
|
103
|
+
export async function submitForGrading(session, assignmentid, acceptStatement) {
|
|
104
|
+
const warnings = await callWs(session, "mod_assign_submit_for_grading", {
|
|
105
|
+
assignmentid,
|
|
106
|
+
acceptsubmissionstatement: acceptStatement ? 1 : 0,
|
|
107
|
+
});
|
|
108
|
+
throwOnWarnings(warnings, "Moodle no envió la entrega.");
|
|
109
|
+
}
|
|
110
|
+
/**
|
|
111
|
+
* Vacía la entrega del usuario (o la de su grupo). Es la única forma de dejarla sin nada:
|
|
112
|
+
* mod_assign_save_submission rechaza una entrega vacía.
|
|
113
|
+
*/
|
|
114
|
+
export async function removeSubmission(session, assignid) {
|
|
115
|
+
const { userid } = await getSiteInfo(session);
|
|
116
|
+
const warnings = await callWs(session, "mod_assign_remove_submission", {
|
|
117
|
+
userid,
|
|
118
|
+
assignid,
|
|
119
|
+
});
|
|
120
|
+
throwOnWarnings(warnings, "Moodle no quitó la entrega.");
|
|
121
|
+
}
|
|
122
|
+
// --- Elecciones (choice) y selección de grupos (choicegroup) ---
|
|
123
|
+
export async function getChoices(session, courseId) {
|
|
124
|
+
const { choices } = await callWs(session, "mod_choice_get_choices_by_courses", {
|
|
125
|
+
"courseids[0]": courseId,
|
|
126
|
+
});
|
|
127
|
+
return choices;
|
|
128
|
+
}
|
|
129
|
+
export async function getChoiceOptions(session, choiceid) {
|
|
130
|
+
const { options } = await callWs(session, "mod_choice_get_choice_options", {
|
|
131
|
+
choiceid,
|
|
132
|
+
});
|
|
133
|
+
return options;
|
|
134
|
+
}
|
|
135
|
+
export async function submitChoiceResponse(session, choiceid, optionIds) {
|
|
136
|
+
const params = { choiceid };
|
|
137
|
+
optionIds.forEach((id, i) => (params[`responses[${i}]`] = id));
|
|
138
|
+
await callWs(session, "mod_choice_submit_choice_response", params);
|
|
139
|
+
}
|
|
140
|
+
export async function deleteChoiceResponses(session, choiceid) {
|
|
141
|
+
await callWs(session, "mod_choice_delete_choice_responses", { choiceid });
|
|
142
|
+
}
|
|
143
|
+
export async function getChoicegroupOptions(session, choicegroupid) {
|
|
144
|
+
const { userid } = await getSiteInfo(session);
|
|
145
|
+
const { options } = await callWs(session, "mod_choicegroup_get_choicegroup_options", {
|
|
146
|
+
choicegroupid,
|
|
147
|
+
userid,
|
|
148
|
+
});
|
|
149
|
+
return options;
|
|
150
|
+
}
|
|
151
|
+
/**
|
|
152
|
+
* El plugin lee `responses` si la actividad deja un solo grupo y `choicegroup_<id>: "true"` si deja
|
|
153
|
+
* varios, y la configuración no se puede consultar: mandamos las dos formas y él usa la que le sirve.
|
|
154
|
+
* Con varios grupos, los que no vienen marcados se quitan.
|
|
155
|
+
*/
|
|
156
|
+
export async function submitChoicegroupResponse(session, choicegroupid, optionIds) {
|
|
157
|
+
const data = [
|
|
158
|
+
{ name: "responses", value: String(optionIds[0]) },
|
|
159
|
+
...optionIds.map((id) => ({ name: `choicegroup_${id}`, value: "true" })),
|
|
160
|
+
];
|
|
161
|
+
const params = { choicegroupid };
|
|
162
|
+
data.forEach((d, i) => {
|
|
163
|
+
params[`data[${i}][name]`] = d.name;
|
|
164
|
+
params[`data[${i}][value]`] = d.value;
|
|
165
|
+
});
|
|
166
|
+
await callWs(session, "mod_choicegroup_submit_choicegroup_response", params);
|
|
167
|
+
}
|
|
168
|
+
export async function deleteChoicegroupResponses(session, choicegroupid) {
|
|
169
|
+
await callWs(session, "mod_choicegroup_delete_choicegroup_responses", { choicegroupid });
|
|
170
|
+
}
|
|
171
|
+
// --- Mensajes ---
|
|
172
|
+
/** Nombre de otro usuario, visto desde el usuario logueado, o null si no existe. */
|
|
173
|
+
export async function getMemberName(session, userId) {
|
|
174
|
+
const { userid } = await getSiteInfo(session);
|
|
175
|
+
let members;
|
|
176
|
+
try {
|
|
177
|
+
members = await callWs(session, "core_message_get_member_info", {
|
|
178
|
+
referenceuserid: userid,
|
|
179
|
+
"userids[0]": userId,
|
|
180
|
+
});
|
|
181
|
+
}
|
|
182
|
+
catch (e) {
|
|
183
|
+
// Con un id que no existe, Moodle 5.1 no devuelve vacío: revienta con un error de PHP.
|
|
184
|
+
if (e instanceof MoodleError)
|
|
185
|
+
return null;
|
|
186
|
+
throw e;
|
|
187
|
+
}
|
|
188
|
+
const m = members.find((x) => x.id === userId && !x.isdeleted);
|
|
189
|
+
return m?.fullname ?? null;
|
|
190
|
+
}
|
|
191
|
+
/** Manda un mensaje privado. Moodle no tira excepción si falla: devuelve `msgid: -1` y el motivo. */
|
|
192
|
+
export async function sendInstantMessage(session, touserid, html) {
|
|
193
|
+
const [res] = await callWs(session, "core_message_send_instant_messages", { "messages[0][touserid]": touserid, "messages[0][text]": html, "messages[0][textformat]": 1 });
|
|
194
|
+
if (!res || res.msgid === -1)
|
|
195
|
+
throw new MoodleError(res?.errormessage || "Moodle no aceptó el mensaje.");
|
|
196
|
+
return res.msgid;
|
|
197
|
+
}
|
|
198
|
+
// --- Calendario ---
|
|
199
|
+
/** Crea un evento personal: solo lo ve el usuario. */
|
|
200
|
+
export async function createUserEvent(session, event) {
|
|
201
|
+
const { events } = await callWs(session, "core_calendar_create_calendar_events", {
|
|
202
|
+
"events[0][name]": event.name,
|
|
203
|
+
"events[0][description]": event.description,
|
|
204
|
+
"events[0][format]": 1,
|
|
205
|
+
"events[0][eventtype]": "user",
|
|
206
|
+
"events[0][timestart]": event.timestart,
|
|
207
|
+
"events[0][timeduration]": event.timeduration,
|
|
208
|
+
});
|
|
209
|
+
if (!events[0])
|
|
210
|
+
throw new MoodleError("Moodle no creó el evento.");
|
|
211
|
+
return events[0].id;
|
|
212
|
+
}
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { postForm } from "../../shared/http.js";
|
|
2
2
|
import { AULA_BASE_URL } from "./constants.js";
|
|
3
3
|
const WS_URL = `${AULA_BASE_URL}/webservice/rest/server.php`;
|
|
4
|
+
const UPLOAD_URL = `${AULA_BASE_URL}/webservice/upload.php`;
|
|
4
5
|
const TOKEN_ERRORS = new Set(["invalidtoken", "expiredtoken"]);
|
|
5
6
|
export class MoodleError extends Error {
|
|
6
7
|
errorcode;
|
|
@@ -37,11 +38,11 @@ export async function callWs(session, wsfunction, params = {}) {
|
|
|
37
38
|
export async function fetchFile(session, url, maxBytes) {
|
|
38
39
|
let token = await session.getToken();
|
|
39
40
|
for (let attempt = 0;; attempt++) {
|
|
40
|
-
const
|
|
41
|
-
|
|
41
|
+
const withToken = new URL(url);
|
|
42
|
+
withToken.searchParams.set("token", token);
|
|
42
43
|
let res;
|
|
43
44
|
try {
|
|
44
|
-
res = await fetch(
|
|
45
|
+
res = await fetch(withToken, { signal: AbortSignal.timeout(60_000) });
|
|
45
46
|
}
|
|
46
47
|
catch (e) {
|
|
47
48
|
throw new MoodleError(`No se pudo bajar el archivo: ${e.message}`);
|
|
@@ -57,27 +58,60 @@ export async function fetchFile(session, url, maxBytes) {
|
|
|
57
58
|
}
|
|
58
59
|
if (!res.ok || !res.body)
|
|
59
60
|
throw new MoodleError(`HTTP ${res.status} al bajar el archivo`);
|
|
60
|
-
return { bytes: await
|
|
61
|
+
return { bytes: await readWithLimit(res, maxBytes), contentType };
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
/**
|
|
65
|
+
* Sube archivos al área de borrador del usuario por `webservice/upload.php` y devuelve su `itemid`,
|
|
66
|
+
* que es lo que piden las funciones que guardan archivos. Todos quedan en el mismo borrador.
|
|
67
|
+
*/
|
|
68
|
+
export async function uploadDraftFiles(session, files) {
|
|
69
|
+
let token = await session.getToken();
|
|
70
|
+
for (let attempt = 0;; attempt++) {
|
|
71
|
+
const form = new FormData();
|
|
72
|
+
form.set("token", token);
|
|
73
|
+
form.set("filearea", "draft");
|
|
74
|
+
form.set("itemid", "0");
|
|
75
|
+
files.forEach((f, i) => form.set(`file_${i + 1}`, new Blob([new Uint8Array(f.bytes)]), f.name));
|
|
76
|
+
let res;
|
|
77
|
+
try {
|
|
78
|
+
res = await fetch(UPLOAD_URL, { method: "POST", body: form, signal: AbortSignal.timeout(120_000) });
|
|
79
|
+
}
|
|
80
|
+
catch (e) {
|
|
81
|
+
throw new MoodleError(`No se pudo subir el archivo: ${e.message}`);
|
|
82
|
+
}
|
|
83
|
+
if (!res.ok)
|
|
84
|
+
throw new MoodleError(`HTTP ${res.status} al subir el archivo`);
|
|
85
|
+
// Si sale bien, un registro por archivo; si no, `{ error, errorcode }`.
|
|
86
|
+
const data = (await res.json());
|
|
87
|
+
if (Array.isArray(data) && data[0])
|
|
88
|
+
return data[0].itemid;
|
|
89
|
+
const err = Array.isArray(data) ? {} : data;
|
|
90
|
+
if (attempt === 0 && err.errorcode && TOKEN_ERRORS.has(err.errorcode)) {
|
|
91
|
+
token = await session.refreshToken();
|
|
92
|
+
continue;
|
|
93
|
+
}
|
|
94
|
+
throw new MoodleError(err.error ?? "Moodle no aceptó el archivo.", err.errorcode);
|
|
61
95
|
}
|
|
62
96
|
}
|
|
63
97
|
/** Lee el cuerpo cortando apenas pasa el límite, sin esperar a bajarlo entero. */
|
|
64
|
-
async function
|
|
65
|
-
const
|
|
98
|
+
async function readWithLimit(res, maxBytes) {
|
|
99
|
+
const tooBig = `El archivo pesa más de ${Math.round(maxBytes / 1024 / 1024)} MB; es demasiado para leerlo.`;
|
|
66
100
|
if (Number(res.headers.get("content-length")) > maxBytes) {
|
|
67
101
|
await res.body?.cancel();
|
|
68
|
-
throw new MoodleError(
|
|
102
|
+
throw new MoodleError(tooBig);
|
|
69
103
|
}
|
|
70
|
-
const
|
|
104
|
+
const chunks = [];
|
|
71
105
|
let total = 0;
|
|
72
|
-
for await (const
|
|
73
|
-
total +=
|
|
106
|
+
for await (const chunk of res.body) {
|
|
107
|
+
total += chunk.length;
|
|
74
108
|
if (total > maxBytes)
|
|
75
|
-
throw new MoodleError(
|
|
76
|
-
|
|
109
|
+
throw new MoodleError(tooBig);
|
|
110
|
+
chunks.push(chunk);
|
|
77
111
|
}
|
|
78
112
|
const bytes = new Uint8Array(total);
|
|
79
113
|
let i = 0;
|
|
80
|
-
for (const p of
|
|
114
|
+
for (const p of chunks) {
|
|
81
115
|
bytes.set(p, i);
|
|
82
116
|
i += p.length;
|
|
83
117
|
}
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
import { CookieJar } from "tough-cookie";
|
|
2
|
+
import { loadCredentials } from "./credentials.js";
|
|
3
|
+
import { fetchToken, LoginRequiredError } from "./sso-login.js";
|
|
4
|
+
/** Con qué nombre guarda el aula su sesión en el store. */
|
|
5
|
+
export const AULA_PLATFORM = "aula";
|
|
6
|
+
/**
|
|
7
|
+
* Sesión del modo HTTP: la de un usuario del store. Renueva el token con las cookies del SSO y, si
|
|
8
|
+
* ya no alcanzan, con la contraseña del keychain cuando es de este mismo usuario: eso pasa solo en
|
|
9
|
+
* local, porque el servidor hosteado no guarda contraseñas.
|
|
10
|
+
*/
|
|
11
|
+
export class StoredAulaSession {
|
|
12
|
+
store;
|
|
13
|
+
userId;
|
|
14
|
+
refreshing = null;
|
|
15
|
+
constructor(store, userId) {
|
|
16
|
+
this.store = store;
|
|
17
|
+
this.userId = userId;
|
|
18
|
+
}
|
|
19
|
+
async getToken() {
|
|
20
|
+
return (await this.read()).token;
|
|
21
|
+
}
|
|
22
|
+
refreshToken() {
|
|
23
|
+
this.refreshing ??= this.renew().finally(() => {
|
|
24
|
+
this.refreshing = null;
|
|
25
|
+
});
|
|
26
|
+
return this.refreshing;
|
|
27
|
+
}
|
|
28
|
+
async renew() {
|
|
29
|
+
const stored = await this.read();
|
|
30
|
+
const jar = await CookieJar.deserialize(stored.cookies);
|
|
31
|
+
// Las del keychain (o UTN_USER/UTN_PASS) son de una sola persona: con otro usuario
|
|
32
|
+
// entraríamos al aula de ella y guardaríamos su token en la sesión de este.
|
|
33
|
+
const credentials = await loadCredentials();
|
|
34
|
+
const own = credentials?.username.trim().toLowerCase() === stored.username ? credentials : null;
|
|
35
|
+
let token;
|
|
36
|
+
try {
|
|
37
|
+
token = await fetchToken(jar, own);
|
|
38
|
+
}
|
|
39
|
+
catch (e) {
|
|
40
|
+
if (e instanceof LoginRequiredError) {
|
|
41
|
+
throw new LoginRequiredError("La sesión del aula venció: volvé a iniciar sesión (`utn-mcp login --http`).");
|
|
42
|
+
}
|
|
43
|
+
throw e;
|
|
44
|
+
}
|
|
45
|
+
const data = { ...stored, token, cookies: await jar.serialize() };
|
|
46
|
+
await this.store.saveSession(this.userId, AULA_PLATFORM, JSON.stringify(data));
|
|
47
|
+
return token;
|
|
48
|
+
}
|
|
49
|
+
async read() {
|
|
50
|
+
const raw = await this.store.loadSession(this.userId, AULA_PLATFORM);
|
|
51
|
+
if (!raw)
|
|
52
|
+
throw new LoginRequiredError("No hay sesión del aula para este usuario: corré `utn-mcp login --http`.");
|
|
53
|
+
return JSON.parse(raw);
|
|
54
|
+
}
|
|
55
|
+
}
|
|
@@ -0,0 +1,159 @@
|
|
|
1
|
+
import { textToHtml } from "../../shared/html.js";
|
|
2
|
+
import { addDiscussion, addDiscussionPost, createUserEvent, deleteChoiceResponses, deleteChoicegroupResponses, deletePost, getChoiceOptions, getChoicegroupOptions, getDiscussionPosts, getMemberName, sendInstantMessage, submitChoiceResponse, submitChoicegroupResponse, updateDiscussionPost, } from "../client/client.js";
|
|
3
|
+
import { AULA_BASE_URL } from "../client/constants.js";
|
|
4
|
+
import { toOption } from "./activities.js";
|
|
5
|
+
import { discussionAddr, formatAddress, parseAddress } from "./address.js";
|
|
6
|
+
import { isoDate } from "./fields.js";
|
|
7
|
+
import { findModule } from "./navigate.js";
|
|
8
|
+
// --- Foros ---
|
|
9
|
+
/**
|
|
10
|
+
* Con la dirección de un foro abre un hilo; con la de una discusión responde. Así el modelo no elige
|
|
11
|
+
* entre dos operaciones: la dirección que ya tiene dice cuál es.
|
|
12
|
+
*/
|
|
13
|
+
export async function postToForum(session, rawAddress, message, subject, replyTo) {
|
|
14
|
+
const address = parseAddress(rawAddress);
|
|
15
|
+
if (address.level === "module") {
|
|
16
|
+
const { module } = await findModule(session, address.courseId, address.cmid);
|
|
17
|
+
if (module.modname !== "forum") {
|
|
18
|
+
throw new Error(`"${rawAddress}" no es un foro, es un módulo de tipo ${module.modname}.`);
|
|
19
|
+
}
|
|
20
|
+
if (!subject?.trim())
|
|
21
|
+
throw new Error("Para abrir un hilo hace falta un `asunto`.");
|
|
22
|
+
const discussionId = await addDiscussion(session, module.instance, subject.trim(), textToHtml(message));
|
|
23
|
+
return { publicado: "hilo nuevo", direccion: discussionAddr(address.courseId, address.cmid, discussionId) };
|
|
24
|
+
}
|
|
25
|
+
if (address.level === "discussion") {
|
|
26
|
+
const { posts } = await getDiscussionPosts(session, address.discussionId);
|
|
27
|
+
const parent = replyTo
|
|
28
|
+
? findPost(posts, replyTo, rawAddress)
|
|
29
|
+
: posts.find((p) => p.parentid === null && !p.isdeleted);
|
|
30
|
+
if (!parent)
|
|
31
|
+
throw new Error(`La discusión ${rawAddress} no tiene publicaciones a las que responder.`);
|
|
32
|
+
if (!parent.capabilities.reply) {
|
|
33
|
+
throw new Error("No podés responder en esta discusión: está cerrada o el foro no deja responder.");
|
|
34
|
+
}
|
|
35
|
+
const postId = await addDiscussionPost(session, parent.id, subject?.trim() || parent.replysubject, textToHtml(message));
|
|
36
|
+
return { publicado: "respuesta", direccion: formatAddress(address), id: postId, respuestaA: parent.id };
|
|
37
|
+
}
|
|
38
|
+
throw new Error(`Para publicar hace falta la dirección de un foro (/curso/{id}/mod/{cmid}) o de una discusión ` +
|
|
39
|
+
`(/curso/{id}/mod/{cmid}/disc/{id}); "${rawAddress}" no es ninguna de las dos.`);
|
|
40
|
+
}
|
|
41
|
+
export async function editForumPost(session, rawAddress, postId, message, subject) {
|
|
42
|
+
const { address, post } = await findPostAt(session, rawAddress, postId);
|
|
43
|
+
if (!post.capabilities.edit) {
|
|
44
|
+
throw new Error("No podés editar esta publicación: no es tuya o ya pasó el tiempo para editarla.");
|
|
45
|
+
}
|
|
46
|
+
// Asunto vacío: Moodle deja el que tenía.
|
|
47
|
+
await updateDiscussionPost(session, postId, subject?.trim() ?? "", textToHtml(message));
|
|
48
|
+
return { editado: true, direccion: formatAddress(address), id: postId };
|
|
49
|
+
}
|
|
50
|
+
export async function deleteForumPost(session, rawAddress, postId) {
|
|
51
|
+
const { address, post } = await findPostAt(session, rawAddress, postId);
|
|
52
|
+
if (!post.capabilities.delete) {
|
|
53
|
+
throw new Error("No podés borrar esta publicación: no es tuya, tiene respuestas o ya pasó el tiempo.");
|
|
54
|
+
}
|
|
55
|
+
await deletePost(session, postId);
|
|
56
|
+
const isFirst = post.parentid === null;
|
|
57
|
+
return {
|
|
58
|
+
borrado: true,
|
|
59
|
+
id: postId,
|
|
60
|
+
// Borrar la primera publicación se lleva la discusión entera.
|
|
61
|
+
discusionBorrada: isFirst || undefined,
|
|
62
|
+
direccion: isFirst ? undefined : formatAddress(address),
|
|
63
|
+
};
|
|
64
|
+
}
|
|
65
|
+
async function findPostAt(session, rawAddress, postId) {
|
|
66
|
+
const address = parseAddress(rawAddress);
|
|
67
|
+
if (address.level !== "discussion") {
|
|
68
|
+
throw new Error(`Hace falta la dirección de la discusión donde está la publicación; "${rawAddress}" no lo es.`);
|
|
69
|
+
}
|
|
70
|
+
const { posts } = await getDiscussionPosts(session, address.discussionId);
|
|
71
|
+
return { address, post: findPost(posts, postId, rawAddress) };
|
|
72
|
+
}
|
|
73
|
+
function findPost(posts, postId, rawAddress) {
|
|
74
|
+
const post = posts.find((p) => p.id === postId && !p.isdeleted);
|
|
75
|
+
if (!post) {
|
|
76
|
+
throw new Error(`La publicación ${postId} no está en ${rawAddress}. Los ids salen de abrir la discusión con aula_open.`);
|
|
77
|
+
}
|
|
78
|
+
return post;
|
|
79
|
+
}
|
|
80
|
+
// --- Elecciones y selección de grupos ---
|
|
81
|
+
/** Deja elegidas exactamente `optionIds`; vacío borra la respuesta. Devuelve cómo quedó. */
|
|
82
|
+
export async function answerChoice(session, rawAddress, optionIds) {
|
|
83
|
+
const address = parseAddress(rawAddress);
|
|
84
|
+
if (address.level !== "module")
|
|
85
|
+
throw new Error(`"${rawAddress}" no es la dirección de un módulo.`);
|
|
86
|
+
const { module } = await findModule(session, address.courseId, address.cmid);
|
|
87
|
+
const api = CHOICE_APIS[module.modname];
|
|
88
|
+
if (!api) {
|
|
89
|
+
throw new Error(`"${rawAddress}" es un módulo de tipo ${module.modname}; solo se puede elegir en choice y choicegroup.`);
|
|
90
|
+
}
|
|
91
|
+
const current = await api.options(session, module.instance);
|
|
92
|
+
for (const id of optionIds) {
|
|
93
|
+
const option = current.find((o) => o.id === id);
|
|
94
|
+
if (!option) {
|
|
95
|
+
throw new Error(`No existe la opción ${id}. Las válidas son: ${current.map((o) => o.id).join(", ")}.`);
|
|
96
|
+
}
|
|
97
|
+
if (option.disabled && !option.checked) {
|
|
98
|
+
throw new Error(`La opción ${id} (${option.text ?? option.name}) está completa o deshabilitada.`);
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
if (optionIds.length)
|
|
102
|
+
await api.submit(session, module.instance, optionIds);
|
|
103
|
+
else
|
|
104
|
+
await api.clear(session, module.instance);
|
|
105
|
+
const after = await api.options(session, module.instance);
|
|
106
|
+
const chosen = after.filter((o) => o.checked).map((o) => o.id);
|
|
107
|
+
const matches = chosen.length === optionIds.length && optionIds.every((id) => chosen.includes(id));
|
|
108
|
+
return {
|
|
109
|
+
elegidas: chosen,
|
|
110
|
+
// Pasa cuando la actividad deja una sola opción y se pidieron varias.
|
|
111
|
+
aviso: matches ? undefined : "Moodle no dejó exactamente las opciones pedidas; `elegidas` es lo que quedó.",
|
|
112
|
+
opciones: after.map(toOption),
|
|
113
|
+
};
|
|
114
|
+
}
|
|
115
|
+
const CHOICE_APIS = {
|
|
116
|
+
choice: { options: getChoiceOptions, submit: submitChoiceResponse, clear: deleteChoiceResponses },
|
|
117
|
+
choicegroup: {
|
|
118
|
+
options: getChoicegroupOptions,
|
|
119
|
+
submit: submitChoicegroupResponse,
|
|
120
|
+
clear: deleteChoicegroupResponses,
|
|
121
|
+
},
|
|
122
|
+
};
|
|
123
|
+
// --- Mensajes ---
|
|
124
|
+
export async function sendMessage(session, userId, message) {
|
|
125
|
+
// Se busca antes el nombre: un id equivocado le mandaría el mensaje a otra persona sin que nadie lo note.
|
|
126
|
+
const name = await getMemberName(session, userId);
|
|
127
|
+
if (!name) {
|
|
128
|
+
throw new Error(`No existe el usuario ${userId}. El id sale del \`autorId\` de un foro abierto con aula_open.`);
|
|
129
|
+
}
|
|
130
|
+
await sendInstantMessage(session, userId, textToHtml(message));
|
|
131
|
+
return { enviado: true, para: name };
|
|
132
|
+
}
|
|
133
|
+
// --- Calendario ---
|
|
134
|
+
/** Exige la zona horaria: el servidor hosteado no está en la hora de Argentina y adivinarla corre el evento. */
|
|
135
|
+
const HAS_TIMEZONE = /(Z|[+-]\d{2}:?\d{2})$/i;
|
|
136
|
+
export async function addCalendarEvent(session, event) {
|
|
137
|
+
const start = new Date(event.start);
|
|
138
|
+
if (!HAS_TIMEZONE.test(event.start.trim()) || Number.isNaN(start.getTime())) {
|
|
139
|
+
throw new Error(`Fecha inválida: "${event.start}". Usá ISO 8601 con zona horaria, por ejemplo "2026-10-14T18:00:00-03:00".`);
|
|
140
|
+
}
|
|
141
|
+
const timestart = Math.floor(start.getTime() / 1000);
|
|
142
|
+
const timeduration = Math.round((event.durationMinutes ?? 0) * 60);
|
|
143
|
+
const name = event.name.trim();
|
|
144
|
+
const id = await createUserEvent(session, {
|
|
145
|
+
name,
|
|
146
|
+
description: event.description ? textToHtml(event.description) : "",
|
|
147
|
+
timestart,
|
|
148
|
+
timeduration,
|
|
149
|
+
});
|
|
150
|
+
return {
|
|
151
|
+
creado: true,
|
|
152
|
+
id,
|
|
153
|
+
nombre: name,
|
|
154
|
+
inicio: isoDate(timestart),
|
|
155
|
+
fin: timeduration ? isoDate(timestart + timeduration) : undefined,
|
|
156
|
+
// Para que el usuario lo vea en el calendario del aula.
|
|
157
|
+
url: `${AULA_BASE_URL}/calendar/view.php?view=day&time=${timestart}`,
|
|
158
|
+
};
|
|
159
|
+
}
|
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
import { htmlToText } from "../../shared/html.js";
|
|
2
|
+
import { getAssignments, getChoiceOptions, getChoicegroupOptions, getChoices, getSubmissionStatus, } from "../client/client.js";
|
|
3
|
+
import { isoDate, toFile } from "./fields.js";
|
|
4
|
+
const SUBMISSION_STATUS_LABELS = {
|
|
5
|
+
new: "sin entregar",
|
|
6
|
+
draft: "borrador",
|
|
7
|
+
submitted: "enviada",
|
|
8
|
+
reopened: "reabierta",
|
|
9
|
+
};
|
|
10
|
+
/** La entrega del usuario (o la de su grupo): qué acepta la tarea, qué subió y la devolución si la hay. */
|
|
11
|
+
export async function viewSubmission(session, courseId, module) {
|
|
12
|
+
const [assignments, status] = await Promise.all([
|
|
13
|
+
getAssignments(session, courseId),
|
|
14
|
+
getSubmissionStatus(session, module.instance),
|
|
15
|
+
]);
|
|
16
|
+
const assignment = assignments.find((a) => a.cmid === module.id);
|
|
17
|
+
const attempt = status.lastattempt;
|
|
18
|
+
// En las entregas grupales la individual queda vacía; la que cuenta es la del grupo.
|
|
19
|
+
const submission = attempt?.teamsubmission ?? attempt?.submission;
|
|
20
|
+
const config = (plugin, name) => assignment?.configs.find((c) => c.subtype === "assignsubmission" && c.plugin === plugin && c.name === name)
|
|
21
|
+
?.value;
|
|
22
|
+
const accepts = [];
|
|
23
|
+
if (config("file", "enabled") === "1") {
|
|
24
|
+
accepts.push({
|
|
25
|
+
tipo: "archivos",
|
|
26
|
+
maximo: Number(config("file", "maxfilesubmissions")) || undefined,
|
|
27
|
+
tamanioMaximo: Number(config("file", "maxsubmissionsizebytes")) || undefined,
|
|
28
|
+
extensiones: config("file", "filetypeslist") || undefined,
|
|
29
|
+
});
|
|
30
|
+
}
|
|
31
|
+
if (config("onlinetext", "enabled") === "1") {
|
|
32
|
+
accepts.push({ tipo: "texto en línea", limitePalabras: Number(config("onlinetext", "wordlimit")) || undefined });
|
|
33
|
+
}
|
|
34
|
+
const feedback = status.feedback;
|
|
35
|
+
return {
|
|
36
|
+
estado: submission ? (SUBMISSION_STATUS_LABELS[submission.status] ?? submission.status) : "sin entregar",
|
|
37
|
+
grupal: assignment?.teamsubmission === 1 || undefined,
|
|
38
|
+
// Moodle crea la entrega vacía al abrir la tarea: su fecha no dice nada hasta que hay algo subido.
|
|
39
|
+
modificada: (submission?.status !== "new" && isoDate(submission?.timemodified)) || undefined,
|
|
40
|
+
abre: isoDate(assignment?.allowsubmissionsfromdate) || undefined,
|
|
41
|
+
vence: isoDate(assignment?.duedate) || undefined,
|
|
42
|
+
prorroga: isoDate(attempt?.extensionduedate) || undefined,
|
|
43
|
+
cierreDefinitivo: isoDate(assignment?.cutoffdate) || undefined,
|
|
44
|
+
acepta: accepts.length ? accepts : undefined,
|
|
45
|
+
// Con borradores, lo subido no cuenta hasta confirmar el envío.
|
|
46
|
+
requiereConfirmarEnvio: assignment?.submissiondrafts === 1 || undefined,
|
|
47
|
+
requiereDeclaracion: assignment?.requiresubmissionstatement === 1 || undefined,
|
|
48
|
+
declaracion: (assignment?.requiresubmissionstatement === 1 && htmlToText(assignment.submissionstatement)) || undefined,
|
|
49
|
+
puedeEditar: attempt?.canedit ?? false,
|
|
50
|
+
puedeEnviar: attempt?.cansubmit || undefined,
|
|
51
|
+
bloqueada: attempt?.locked || undefined,
|
|
52
|
+
...pluginContent(submission?.plugins),
|
|
53
|
+
devolucion: feedback
|
|
54
|
+
? {
|
|
55
|
+
calificacion: feedback.gradefordisplay || undefined,
|
|
56
|
+
fecha: isoDate(feedback.gradeddate),
|
|
57
|
+
...pluginContent(feedback.plugins),
|
|
58
|
+
}
|
|
59
|
+
: undefined,
|
|
60
|
+
};
|
|
61
|
+
}
|
|
62
|
+
/** Los archivos y textos de una entrega o devolución, sin los plugins vacíos. */
|
|
63
|
+
function pluginContent(plugins = []) {
|
|
64
|
+
const files = plugins.flatMap((p) => p.fileareas?.flatMap((a) => a.files ?? []) ?? []);
|
|
65
|
+
const texts = plugins
|
|
66
|
+
.flatMap((p) => p.editorfields ?? [])
|
|
67
|
+
.map((e) => htmlToText(e.text))
|
|
68
|
+
.filter(Boolean);
|
|
69
|
+
return {
|
|
70
|
+
archivos: files.length ? files.map(toFile) : undefined,
|
|
71
|
+
texto: texts.length ? texts.join("\n\n") : undefined,
|
|
72
|
+
};
|
|
73
|
+
}
|
|
74
|
+
export async function viewChoice(session, courseId, module) {
|
|
75
|
+
const [choices, options] = await Promise.all([
|
|
76
|
+
getChoices(session, courseId),
|
|
77
|
+
getChoiceOptions(session, module.instance),
|
|
78
|
+
]);
|
|
79
|
+
const choice = choices.find((c) => c.coursemodule === module.id);
|
|
80
|
+
return {
|
|
81
|
+
multiple: choice?.allowmultiple || undefined,
|
|
82
|
+
puedeCambiar: choice?.allowupdate,
|
|
83
|
+
abre: isoDate(choice?.timeopen) || undefined,
|
|
84
|
+
cierra: isoDate(choice?.timeclose) || undefined,
|
|
85
|
+
opciones: options.map(toOption),
|
|
86
|
+
};
|
|
87
|
+
}
|
|
88
|
+
/** El plugin no expone si deja cambiar o elegir varios grupos: eso lo dice la descripción o el error al elegir. */
|
|
89
|
+
export async function viewChoicegroup(session, module) {
|
|
90
|
+
const options = await getChoicegroupOptions(session, module.instance);
|
|
91
|
+
return { opciones: options.map(toOption) };
|
|
92
|
+
}
|
|
93
|
+
export function toOption(o) {
|
|
94
|
+
return {
|
|
95
|
+
id: o.id,
|
|
96
|
+
texto: o.text ?? o.name,
|
|
97
|
+
elegida: o.checked || undefined,
|
|
98
|
+
deshabilitada: o.disabled || undefined,
|
|
99
|
+
cupo: o.maxanswers || undefined,
|
|
100
|
+
anotados: o.countanswers,
|
|
101
|
+
};
|
|
102
|
+
}
|