utn-mcp 0.2.0 → 0.6.7
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 +146 -91
- package/build/aula-virtual/client/client.js +135 -3
- package/build/aula-virtual/client/moodle-api.js +13 -13
- package/build/aula-virtual/format/actions.js +159 -0
- package/build/aula-virtual/format/activities.js +101 -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/tools.js +190 -12
- package/build/server.js +1 -1
- 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/package.json +2 -2
- 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
|
@@ -0,0 +1,101 @@
|
|
|
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
|
+
puedeEditar: attempt?.canedit ?? false,
|
|
49
|
+
puedeEnviar: attempt?.cansubmit || undefined,
|
|
50
|
+
bloqueada: attempt?.locked || undefined,
|
|
51
|
+
...pluginContent(submission?.plugins),
|
|
52
|
+
devolucion: feedback
|
|
53
|
+
? {
|
|
54
|
+
calificacion: feedback.gradefordisplay || undefined,
|
|
55
|
+
fecha: isoDate(feedback.gradeddate),
|
|
56
|
+
...pluginContent(feedback.plugins),
|
|
57
|
+
}
|
|
58
|
+
: undefined,
|
|
59
|
+
};
|
|
60
|
+
}
|
|
61
|
+
/** Los archivos y textos de una entrega o devolución, sin los plugins vacíos. */
|
|
62
|
+
function pluginContent(plugins = []) {
|
|
63
|
+
const files = plugins.flatMap((p) => p.fileareas?.flatMap((a) => a.files ?? []) ?? []);
|
|
64
|
+
const texts = plugins
|
|
65
|
+
.flatMap((p) => p.editorfields ?? [])
|
|
66
|
+
.map((e) => htmlToText(e.text))
|
|
67
|
+
.filter(Boolean);
|
|
68
|
+
return {
|
|
69
|
+
archivos: files.length ? files.map(toFile) : undefined,
|
|
70
|
+
texto: texts.length ? texts.join("\n\n") : undefined,
|
|
71
|
+
};
|
|
72
|
+
}
|
|
73
|
+
export async function viewChoice(session, courseId, module) {
|
|
74
|
+
const [choices, options] = await Promise.all([
|
|
75
|
+
getChoices(session, courseId),
|
|
76
|
+
getChoiceOptions(session, module.instance),
|
|
77
|
+
]);
|
|
78
|
+
const choice = choices.find((c) => c.coursemodule === module.id);
|
|
79
|
+
return {
|
|
80
|
+
multiple: choice?.allowmultiple || undefined,
|
|
81
|
+
puedeCambiar: choice?.allowupdate,
|
|
82
|
+
abre: isoDate(choice?.timeopen) || undefined,
|
|
83
|
+
cierra: isoDate(choice?.timeclose) || undefined,
|
|
84
|
+
opciones: options.map(toOption),
|
|
85
|
+
};
|
|
86
|
+
}
|
|
87
|
+
/** El plugin no expone si deja cambiar o elegir varios grupos: eso lo dice la descripción o el error al elegir. */
|
|
88
|
+
export async function viewChoicegroup(session, module) {
|
|
89
|
+
const options = await getChoicegroupOptions(session, module.instance);
|
|
90
|
+
return { opciones: options.map(toOption) };
|
|
91
|
+
}
|
|
92
|
+
export function toOption(o) {
|
|
93
|
+
return {
|
|
94
|
+
id: o.id,
|
|
95
|
+
texto: o.text ?? o.name,
|
|
96
|
+
elegida: o.checked || undefined,
|
|
97
|
+
deshabilitada: o.disabled || undefined,
|
|
98
|
+
cupo: o.maxanswers || undefined,
|
|
99
|
+
anotados: o.countanswers,
|
|
100
|
+
};
|
|
101
|
+
}
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
export const ROOT = "/";
|
|
2
|
+
export const courseAddr = (courseId) => `/curso/${courseId}`;
|
|
3
|
+
export const moduleAddr = (courseId, cmid) => `/curso/${courseId}/mod/${cmid}`;
|
|
4
|
+
export const discussionAddr = (courseId, cmid, discussionId) => `/curso/${courseId}/mod/${cmid}/disc/${discussionId}`;
|
|
5
|
+
const FORMATS = [
|
|
6
|
+
"/",
|
|
7
|
+
"/curso/{courseId}",
|
|
8
|
+
"/curso/{courseId}/mod/{cmid}",
|
|
9
|
+
"/curso/{courseId}/mod/{cmid}/disc/{discussionId}",
|
|
10
|
+
].join(", ");
|
|
11
|
+
const PATTERN = /^\/curso\/(\d+)(?:\/mod\/(\d+)(?:\/disc\/(\d+))?)?$/;
|
|
12
|
+
export function parseAddress(raw) {
|
|
13
|
+
const path = raw.trim().replace(/\/+$/, "") || "/";
|
|
14
|
+
if (path === "/")
|
|
15
|
+
return { level: "root" };
|
|
16
|
+
const m = PATTERN.exec(path);
|
|
17
|
+
if (!m)
|
|
18
|
+
throw new Error(`Dirección inválida: "${raw}". Formatos válidos: ${FORMATS}`);
|
|
19
|
+
const [, course, cmid, disc] = m;
|
|
20
|
+
const courseId = Number(course);
|
|
21
|
+
if (!cmid)
|
|
22
|
+
return { level: "course", courseId };
|
|
23
|
+
if (!disc)
|
|
24
|
+
return { level: "module", courseId, cmid: Number(cmid) };
|
|
25
|
+
return { level: "discussion", courseId, cmid: Number(cmid), discussionId: Number(disc) };
|
|
26
|
+
}
|
|
27
|
+
/** La dirección en texto, tal como la ve el modelo. */
|
|
28
|
+
export function formatAddress(address) {
|
|
29
|
+
switch (address.level) {
|
|
30
|
+
case "root":
|
|
31
|
+
return ROOT;
|
|
32
|
+
case "course":
|
|
33
|
+
return courseAddr(address.courseId);
|
|
34
|
+
case "module":
|
|
35
|
+
return moduleAddr(address.courseId, address.cmid);
|
|
36
|
+
case "discussion":
|
|
37
|
+
return discussionAddr(address.courseId, address.cmid, address.discussionId);
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
/** La dirección de un nivel más arriba, o null si ya estamos en la raíz. */
|
|
41
|
+
export function parentOf(address) {
|
|
42
|
+
switch (address.level) {
|
|
43
|
+
case "root":
|
|
44
|
+
return null;
|
|
45
|
+
case "course":
|
|
46
|
+
return ROOT;
|
|
47
|
+
case "module":
|
|
48
|
+
return courseAddr(address.courseId);
|
|
49
|
+
case "discussion":
|
|
50
|
+
return moduleAddr(address.courseId, address.cmid);
|
|
51
|
+
}
|
|
52
|
+
}
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
import { userFacingUrl } from "./files.js";
|
|
2
|
+
/** Los timestamps de Moodle (segundos) en ISO; 0 o vacío es "sin fecha". */
|
|
3
|
+
export const isoDate = (seconds) => seconds ? new Date(seconds * 1000).toISOString() : null;
|
|
4
|
+
export function toFile(f) {
|
|
5
|
+
return {
|
|
6
|
+
nombre: f.filename,
|
|
7
|
+
ruta: f.filepath || undefined,
|
|
8
|
+
tamanio: f.filesize,
|
|
9
|
+
mimetype: f.mimetype,
|
|
10
|
+
modificado: isoDate(f.timemodified),
|
|
11
|
+
// Sirve para pasársela al usuario y también para leerlo con aula_read_file.
|
|
12
|
+
url: userFacingUrl(f.fileurl),
|
|
13
|
+
};
|
|
14
|
+
}
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
import { openDocument, UnsupportedFormat } from "../../shared/documents/index.js";
|
|
2
|
+
import { AULA_BASE_URL } from "../client/constants.js";
|
|
3
|
+
import { fetchFile } from "../client/moodle-api.js";
|
|
4
|
+
const ORIGIN = new URL(AULA_BASE_URL).origin;
|
|
5
|
+
const WS_PATH = "/webservice/pluginfile.php/";
|
|
6
|
+
const WEB_PATH = "/pluginfile.php/";
|
|
7
|
+
/** Cuánto se devuelve por llamada, en caracteres de texto. */
|
|
8
|
+
const BUDGET_CHARS = 30_000;
|
|
9
|
+
/** Lo que cuesta una página como imagen (~1.600 tokens), medido en caracteres de texto. */
|
|
10
|
+
const IMAGE_COST_CHARS = 6_000;
|
|
11
|
+
/** El más grande que hay hoy en los cursos pesa 12 MB. */
|
|
12
|
+
const MAX_BYTES = 30 * 1024 * 1024;
|
|
13
|
+
/**
|
|
14
|
+
* Moodle devuelve las URLs del web service, que piden token. Al usuario le sirve la misma ruta sin
|
|
15
|
+
* `/webservice`, que se abre con la sesión de su navegador. Es la única que mostramos: el token no
|
|
16
|
+
* sale nunca del servidor.
|
|
17
|
+
*/
|
|
18
|
+
export function userFacingUrl(fileurl) {
|
|
19
|
+
return fileurl.replace(`${ORIGIN}${WS_PATH}`, `${ORIGIN}${WEB_PATH}`);
|
|
20
|
+
}
|
|
21
|
+
/**
|
|
22
|
+
* Acepta cualquiera de las dos formas y devuelve la del web service. Solo archivos del aula: con
|
|
23
|
+
* cualquier otra URL estaríamos mandando el token del usuario a donde diga el modelo.
|
|
24
|
+
*/
|
|
25
|
+
function downloadUrl(url) {
|
|
26
|
+
let u;
|
|
27
|
+
try {
|
|
28
|
+
u = new URL(url);
|
|
29
|
+
}
|
|
30
|
+
catch {
|
|
31
|
+
throw new Error(`URL inválida: "${url}". Usá la \`url\` de un archivo tal como la devuelve aula_open.`);
|
|
32
|
+
}
|
|
33
|
+
const path = u.pathname.startsWith(WS_PATH) ? u.pathname : u.pathname.startsWith(WEB_PATH) ? WS_PATH + u.pathname.slice(WEB_PATH.length) : null;
|
|
34
|
+
if (u.origin !== ORIGIN || !path) {
|
|
35
|
+
throw new Error(`Solo se pueden leer archivos del aula virtual (${ORIGIN}${WEB_PATH}...).`);
|
|
36
|
+
}
|
|
37
|
+
u.pathname = path;
|
|
38
|
+
u.searchParams.delete("token");
|
|
39
|
+
u.searchParams.delete("wstoken");
|
|
40
|
+
return u;
|
|
41
|
+
}
|
|
42
|
+
/** Baja un archivo del aula y devuelve desde la parte `from` tantas como entren en el presupuesto. */
|
|
43
|
+
export async function readAulaFile(session, url, from = 1) {
|
|
44
|
+
const download = downloadUrl(url);
|
|
45
|
+
const fileName = decodeURIComponent(download.pathname.split("/").pop() ?? "");
|
|
46
|
+
const forUser = userFacingUrl(download.href);
|
|
47
|
+
const { bytes } = await fetchFile(session, download, MAX_BYTES);
|
|
48
|
+
let doc;
|
|
49
|
+
try {
|
|
50
|
+
doc = await openDocument(bytes, fileName);
|
|
51
|
+
}
|
|
52
|
+
catch (e) {
|
|
53
|
+
if (e instanceof UnsupportedFormat) {
|
|
54
|
+
throw new Error(`${e.message} El usuario lo puede bajar desde ${forUser}`);
|
|
55
|
+
}
|
|
56
|
+
throw e;
|
|
57
|
+
}
|
|
58
|
+
try {
|
|
59
|
+
if (from > doc.total) {
|
|
60
|
+
throw new Error(`"${fileName}" termina en ${doc.unit} ${doc.total}; se pidió desde ${from}.`);
|
|
61
|
+
}
|
|
62
|
+
const parts = [];
|
|
63
|
+
let spent = 0;
|
|
64
|
+
let n = from;
|
|
65
|
+
for (; n <= doc.total; n++) {
|
|
66
|
+
const part = await doc.part(n);
|
|
67
|
+
const cost = "text" in part ? part.text.length : IMAGE_COST_CHARS;
|
|
68
|
+
// Siempre al menos una parte, aunque sola se pase del presupuesto.
|
|
69
|
+
if (parts.length > 0 && spent + cost > BUDGET_CHARS)
|
|
70
|
+
break;
|
|
71
|
+
parts.push({ ...part, number: n });
|
|
72
|
+
spent += cost;
|
|
73
|
+
}
|
|
74
|
+
return {
|
|
75
|
+
archivo: fileName,
|
|
76
|
+
formato: doc.format,
|
|
77
|
+
unidad: doc.unit,
|
|
78
|
+
total: doc.total,
|
|
79
|
+
desde: from,
|
|
80
|
+
hasta: n - 1,
|
|
81
|
+
siguiente: n <= doc.total ? n : undefined,
|
|
82
|
+
urlParaUsuario: forUser,
|
|
83
|
+
parts,
|
|
84
|
+
};
|
|
85
|
+
}
|
|
86
|
+
finally {
|
|
87
|
+
await doc.close();
|
|
88
|
+
}
|
|
89
|
+
}
|
|
@@ -0,0 +1,143 @@
|
|
|
1
|
+
import { htmlToSnippet, htmlToText } from "../../shared/html.js";
|
|
2
|
+
import { courseAddr, discussionAddr, formatAddress, moduleAddr, parentOf, parseAddress, } from "./address.js";
|
|
3
|
+
import { canAddDiscussion, getCourseSections, getDiscussionPosts, getForumDiscussions, getUserCourses, PAGE_SIZE, } from "../client/client.js";
|
|
4
|
+
import { viewChoice, viewChoicegroup, viewSubmission } from "./activities.js";
|
|
5
|
+
import { isoDate, toFile } from "./fields.js";
|
|
6
|
+
/**
|
|
7
|
+
* Entra a una dirección del aula y devuelve su contenido, el padre y las direcciones de lo que hay
|
|
8
|
+
* adentro. Es el único lugar que decide qué ve el modelo y cuánto texto le cuesta.
|
|
9
|
+
*/
|
|
10
|
+
export async function open(session, rawAddress, page = 0) {
|
|
11
|
+
const address = parseAddress(rawAddress);
|
|
12
|
+
const base = { direccion: formatAddress(address), padre: parentOf(address) };
|
|
13
|
+
switch (address.level) {
|
|
14
|
+
case "root":
|
|
15
|
+
return { ...base, cursos: await viewCourses(session) };
|
|
16
|
+
case "course":
|
|
17
|
+
return { ...base, secciones: await viewCourse(session, address.courseId) };
|
|
18
|
+
case "module":
|
|
19
|
+
return { ...base, ...(await viewModule(session, address, page)) };
|
|
20
|
+
case "discussion":
|
|
21
|
+
return { ...base, ...(await viewDiscussion(session, address.discussionId)) };
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
async function viewCourses(session) {
|
|
25
|
+
const courses = await getUserCourses(session);
|
|
26
|
+
return courses.map((c) => ({
|
|
27
|
+
direccion: courseAddr(c.id),
|
|
28
|
+
id: c.id,
|
|
29
|
+
nombre: c.fullname,
|
|
30
|
+
nombreCorto: c.shortname,
|
|
31
|
+
visible: c.visible === 1,
|
|
32
|
+
progreso: c.progress ?? null,
|
|
33
|
+
ultimoAcceso: isoDate(c.lastaccess),
|
|
34
|
+
}));
|
|
35
|
+
}
|
|
36
|
+
/** El mapa del curso: secciones y módulos, con las descripciones recortadas. */
|
|
37
|
+
async function viewCourse(session, courseId) {
|
|
38
|
+
const sections = await getCourseSections(session, courseId);
|
|
39
|
+
return sections
|
|
40
|
+
.filter((s) => s.uservisible !== false)
|
|
41
|
+
.map((s) => ({
|
|
42
|
+
nombre: s.name,
|
|
43
|
+
numero: s.section,
|
|
44
|
+
resumen: htmlToText(s.summary) || undefined,
|
|
45
|
+
modulos: s.modules.filter((m) => m.uservisible).map((m) => toModule(m, courseId, true)),
|
|
46
|
+
}))
|
|
47
|
+
.filter((s) => s.resumen || s.modulos.length > 0);
|
|
48
|
+
}
|
|
49
|
+
async function viewModule(session, address, page) {
|
|
50
|
+
const { sectionName, module } = await findModule(session, address.courseId, address.cmid);
|
|
51
|
+
const base = { seccion: sectionName, modulo: toModule(module, address.courseId) };
|
|
52
|
+
switch (module.modname) {
|
|
53
|
+
case "forum":
|
|
54
|
+
return { ...base, ...(await viewForum(session, address, module.instance, page)) };
|
|
55
|
+
case "assign":
|
|
56
|
+
return { ...base, entrega: await viewSubmission(session, address.courseId, module) };
|
|
57
|
+
case "choice":
|
|
58
|
+
return { ...base, eleccion: await viewChoice(session, address.courseId, module) };
|
|
59
|
+
case "choicegroup":
|
|
60
|
+
return { ...base, eleccion: await viewChoicegroup(session, module) };
|
|
61
|
+
default:
|
|
62
|
+
return base;
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
/** Un foro es un índice: asuntos y un fragmento de cada uno. El mensaje completo sale al abrir la discusión. */
|
|
66
|
+
async function viewForum(session, address, forumid, page) {
|
|
67
|
+
const [{ discussions }, canStartDiscussion] = await Promise.all([
|
|
68
|
+
getForumDiscussions(session, forumid, page),
|
|
69
|
+
canAddDiscussion(session, forumid),
|
|
70
|
+
]);
|
|
71
|
+
return {
|
|
72
|
+
puedeAbrirHilo: canStartDiscussion,
|
|
73
|
+
discusiones: discussions.map((d) => ({
|
|
74
|
+
direccion: discussionAddr(address.courseId, address.cmid, d.discussion),
|
|
75
|
+
asunto: d.subject,
|
|
76
|
+
autor: d.userfullname,
|
|
77
|
+
autorId: d.userid,
|
|
78
|
+
fecha: isoDate(d.created),
|
|
79
|
+
ultimaActividad: isoDate(d.timemodified),
|
|
80
|
+
respuestas: d.numreplies,
|
|
81
|
+
fijada: d.pinned || undefined,
|
|
82
|
+
adjuntos: d.attachment || undefined,
|
|
83
|
+
resumen: htmlToSnippet(d.message),
|
|
84
|
+
})),
|
|
85
|
+
pagina: page,
|
|
86
|
+
hayMasDiscusiones: discussions.length === PAGE_SIZE || undefined,
|
|
87
|
+
};
|
|
88
|
+
}
|
|
89
|
+
async function viewDiscussion(session, discussionId) {
|
|
90
|
+
const { posts } = await getDiscussionPosts(session, discussionId);
|
|
91
|
+
const visible = posts.filter((p) => !p.isdeleted);
|
|
92
|
+
return {
|
|
93
|
+
asunto: visible[0]?.subject,
|
|
94
|
+
publicaciones: visible.map((p) => ({
|
|
95
|
+
autor: p.author.fullname,
|
|
96
|
+
autorId: p.author.id,
|
|
97
|
+
fecha: isoDate(p.timecreated),
|
|
98
|
+
respuestaA: p.parentid ?? undefined,
|
|
99
|
+
id: p.id,
|
|
100
|
+
mensaje: htmlToText(p.message),
|
|
101
|
+
adjuntos: p.attachments.length ? p.attachments.map(toFile) : undefined,
|
|
102
|
+
// Lo que el usuario puede hacer con cada publicación, para no intentar lo que Moodle va a rechazar.
|
|
103
|
+
acciones: allowedActions(p.capabilities),
|
|
104
|
+
})),
|
|
105
|
+
};
|
|
106
|
+
}
|
|
107
|
+
function allowedActions(c) {
|
|
108
|
+
const allowed = [c.reply && "responder", c.edit && "editar", c.delete && "borrar"].filter(Boolean);
|
|
109
|
+
return allowed.length ? allowed : undefined;
|
|
110
|
+
}
|
|
111
|
+
/** Ubica un módulo por su cmid y se queda con la sección que lo contiene. */
|
|
112
|
+
export async function findModule(session, courseId, cmid) {
|
|
113
|
+
const sections = await getCourseSections(session, courseId, cmid);
|
|
114
|
+
for (const section of sections) {
|
|
115
|
+
const module = section.modules.find((m) => m.id === cmid);
|
|
116
|
+
if (!module)
|
|
117
|
+
continue;
|
|
118
|
+
if (!module.uservisible)
|
|
119
|
+
throw new Error(`No tenés acceso al módulo ${cmid}.`);
|
|
120
|
+
return { sectionName: section.name, module };
|
|
121
|
+
}
|
|
122
|
+
throw new Error(`No existe el módulo ${cmid} en el curso ${courseId}.`);
|
|
123
|
+
}
|
|
124
|
+
/** `summarized` recorta la descripción: en el mapa del curso alcanza para decidir si entrar. */
|
|
125
|
+
function toModule(m, courseId, summarized = false) {
|
|
126
|
+
const contents = m.contents ?? [];
|
|
127
|
+
const files = contents.filter((c) => c.type === "file");
|
|
128
|
+
const links = contents.filter((c) => c.type === "url");
|
|
129
|
+
return {
|
|
130
|
+
direccion: moduleAddr(courseId, m.id),
|
|
131
|
+
id: m.id,
|
|
132
|
+
nombre: m.name,
|
|
133
|
+
tipo: m.modname,
|
|
134
|
+
url: m.url,
|
|
135
|
+
descripcion: (summarized ? htmlToSnippet(m.description) : htmlToText(m.description)) || undefined,
|
|
136
|
+
restriccion: htmlToText(m.availabilityinfo) || undefined,
|
|
137
|
+
fechas: m.dates?.length
|
|
138
|
+
? m.dates.map((d) => ({ etiqueta: d.label, fecha: isoDate(d.timestamp) }))
|
|
139
|
+
: undefined,
|
|
140
|
+
archivos: files.length ? files.map(toFile) : undefined,
|
|
141
|
+
enlaces: links.length ? links.map((l) => l.fileurl) : undefined,
|
|
142
|
+
};
|
|
143
|
+
}
|
|
@@ -1,8 +1,9 @@
|
|
|
1
1
|
import * as z from "zod";
|
|
2
2
|
import { getSiteInfo } from "./client/client.js";
|
|
3
|
-
import {
|
|
4
|
-
import {
|
|
5
|
-
import {
|
|
3
|
+
import { addCalendarEvent, answerChoice, deleteForumPost, editForumPost, postToForum, sendMessage, } from "./format/actions.js";
|
|
4
|
+
import { ROOT } from "./format/address.js";
|
|
5
|
+
import { readAulaFile } from "./format/files.js";
|
|
6
|
+
import { open } from "./format/navigate.js";
|
|
6
7
|
// JSON compacto: indentar estas respuestas cuesta ~25% más de contexto y no se lee mejor.
|
|
7
8
|
const json = (data) => ({
|
|
8
9
|
content: [{ type: "text", text: JSON.stringify(data) }],
|
|
@@ -11,8 +12,13 @@ const failure = (e) => ({
|
|
|
11
12
|
content: [{ type: "text", text: `Error: ${e.message}` }],
|
|
12
13
|
isError: true,
|
|
13
14
|
});
|
|
15
|
+
/** Para que el cliente distinga las tools que leen de las que cambian algo en el aula. */
|
|
16
|
+
const READ_ONLY = { readOnlyHint: true, openWorldHint: false };
|
|
14
17
|
export function registerAulaVirtualTools(server, session) {
|
|
15
|
-
server.registerTool("aula_get_site_info", {
|
|
18
|
+
server.registerTool("aula_get_site_info", {
|
|
19
|
+
description: "Datos del usuario logueado en el aula virtual de la UTN FRBA y del sitio Moodle.",
|
|
20
|
+
annotations: READ_ONLY,
|
|
21
|
+
}, async () => {
|
|
16
22
|
try {
|
|
17
23
|
const { sitename, fullname, username, userid, release } = await getSiteInfo(session);
|
|
18
24
|
return json({ sitename, fullname, username, userid, release });
|
|
@@ -24,9 +30,10 @@ export function registerAulaVirtualTools(server, session) {
|
|
|
24
30
|
server.registerTool("aula_list_courses", {
|
|
25
31
|
description: "Lista los cursos (materias) en los que está inscripto el usuario. Es el punto de entrada: " +
|
|
26
32
|
"cada curso viene con su `direccion`, que se le pasa a aula_open para entrar.",
|
|
33
|
+
annotations: READ_ONLY,
|
|
27
34
|
}, async () => {
|
|
28
35
|
try {
|
|
29
|
-
return json(await open(session,
|
|
36
|
+
return json(await open(session, ROOT));
|
|
30
37
|
}
|
|
31
38
|
catch (e) {
|
|
32
39
|
return failure(e);
|
|
@@ -36,6 +43,11 @@ export function registerAulaVirtualTools(server, session) {
|
|
|
36
43
|
description: "Entra a una dirección del aula virtual y devuelve lo que hay ahí: un curso con sus secciones " +
|
|
37
44
|
"y módulos, un módulo con sus archivos y fechas, un foro con el índice de sus discusiones, o " +
|
|
38
45
|
"una discusión con sus mensajes completos.\n\n" +
|
|
46
|
+
"Según el tipo de módulo trae además: en una tarea (assign), `entrega` con el estado, el " +
|
|
47
|
+
"vencimiento, lo que se subió y la devolución con la nota; en una elección (choice) o " +
|
|
48
|
+
"selección de grupos (choicegroup), `eleccion` con las opciones y sus ids; en un foro, " +
|
|
49
|
+
"`puedeAbrirHilo`; en una discusión, el `id`, el `autorId` y las `acciones` permitidas de " +
|
|
50
|
+
"cada publicación. Es lo que piden las tools que escriben en el aula.\n\n" +
|
|
39
51
|
"No hace falta armar la dirección: cada respuesta trae las direcciones de lo que hay adentro " +
|
|
40
52
|
"(`direccion`) y la de un nivel más arriba (`padre`), así que se navega pasando de vuelta una " +
|
|
41
53
|
"de esas. Para empezar, usar aula_list_courses o la dirección \"/\".\n\n" +
|
|
@@ -57,6 +69,7 @@ export function registerAulaVirtualTools(server, session) {
|
|
|
57
69
|
.describe("Solo para foros: página del índice de discusiones, de 50 en 50. La respuesta avisa " +
|
|
58
70
|
"con `hayMasDiscusiones` cuando hay otra."),
|
|
59
71
|
}),
|
|
72
|
+
annotations: READ_ONLY,
|
|
60
73
|
}, async ({ direccion, pagina }) => {
|
|
61
74
|
try {
|
|
62
75
|
return json(await open(session, direccion, pagina));
|
|
@@ -82,20 +95,21 @@ export function registerAulaVirtualTools(server, session) {
|
|
|
82
95
|
.optional()
|
|
83
96
|
.describe("Número de página, diapositiva o fragmento desde el cual leer. Por defecto, 1."),
|
|
84
97
|
}),
|
|
98
|
+
annotations: READ_ONLY,
|
|
85
99
|
}, async ({ url, desde }) => {
|
|
86
100
|
try {
|
|
87
|
-
const {
|
|
88
|
-
const
|
|
101
|
+
const { parts, ...fileRead } = await readAulaFile(session, url, desde);
|
|
102
|
+
const label = (n, title) => `--- ${fileRead.unidad} ${n} de ${fileRead.total}${title ? ` (${title})` : ""} ---`;
|
|
89
103
|
return {
|
|
90
104
|
content: [
|
|
91
|
-
{ type: "text", text: JSON.stringify(
|
|
92
|
-
...
|
|
93
|
-
? [{ type: "text", text: `${
|
|
105
|
+
{ type: "text", text: JSON.stringify(fileRead) },
|
|
106
|
+
...parts.flatMap((p) => "text" in p
|
|
107
|
+
? [{ type: "text", text: `${label(p.number, p.title)}\n${p.text}` }]
|
|
94
108
|
: [
|
|
95
|
-
{ type: "text", text:
|
|
109
|
+
{ type: "text", text: label(p.number, p.title) },
|
|
96
110
|
{
|
|
97
111
|
type: "image",
|
|
98
|
-
data: Buffer.from(p.
|
|
112
|
+
data: Buffer.from(p.image).toString("base64"),
|
|
99
113
|
mimeType: p.mimeType,
|
|
100
114
|
},
|
|
101
115
|
]),
|
|
@@ -106,4 +120,168 @@ export function registerAulaVirtualTools(server, session) {
|
|
|
106
120
|
return failure(e);
|
|
107
121
|
}
|
|
108
122
|
});
|
|
123
|
+
// --- Escritura: lo que sigue cambia algo en el aula, y casi todo queda a la vista de otras personas. ---
|
|
124
|
+
const CONFIRM_FIRST = "\n\nAntes de llamarla, mostrale al usuario exactamente lo que se va a hacer y esperá que confirme.";
|
|
125
|
+
server.registerTool("aula_forum_post", {
|
|
126
|
+
description: "Publica en un foro del aula virtual. Con la `direccion` de un foro abre un hilo nuevo (hace " +
|
|
127
|
+
"falta `asunto`); con la de una discusión responde en ella, a la publicación `respuestaA` o, " +
|
|
128
|
+
"si no se indica, a la primera.\n\n" +
|
|
129
|
+
"Lo que se publica lo ven docentes y compañeros, y a muchos les llega por mail. Antes de abrir " +
|
|
130
|
+
"un hilo, mirá `puedeAbrirHilo` del foro; antes de responder, que la publicación tenga " +
|
|
131
|
+
'"responder" en `acciones`.' +
|
|
132
|
+
CONFIRM_FIRST,
|
|
133
|
+
inputSchema: z.object({
|
|
134
|
+
direccion: z
|
|
135
|
+
.string()
|
|
136
|
+
.describe('Dirección de un foro o de una discusión. Ej: "/curso/691/mod/34831" o ' +
|
|
137
|
+
'"/curso/691/mod/34831/disc/416676".'),
|
|
138
|
+
mensaje: z
|
|
139
|
+
.string()
|
|
140
|
+
.min(1)
|
|
141
|
+
.describe("Texto del mensaje, en texto plano. Una línea en blanco separa párrafos. " +
|
|
142
|
+
'Ej: "Hola, ¿el parcial incluye la unidad 4?"'),
|
|
143
|
+
asunto: z
|
|
144
|
+
.string()
|
|
145
|
+
.optional()
|
|
146
|
+
.describe('Obligatorio para abrir un hilo. En una respuesta, por defecto "Re: <asunto>". ' +
|
|
147
|
+
'Ej: "Consulta sobre el parcial".'),
|
|
148
|
+
respuestaA: z
|
|
149
|
+
.number()
|
|
150
|
+
.int()
|
|
151
|
+
.optional()
|
|
152
|
+
.describe("Solo al responder: `id` de la publicación a la que se responde, de aula_open. Ej: 768118."),
|
|
153
|
+
}),
|
|
154
|
+
annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: false, openWorldHint: true },
|
|
155
|
+
}, async ({ direccion, mensaje, asunto, respuestaA }) => {
|
|
156
|
+
try {
|
|
157
|
+
return json(await postToForum(session, direccion, mensaje, asunto, respuestaA));
|
|
158
|
+
}
|
|
159
|
+
catch (e) {
|
|
160
|
+
return failure(e);
|
|
161
|
+
}
|
|
162
|
+
});
|
|
163
|
+
server.registerTool("aula_forum_edit_post", {
|
|
164
|
+
description: "Edita una publicación propia de un foro y reemplaza el mensaje completo. Solo se puede si la " +
|
|
165
|
+
'publicación tiene "editar" en `acciones`: Moodle deja editar por un rato después de publicar.' +
|
|
166
|
+
CONFIRM_FIRST,
|
|
167
|
+
inputSchema: z.object({
|
|
168
|
+
direccion: z
|
|
169
|
+
.string()
|
|
170
|
+
.describe('Dirección de la discusión donde está la publicación. Ej: "/curso/691/mod/34831/disc/416676".'),
|
|
171
|
+
id: z.number().int().describe("`id` de la publicación, de aula_open. Ej: 768118."),
|
|
172
|
+
mensaje: z
|
|
173
|
+
.string()
|
|
174
|
+
.min(1)
|
|
175
|
+
.describe('El mensaje nuevo completo, en texto plano; reemplaza al anterior. Ej: "Ya lo resolví, gracias."'),
|
|
176
|
+
asunto: z
|
|
177
|
+
.string()
|
|
178
|
+
.optional()
|
|
179
|
+
.describe('Asunto nuevo. Si no se pasa, queda el que tenía. Ej: "Consulta sobre el parcial (resuelta)".'),
|
|
180
|
+
}),
|
|
181
|
+
annotations: { readOnlyHint: false, destructiveHint: true, idempotentHint: true, openWorldHint: true },
|
|
182
|
+
}, async ({ direccion, id, mensaje, asunto }) => {
|
|
183
|
+
try {
|
|
184
|
+
return json(await editForumPost(session, direccion, id, mensaje, asunto));
|
|
185
|
+
}
|
|
186
|
+
catch (e) {
|
|
187
|
+
return failure(e);
|
|
188
|
+
}
|
|
189
|
+
});
|
|
190
|
+
server.registerTool("aula_forum_delete_post", {
|
|
191
|
+
description: "Borra una publicación propia de un foro. No se puede deshacer. Solo se puede si tiene " +
|
|
192
|
+
'"borrar" en `acciones`. Si es la primera publicación de la discusión, se borra la discusión ' +
|
|
193
|
+
"entera." +
|
|
194
|
+
CONFIRM_FIRST,
|
|
195
|
+
inputSchema: z.object({
|
|
196
|
+
direccion: z
|
|
197
|
+
.string()
|
|
198
|
+
.describe('Dirección de la discusión donde está la publicación. Ej: "/curso/691/mod/34831/disc/416676".'),
|
|
199
|
+
id: z.number().int().describe("`id` de la publicación, de aula_open. Ej: 768118."),
|
|
200
|
+
}),
|
|
201
|
+
annotations: { readOnlyHint: false, destructiveHint: true, idempotentHint: true, openWorldHint: true },
|
|
202
|
+
}, async ({ direccion, id }) => {
|
|
203
|
+
try {
|
|
204
|
+
return json(await deleteForumPost(session, direccion, id));
|
|
205
|
+
}
|
|
206
|
+
catch (e) {
|
|
207
|
+
return failure(e);
|
|
208
|
+
}
|
|
209
|
+
});
|
|
210
|
+
server.registerTool("aula_choice_answer", {
|
|
211
|
+
description: 'Responde una elección (choice: encuestas rápidas como "¿venís al parcial?") o una selección ' +
|
|
212
|
+
"de grupos (choicegroup: anotarse en un grupo o comisión). Deja elegidas exactamente las " +
|
|
213
|
+
"`opciones` pasadas y reemplaza la respuesta anterior; con una lista vacía la borra.\n\n" +
|
|
214
|
+
"Las opciones salen de `eleccion` al abrir el módulo con aula_open. Muchas no dejan cambiar " +
|
|
215
|
+
"la respuesta una vez dada (`puedeCambiar: false`), y las opciones `deshabilitada` están " +
|
|
216
|
+
"completas. Devuelve cómo quedó la elección." +
|
|
217
|
+
CONFIRM_FIRST,
|
|
218
|
+
inputSchema: z.object({
|
|
219
|
+
direccion: z.string().describe('Dirección del módulo. Ej: "/curso/29487/mod/1729113".'),
|
|
220
|
+
opciones: z
|
|
221
|
+
.array(z.number().int())
|
|
222
|
+
.describe("`id` de las opciones a elegir. Ej: [31076]. Varias solo si la actividad lo permite."),
|
|
223
|
+
}),
|
|
224
|
+
annotations: { readOnlyHint: false, destructiveHint: true, idempotentHint: true, openWorldHint: false },
|
|
225
|
+
}, async ({ direccion, opciones }) => {
|
|
226
|
+
try {
|
|
227
|
+
return json(await answerChoice(session, direccion, opciones));
|
|
228
|
+
}
|
|
229
|
+
catch (e) {
|
|
230
|
+
return failure(e);
|
|
231
|
+
}
|
|
232
|
+
});
|
|
233
|
+
server.registerTool("aula_send_message", {
|
|
234
|
+
description: "Manda un mensaje privado por la mensajería del aula virtual a otro usuario, docente o " +
|
|
235
|
+
"compañero. El `usuarioId` sale del `autorId` de un foro o una discusión abiertos con " +
|
|
236
|
+
"aula_open: no lo inventes ni lo deduzcas del nombre. La respuesta trae el nombre del " +
|
|
237
|
+
"destinatario." +
|
|
238
|
+
CONFIRM_FIRST +
|
|
239
|
+
" Incluí a quién va dirigido.",
|
|
240
|
+
inputSchema: z.object({
|
|
241
|
+
usuarioId: z.number().int().describe("Id del destinatario, del `autorId` de aula_open. Ej: 1066."),
|
|
242
|
+
mensaje: z
|
|
243
|
+
.string()
|
|
244
|
+
.min(1)
|
|
245
|
+
.describe('Texto del mensaje, en texto plano. Ej: "Profe, ¿puedo rendir el recuperatorio el viernes?"'),
|
|
246
|
+
}),
|
|
247
|
+
annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: false, openWorldHint: true },
|
|
248
|
+
}, async ({ usuarioId, mensaje }) => {
|
|
249
|
+
try {
|
|
250
|
+
return json(await sendMessage(session, usuarioId, mensaje));
|
|
251
|
+
}
|
|
252
|
+
catch (e) {
|
|
253
|
+
return failure(e);
|
|
254
|
+
}
|
|
255
|
+
});
|
|
256
|
+
server.registerTool("aula_calendar_add_event", {
|
|
257
|
+
description: "Agrega un evento personal al calendario del aula virtual, que solo ve el usuario: por ejemplo " +
|
|
258
|
+
"un recordatorio de un parcial o una entrega. El aula avisa según la configuración de " +
|
|
259
|
+
"notificaciones del usuario. La fecha va con zona horaria; en Argentina es -03:00.",
|
|
260
|
+
inputSchema: z.object({
|
|
261
|
+
nombre: z.string().min(1).describe('Título del evento. Ej: "Parcial de Física II".'),
|
|
262
|
+
inicio: z
|
|
263
|
+
.string()
|
|
264
|
+
.describe('Fecha y hora en ISO 8601 con zona horaria. Ej: "2026-10-14T18:00:00-03:00".'),
|
|
265
|
+
duracionMinutos: z
|
|
266
|
+
.number()
|
|
267
|
+
.int()
|
|
268
|
+
.min(0)
|
|
269
|
+
.optional()
|
|
270
|
+
.describe("Duración en minutos. Por defecto, sin duración. Ej: 120."),
|
|
271
|
+
descripcion: z.string().optional().describe('Detalle, en texto plano. Ej: "Aula 312, unidades 1 a 4".'),
|
|
272
|
+
}),
|
|
273
|
+
annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: false, openWorldHint: false },
|
|
274
|
+
}, async ({ nombre, inicio, duracionMinutos, descripcion }) => {
|
|
275
|
+
try {
|
|
276
|
+
return json(await addCalendarEvent(session, {
|
|
277
|
+
name: nombre,
|
|
278
|
+
start: inicio,
|
|
279
|
+
durationMinutes: duracionMinutos,
|
|
280
|
+
description: descripcion,
|
|
281
|
+
}));
|
|
282
|
+
}
|
|
283
|
+
catch (e) {
|
|
284
|
+
return failure(e);
|
|
285
|
+
}
|
|
286
|
+
});
|
|
109
287
|
}
|
package/build/server.js
CHANGED