Read the timetable from WebUntis
Schulcloud holds the material for a lesson but not the lesson: this school's course `times` are empty and it publishes the schedule in WebUntis. So "what do I have today, and has anything been cancelled" was unanswerable, and the timetable cannot be typed into a prompt either — it changes daily. core/untis.ts talks to the API the Untis Mobile app uses, and three tools sit on it: untis_timetable (a day or a range, Entfall, Vertretung, room changes, the notes on each period, inline homework, the period id), untis_homework (the class register's list, which is not Schulcloud's tasks) and untis_lesson_topics (what earlier lessons of a series actually covered, which is what says where a subject got to). Read-only, but not by the Schulcloud client's rule: this API is JSON-RPC, so every call is a POST, reads included. READ_METHODS is the guarantee instead, enforced at the single choke point and asserted by a test. It matters because the key can do what the app can — the live account holds W_OWN_ABSENCE, so the same key could report the user absent. What the live instance taught us, all recorded in docs/API.md: - `startDateTime` ends in Z and is local time. The 08:00 lesson reports 08:00Z, so new Date() would move every lesson by an hour or two. - A substitution is two periods, the original CANCELLED and the replacement IRREGULAR beside it, not one period with a changed teacher. - Announced tests live in the period's info text. The exam module is unused here, so getExams2017 is always empty and that field carries the tests. - A day with no lessons is not a holiday: the weeks this account spends in the company simply have no periods. - `?v=i3.2` is required, or the call fails with a Java NPE reported as -8998. Errors arrive with HTTP 200 and an error member. -8504 is a rejected key and -8524 a drifting clock; the tools name both, because no retry fixes either. Configuration is all four UNTIS_* values or none — three are identifiers and the fourth is a credential, so a half-filled block is a paste that went wrong. Without them the tools are not registered at all, since a tool that can only fail is worse than a missing one. whoami reports the WebUntis identity and survives a dead Schulcloud session, so "is the server reachable" no longer gets a misleadingly total no. mcp-env.sh switches WebUntis off for a fixture run: that key belongs to the real school. 224 tests. All 10 WebUntis smoke checks pass, with a key and without one; the Schulcloud checks in those runs answer 401 because this machine's session is logged out. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -8,6 +8,7 @@
|
||||
*/
|
||||
|
||||
import { resolve } from 'node:path';
|
||||
import type { UntisConfig } from './core/untis.ts';
|
||||
|
||||
export interface Config {
|
||||
/** Instance base URL, no trailing slash, e.g. `https://schulcloud-thueringen.de`. */
|
||||
@@ -61,6 +62,13 @@ export interface Config {
|
||||
indexFileManager: boolean;
|
||||
/** How often to re-crawl on a timer. Zero = only on demand. */
|
||||
crawlIntervalMs: number;
|
||||
|
||||
/**
|
||||
* WebUntis, where the school keeps the timetable. Unset = the untis_* tools
|
||||
* are not offered at all, which is the right answer for a school that does
|
||||
* not use it — Schulcloud alone cannot say what happens when.
|
||||
*/
|
||||
untis: UntisConfig | undefined;
|
||||
}
|
||||
|
||||
function required(name: string): string {
|
||||
@@ -114,6 +122,46 @@ function secretToken(name: string): string | undefined {
|
||||
return value;
|
||||
}
|
||||
|
||||
/**
|
||||
* The four WebUntis values, or undefined when none is set.
|
||||
*
|
||||
* All four or nothing: three of them are harmless identifiers and the fourth is
|
||||
* a credential, so a half-filled block is a copy-paste that went wrong, not a
|
||||
* configuration to guess at. They come from one dialog — WebUntis → Profil →
|
||||
* Freigaben → Untis Mobile → QR-Code — and the error says so, because that is
|
||||
* the only place to find them.
|
||||
*/
|
||||
function untisConfig(): UntisConfig | undefined {
|
||||
const server = process.env.UNTIS_SERVER?.trim();
|
||||
const school = process.env.UNTIS_SCHOOL?.trim();
|
||||
const user = process.env.UNTIS_USER?.trim();
|
||||
const secret = process.env.UNTIS_SECRET?.trim();
|
||||
const missing = Object.entries({ UNTIS_SERVER: server, UNTIS_SCHOOL: school, UNTIS_USER: user, UNTIS_SECRET: secret })
|
||||
.filter(([, value]) => !value)
|
||||
.map(([name]) => name);
|
||||
if (missing.length === 4) return undefined;
|
||||
if (!server || !school || !user || !secret) {
|
||||
throw new Error(
|
||||
`WebUntis needs all four of UNTIS_SERVER, UNTIS_SCHOOL, UNTIS_USER and UNTIS_SECRET — missing: ` +
|
||||
`${missing.join(', ')}. All four are in WebUntis → Profil → Freigaben → Untis Mobile → QR-Code.`,
|
||||
);
|
||||
}
|
||||
if (!/^[a-z0-9][a-z0-9.-]*\.[a-z]{2,}$/i.test(server)) {
|
||||
throw new Error(
|
||||
`UNTIS_SERVER must be the bare host from the QR dialog's "Url" field, e.g. "ags-erfurt.webuntis.com" — ` +
|
||||
`no scheme and no path, got "${server}".`,
|
||||
);
|
||||
}
|
||||
// A credential: the error states the rule and never echoes the value.
|
||||
if (!/^[A-Za-z2-7]{8,}$/.test(secret)) {
|
||||
throw new Error(
|
||||
'UNTIS_SECRET must be the key from the Untis Mobile QR dialog: at least 8 characters of A-Z and 2-7 ' +
|
||||
'(base32), no spaces.',
|
||||
);
|
||||
}
|
||||
return { server, school, user, secret };
|
||||
}
|
||||
|
||||
/** Like `int`, but 0 is meaningful (it disables the feature) rather than invalid. */
|
||||
function intAllowingZero(name: string, fallback: number): number {
|
||||
const raw = process.env[name]?.trim();
|
||||
@@ -162,5 +210,6 @@ export function loadConfig(): Config {
|
||||
// so an index without it misses whole courses. One page load per folder.
|
||||
indexFileManager: bool('INDEX_FILE_MANAGER', true),
|
||||
crawlIntervalMs: intAllowingZero('CRAWL_INTERVAL_MS', 6 * 60 * 60_000),
|
||||
untis: untisConfig(),
|
||||
};
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user