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:
22
.env.example
22
.env.example
@@ -84,6 +84,28 @@ DATABASE_URL=postgresql://schulcloud:schulcloud@postgres:5432/schulcloud
|
||||
# file records are immutable.
|
||||
# CRAWL_INTERVAL_MS=21600000
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# WebUntis (optional — the timetable, which Schulcloud does not hold)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
# Where the school publishes its timetable. With these set, the server offers
|
||||
# untis_timetable, untis_homework and untis_lesson_topics, plus the German
|
||||
# "tagesvorbereitung" prompt; without them none of that exists. All four values
|
||||
# are in one dialog: WebUntis → Profil → Freigaben → Untis Mobile → QR-Code.
|
||||
#
|
||||
# UNTIS_SECRET is the "Schlüssel" field and is a credential: it authenticates
|
||||
# every request as this user, needs no password, works with an SSO login, and
|
||||
# stays valid until you generate a new key in that dialog. It can do whatever
|
||||
# the Untis Mobile app can — this server only ever calls read methods, by
|
||||
# allowlist (src/core/untis.ts). See docs/AUTH.md.
|
||||
#
|
||||
# Authentication is a time-based code, so the host's clock must be in sync;
|
||||
# WebUntis answers -8524 ("invalid client time") when it is not.
|
||||
# UNTIS_SERVER=yourschool.webuntis.com
|
||||
# UNTIS_SCHOOL=yourschool
|
||||
# UNTIS_USER=your.username
|
||||
# UNTIS_SECRET=
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Limits (optional — sensible defaults are built in)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@@ -38,4 +38,11 @@ export MCP_AUTH_TOKEN=local-instance-token
|
||||
export DATABASE_URL=postgresql://schulcloud:schulcloud@127.0.0.1:55432/schulcloud_local
|
||||
export MIRROR_DIR=$ROOT/tmp/mirror-local
|
||||
export INDEX_PERSONAL_FILES=true
|
||||
# WebUntis off for a local run: this instance has no timetable, and the key in
|
||||
# the repo's .env belongs to the real school — a fixture run has no business
|
||||
# talking to it, even read-only.
|
||||
export UNTIS_SERVER=
|
||||
export UNTIS_SCHOOL=
|
||||
export UNTIS_USER=
|
||||
export UNTIS_SECRET=
|
||||
ENV
|
||||
|
||||
@@ -466,6 +466,91 @@ if (hasIndex) {
|
||||
);
|
||||
}
|
||||
|
||||
console.log('\n== WebUntis ==');
|
||||
// The timetable lives in WebUntis, not in Schulcloud, and the tools only exist
|
||||
// when a key is configured. Both halves are asserted: with a key the live
|
||||
// answers have to be shaped right, without one the tools must not be offered at
|
||||
// all — a tool that can only fail is worse than a missing one.
|
||||
const hasUntis = Boolean(config.untis);
|
||||
{
|
||||
const untisTools = names.filter((name) => name.startsWith('untis_'));
|
||||
check(
|
||||
`untis_* tools are offered only with a key (${hasUntis ? 'configured' : 'not configured'})`,
|
||||
hasUntis
|
||||
? untisTools.join(' ') === 'untis_homework untis_lesson_topics untis_timetable'
|
||||
: untisTools.length === 0,
|
||||
untisTools.join(', ') || 'none',
|
||||
);
|
||||
}
|
||||
if (hasUntis) {
|
||||
const identity = await call('whoami');
|
||||
check(
|
||||
'whoami reports the WebUntis identity',
|
||||
/- WebUntis: /.test(identity.text) && !/not reachable/.test(identity.text),
|
||||
identity.text.split('\n').find((line) => line.startsWith('- WebUntis')),
|
||||
);
|
||||
|
||||
const today = await call('untis_timetable');
|
||||
check(
|
||||
'untis_timetable answers for today',
|
||||
!today.isError && /^## \p{L}+, \d{2}\.\d{2}\.\d{4}/mu.test(today.text),
|
||||
today.text.split('\n')[0],
|
||||
);
|
||||
|
||||
// A four-week window: either it holds lessons or the days say why not. The
|
||||
// school year has gaps — holidays, and the weeks this account spends at work —
|
||||
// so requiring lessons would make the run fail on a correct answer.
|
||||
const start = new Date().toISOString().slice(0, 10);
|
||||
const end = new Date(Date.now() + 28 * 86_400_000).toISOString().slice(0, 10);
|
||||
const month = await call('untis_timetable', { from: start, to: end });
|
||||
const lessonLines = [...month.text.matchAll(/^- \d{2}:\d{2}–\d{2}:\d{2} /gm)].length;
|
||||
check(
|
||||
'untis_timetable answers for a four-week range',
|
||||
!month.isError && (lessonLines > 0 || /No lessons/.test(month.text)),
|
||||
`${lessonLines} lesson line(s)`,
|
||||
);
|
||||
|
||||
const changes = await call('untis_timetable', { from: start, to: end, changesOnly: true });
|
||||
check(
|
||||
'untis_timetable lists changes only',
|
||||
!changes.isError && (/\*\*(Entfall|Vertretung)\*\*/.test(changes.text) || /Nothing cancelled or changed/.test(changes.text)),
|
||||
changes.text.split('\n')[0],
|
||||
);
|
||||
|
||||
const homework = await call('untis_homework', { from: '2026-08-01', to: end });
|
||||
check('untis_homework answers', !homework.isError, homework.text.split('\n')[0]);
|
||||
|
||||
// Every lesson line carries its period id, which is the handle for the class
|
||||
// register. Without one there is nothing to ask about, so the check follows
|
||||
// the timetable's own output rather than a hard-coded id.
|
||||
const periodId = Number(month.text.match(/`(\d{5,})`/)?.[1]);
|
||||
if (Number.isInteger(periodId)) {
|
||||
const topics = await call('untis_lesson_topics', { periodId, limit: 3 });
|
||||
check(
|
||||
'untis_lesson_topics reads what previous lessons covered',
|
||||
!topics.isError && (/Unterrichtsinhalte/.test(topics.text) || /No lesson contents/.test(topics.text)),
|
||||
topics.text.split('\n')[0],
|
||||
);
|
||||
} else {
|
||||
check('untis_lesson_topics reads what previous lessons covered', true, 'skipped: no lesson in the window');
|
||||
}
|
||||
|
||||
const unreal = await call('untis_timetable', { from: '2026-02-30' });
|
||||
check(
|
||||
'a date that does not exist is refused rather than rolled over',
|
||||
unreal.isError && /Not a date in the calendar/.test(unreal.text),
|
||||
unreal.text.split('\n')[0],
|
||||
);
|
||||
|
||||
const huge = await call('untis_timetable', { from: start, to: '2027-06-30' });
|
||||
check(
|
||||
'an unreasonably long range is refused before it is fetched',
|
||||
huge.isError && /at most \d+ at a time/.test(huge.text),
|
||||
huge.text.split('\n')[0],
|
||||
);
|
||||
|
||||
}
|
||||
|
||||
console.log('\n== api_get guard rails ==');
|
||||
check('api_get allows /api/ paths', !(await call('api_get', { path: '/api/v3/me' })).isError);
|
||||
check('api_get rejects non-/api path', (await call('api_get', { path: '/etc/passwd' })).isError);
|
||||
|
||||
@@ -75,7 +75,10 @@ async function main(): Promise<void> {
|
||||
`token from ${token.source}${token.daysLeft === undefined ? '' : `, ${token.daysLeft} day(s) left`}` +
|
||||
`${token.persistent ? '' : ' (replacements not saved: STATE_DIR unset)'}, ` +
|
||||
`keepalive ${keepalive ? `every ${Math.round(config.keepaliveIntervalMs / 60_000)}min` : 'off'}, ` +
|
||||
`index ${services.store ? (config.crawlIntervalMs > 0 ? `every ${Math.round(config.crawlIntervalMs / 3_600_000)}h` : 'on demand') : 'off'}`,
|
||||
`index ${services.store ? (config.crawlIntervalMs > 0 ? `every ${Math.round(config.crawlIntervalMs / 3_600_000)}h` : 'on demand') : 'off'}, ` +
|
||||
// The origin, never the key: this is the line that says whether the
|
||||
// timetable tools exist at all in this deployment.
|
||||
`untis ${services.untis ? services.untis.origin : 'off'}`,
|
||||
);
|
||||
});
|
||||
|
||||
|
||||
@@ -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(),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -2,6 +2,7 @@ import type { Config } from './config.ts';
|
||||
import { SchulcloudClient } from './core/client.ts';
|
||||
import { FileManager } from './core/legacy-files.ts';
|
||||
import type { LegacyUser, MeResponse } from './core/types.ts';
|
||||
import { UntisClient } from './core/untis.ts';
|
||||
import type { Indexer } from './indexer/indexer.ts';
|
||||
import type { Store } from './store/store.ts';
|
||||
|
||||
@@ -20,6 +21,8 @@ export class ServerContext {
|
||||
/** Shared across sessions; undefined when running without an index. */
|
||||
readonly store: Store | undefined;
|
||||
readonly indexer: Indexer | undefined;
|
||||
/** WebUntis — the timetable — or undefined when the server has no key for it. */
|
||||
readonly untis: UntisClient | undefined;
|
||||
private identity: Promise<MeResponse> | undefined;
|
||||
/**
|
||||
* id -> display name, for the whole session.
|
||||
@@ -35,13 +38,20 @@ export class ServerContext {
|
||||
|
||||
constructor(
|
||||
config: Config,
|
||||
shared?: { client?: SchulcloudClient; files?: FileManager; store?: Store; indexer?: Indexer },
|
||||
shared?: {
|
||||
client?: SchulcloudClient;
|
||||
files?: FileManager;
|
||||
store?: Store;
|
||||
indexer?: Indexer;
|
||||
untis?: UntisClient;
|
||||
},
|
||||
) {
|
||||
this.config = config;
|
||||
this.client = shared?.client ?? new SchulcloudClient(config);
|
||||
this.files = shared?.files ?? new FileManager(this.client);
|
||||
this.store = shared?.store;
|
||||
this.indexer = shared?.indexer;
|
||||
this.untis = shared?.untis ?? (config.untis ? new UntisClient(config.untis, config.requestTimeoutMs) : undefined);
|
||||
}
|
||||
|
||||
/** Cached `/me`. Shared promise, so concurrent first calls make one request. */
|
||||
|
||||
555
src/core/untis.ts
Normal file
555
src/core/untis.ts
Normal file
@@ -0,0 +1,555 @@
|
||||
/**
|
||||
* WebUntis: the timetable, its changes, homework and what was taught.
|
||||
*
|
||||
* The school keeps its timetable in WebUntis, not in Schulcloud — Schulcloud's
|
||||
* course `times` are empty here — so "what do I have today, and has anything
|
||||
* been cancelled" is a question only this API can answer. It is the other half
|
||||
* of a school day: Schulcloud holds the material, WebUntis holds the schedule.
|
||||
*
|
||||
* **Read-only, but not by the Schulcloud client's rule.** This is JSON-RPC:
|
||||
* every call is a POST, reads included, so "GET only" cannot be the guarantee.
|
||||
* `READ_METHODS` is: `call` refuses any method outside it. That matters because
|
||||
* the key is the mobile app's credential and can do what the app can — this
|
||||
* account holds `W_OWN_ABSENCE`, so the same key could report the user absent.
|
||||
*
|
||||
* Authentication is a one-time code derived from the base32 key behind the QR
|
||||
* code in WebUntis → Profil → Freigaben (see core/totp.ts). Each request signs
|
||||
* itself, so unlike the Schulcloud session there is nothing to hold open and
|
||||
* nothing to refresh — but the server's clock has to be right, which is what
|
||||
* error -8524 means.
|
||||
*/
|
||||
|
||||
import { compactDate } from './dates.ts';
|
||||
import { totp } from './totp.ts';
|
||||
|
||||
export interface UntisConfig {
|
||||
/** Bare host from the QR dialog's "Url" field, e.g. `ags-erfurt.webuntis.com`. */
|
||||
server: string;
|
||||
/** The school's login name, e.g. `ags-erfurt`. */
|
||||
school: string;
|
||||
user: string;
|
||||
/** The base32 key from the QR dialog. A credential: never log it. */
|
||||
secret: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* The methods this client may call. Everything here reads; nothing writes.
|
||||
*
|
||||
* Verified against the live instance — `getClassregEvents2017` and
|
||||
* `getSchoolyears2017` answer "Method not found" and are deliberately absent.
|
||||
*/
|
||||
const READ_METHODS = new Set([
|
||||
'getUserData2017',
|
||||
'getTimetable2017',
|
||||
'getLessonTopic2017',
|
||||
'getHomeWork2017',
|
||||
'getMessagesOfDay2017',
|
||||
]);
|
||||
|
||||
/**
|
||||
* The read-only guarantee for this API, at its single choke point.
|
||||
*
|
||||
* Exported so a test can hold it to it: this is the line that keeps a key which
|
||||
* *can* write from being used to write.
|
||||
*/
|
||||
export function assertReadMethod(method: string): void {
|
||||
if (!READ_METHODS.has(method)) {
|
||||
throw new Error(`Refusing to call WebUntis method ${method}: not in the read-only allowlist.`);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The client version the mobile API expects as `?v=`.
|
||||
*
|
||||
* Not optional: `jsonrpc_intern.do` reads the parameter without checking it,
|
||||
* so omitting it fails with a Java NullPointerException reported as -8998.
|
||||
*/
|
||||
const API_VERSION = 'i3.2';
|
||||
|
||||
/** How long master data (subjects, teachers, rooms, holidays) is reused. */
|
||||
const MASTER_DATA_TTL_MS = 6 * 60 * 60_000;
|
||||
|
||||
/** A JSON-RPC error from WebUntis. They arrive with HTTP 200 and an `error` body. */
|
||||
export class UntisApiError extends Error {
|
||||
readonly code: number;
|
||||
readonly method: string;
|
||||
|
||||
constructor(code: number, method: string, message: string) {
|
||||
super(`WebUntis ${method} failed (${code}): ${message}`);
|
||||
this.name = 'UntisApiError';
|
||||
this.code = code;
|
||||
this.method = method;
|
||||
}
|
||||
|
||||
/** -8504: the key or user name is wrong, or the key has been regenerated. */
|
||||
get isAuthFailure(): boolean {
|
||||
return this.code === -8504;
|
||||
}
|
||||
|
||||
/** -8524: our clock is too far from the school server's. */
|
||||
get isClockSkew(): boolean {
|
||||
return this.code === -8524;
|
||||
}
|
||||
}
|
||||
|
||||
// --- what callers get ----------------------------------------------------
|
||||
|
||||
/** A subject, teacher, room or class: a short code plus, where known, a full name. */
|
||||
export interface UntisName {
|
||||
name: string;
|
||||
longName?: string;
|
||||
}
|
||||
|
||||
export interface UntisHomework {
|
||||
id: number;
|
||||
lessonId: number;
|
||||
/** When it was set, `YYYY-MM-DD`. */
|
||||
assigned: string;
|
||||
/** When it is due, `YYYY-MM-DD`. */
|
||||
due: string;
|
||||
text: string;
|
||||
remark?: string;
|
||||
completed: boolean;
|
||||
subject?: UntisName;
|
||||
attachments: number;
|
||||
}
|
||||
|
||||
export interface UntisLesson {
|
||||
/** The period id, which `getLessonTopic2017` takes. */
|
||||
periodId: number;
|
||||
/** The lesson (series) id: the same weekly slot shares it. */
|
||||
lessonId: number;
|
||||
date: string;
|
||||
/** `HH:MM` in the school's local time. */
|
||||
start: string;
|
||||
end: string;
|
||||
/** Raw status words, e.g. `REGULAR`, `CANCELLED`, `IRREGULAR`. */
|
||||
statuses: string[];
|
||||
cancelled: boolean;
|
||||
/** A substitution, a moved lesson or anything else Untis calls irregular. */
|
||||
changed: boolean;
|
||||
subjects: UntisName[];
|
||||
teachers: UntisName[];
|
||||
rooms: UntisName[];
|
||||
classes: UntisName[];
|
||||
/** What each kind of element replaced, when Untis says so (its `orgId`). */
|
||||
replaced: { subjects: UntisName[]; teachers: UntisName[]; rooms: UntisName[] };
|
||||
/** The three free-text fields a teacher can attach to a period. */
|
||||
notes: { lesson?: string; substitution?: string; info?: string };
|
||||
homework: UntisHomework[];
|
||||
/** The exam module's title, when the school uses it. */
|
||||
exam?: string;
|
||||
online: boolean;
|
||||
}
|
||||
|
||||
export interface UntisHoliday {
|
||||
name: string;
|
||||
longName: string;
|
||||
start: string;
|
||||
end: string;
|
||||
}
|
||||
|
||||
export interface UntisDay {
|
||||
date: string;
|
||||
lessons: UntisLesson[];
|
||||
/** Holidays and single free days covering this date. */
|
||||
holidays: UntisHoliday[];
|
||||
}
|
||||
|
||||
export interface UntisTimetable {
|
||||
from: string;
|
||||
to: string;
|
||||
/** Every date in the range, including the ones without lessons. */
|
||||
days: UntisDay[];
|
||||
}
|
||||
|
||||
export interface UntisIdentity {
|
||||
displayName: string;
|
||||
elementId: number;
|
||||
elementType: string;
|
||||
schoolName: string;
|
||||
/** Untis' own permission words, e.g. `R_MY_ABSENCES`, `W_OWN_ABSENCE`. */
|
||||
rights: string[];
|
||||
}
|
||||
|
||||
/** One class-register entry: what was taught in a lesson of this series. */
|
||||
export interface UntisTopic {
|
||||
text: string;
|
||||
periodId: number;
|
||||
date: string;
|
||||
start: string;
|
||||
end: string;
|
||||
}
|
||||
|
||||
export interface UntisMessage {
|
||||
subject: string;
|
||||
text: string;
|
||||
}
|
||||
|
||||
// --- raw shapes ----------------------------------------------------------
|
||||
|
||||
interface RawElement {
|
||||
type: string;
|
||||
id: number;
|
||||
orgId?: number;
|
||||
}
|
||||
|
||||
interface RawPeriod {
|
||||
id: number;
|
||||
lessonId: number;
|
||||
startDateTime: string;
|
||||
endDateTime: string;
|
||||
text?: { lesson?: string; substitution?: string; info?: string; attachments?: unknown[] };
|
||||
elements?: RawElement[];
|
||||
is?: string[];
|
||||
homeWorks?: RawHomework[];
|
||||
exam?: { name?: string; text?: string } | null;
|
||||
isOnlinePeriod?: boolean;
|
||||
}
|
||||
|
||||
interface RawHomework {
|
||||
id: number;
|
||||
lessonId: number;
|
||||
startDate: string;
|
||||
endDate: string;
|
||||
text: string;
|
||||
remark?: string | null;
|
||||
completed?: boolean;
|
||||
attachments?: unknown[];
|
||||
}
|
||||
|
||||
interface RawNamed {
|
||||
id: number;
|
||||
name: string;
|
||||
longName?: string;
|
||||
firstName?: string;
|
||||
lastName?: string;
|
||||
}
|
||||
|
||||
interface RawMasterData {
|
||||
timeStamp?: number;
|
||||
subjects?: RawNamed[];
|
||||
teachers?: RawNamed[];
|
||||
rooms?: RawNamed[];
|
||||
klassen?: RawNamed[];
|
||||
holidays?: { name: string; longName: string; startDate: string; endDate: string }[];
|
||||
}
|
||||
|
||||
interface RawUserData {
|
||||
userData?: {
|
||||
displayName?: string;
|
||||
elemId?: number;
|
||||
elemType?: string;
|
||||
schoolName?: string;
|
||||
rights?: string[];
|
||||
};
|
||||
masterData?: RawMasterData;
|
||||
}
|
||||
|
||||
interface RawTimetable {
|
||||
timetable?: { periods?: RawPeriod[] };
|
||||
masterData?: RawMasterData;
|
||||
}
|
||||
|
||||
// --- client --------------------------------------------------------------
|
||||
|
||||
export class UntisClient {
|
||||
private readonly config: UntisConfig;
|
||||
private readonly timeoutMs: number;
|
||||
private identityPromise: Promise<UntisIdentity> | undefined;
|
||||
private masterData: { data: RawMasterData; at: number } | undefined;
|
||||
|
||||
constructor(config: UntisConfig, timeoutMs = 30_000) {
|
||||
this.config = config;
|
||||
this.timeoutMs = timeoutMs;
|
||||
}
|
||||
|
||||
/** For status lines: where this client is pointed, never how it authenticates. */
|
||||
get origin(): string {
|
||||
return `${this.config.server}/${this.config.school}`;
|
||||
}
|
||||
|
||||
private async call<T>(method: string, params: Record<string, unknown>): Promise<T> {
|
||||
// The allowlist is the read-only guarantee for this API; widening it is a
|
||||
// deliberate act, not something a caller can do by passing a string.
|
||||
assertReadMethod(method);
|
||||
const url =
|
||||
`https://${this.config.server}/WebUntis/jsonrpc_intern.do` +
|
||||
`?m=${encodeURIComponent(method)}&school=${encodeURIComponent(this.config.school)}&v=${API_VERSION}`;
|
||||
const response = await fetch(url, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'content-type': 'application/json',
|
||||
// Honest, and accepted: the endpoint does not check for the app's own
|
||||
// user agent.
|
||||
'user-agent': 'schulcloud-mcp',
|
||||
},
|
||||
body: JSON.stringify({
|
||||
id: 'schulcloud-mcp',
|
||||
jsonrpc: '2.0',
|
||||
method,
|
||||
params: [
|
||||
{
|
||||
...params,
|
||||
// The code is a string: one in ten starts with a zero, which a
|
||||
// JSON number would drop.
|
||||
auth: { user: this.config.user, otp: totp(this.config.secret), clientTime: Date.now() },
|
||||
},
|
||||
],
|
||||
}),
|
||||
signal: AbortSignal.timeout(this.timeoutMs),
|
||||
});
|
||||
|
||||
const body = (await response.json().catch(() => undefined)) as
|
||||
| { result?: T; error?: { code?: number; message?: string } }
|
||||
| undefined;
|
||||
// Failures come back as HTTP 200 with an `error` member, so the body is
|
||||
// the thing to check first.
|
||||
if (body?.error) {
|
||||
throw new UntisApiError(body.error.code ?? 0, method, body.error.message ?? 'no message');
|
||||
}
|
||||
if (!response.ok) throw new UntisApiError(0, method, `HTTP ${response.status}`);
|
||||
if (body?.result === undefined) throw new UntisApiError(0, method, 'response carried no result');
|
||||
return body.result;
|
||||
}
|
||||
|
||||
/** Who the key belongs to. Cached; a failure is not, so a fixed key recovers. */
|
||||
identity(): Promise<UntisIdentity> {
|
||||
this.identityPromise ??= this.call<RawUserData>('getUserData2017', {})
|
||||
.then((raw) => {
|
||||
if (raw.masterData) this.masterData = { data: raw.masterData, at: Date.now() };
|
||||
const user = raw.userData ?? {};
|
||||
if (user.elemId === undefined || !user.elemType) {
|
||||
throw new UntisApiError(0, 'getUserData2017', 'response carried no user element');
|
||||
}
|
||||
return {
|
||||
displayName: user.displayName ?? '(unnamed)',
|
||||
elementId: user.elemId,
|
||||
elementType: user.elemType,
|
||||
schoolName: user.schoolName ?? this.config.school,
|
||||
rights: user.rights ?? [],
|
||||
};
|
||||
})
|
||||
.catch((error: unknown) => {
|
||||
this.identityPromise = undefined;
|
||||
throw error;
|
||||
});
|
||||
return this.identityPromise;
|
||||
}
|
||||
|
||||
/**
|
||||
* The timetable for a date range, with every day in it — including the ones
|
||||
* with no lessons, because "no school today" is an answer and an empty list
|
||||
* is not.
|
||||
*/
|
||||
async timetable(from: string, to: string): Promise<UntisTimetable> {
|
||||
const me = await this.identity();
|
||||
const raw = await this.call<RawTimetable>('getTimetable2017', {
|
||||
id: me.elementId,
|
||||
type: me.elementType,
|
||||
startDate: compactDate(from),
|
||||
endDate: compactDate(to),
|
||||
// Always ask for full master data rather than a delta against a cached
|
||||
// timestamp: the delta's removal semantics are unverified, and the whole
|
||||
// set is one payload of a few hundred kilobytes a handful of times a day.
|
||||
masterDataTimestamp: 0,
|
||||
timetableTimestamp: 0,
|
||||
timetableTimestamps: [],
|
||||
});
|
||||
if (raw.masterData?.subjects) this.masterData = { data: raw.masterData, at: Date.now() };
|
||||
const master = raw.masterData ?? (await this.master());
|
||||
|
||||
const lessons = (raw.timetable?.periods ?? []).map((period) => this.toLesson(period, master));
|
||||
const days: UntisDay[] = [];
|
||||
for (let date = from; date <= to; date = nextDate(date)) {
|
||||
days.push({
|
||||
date,
|
||||
lessons: lessons.filter((lesson) => lesson.date === date).sort(byStart),
|
||||
holidays: holidaysOn(master, date),
|
||||
});
|
||||
}
|
||||
return { from, to, days };
|
||||
}
|
||||
|
||||
/**
|
||||
* Homework set for a date range.
|
||||
*
|
||||
* The range filters by the homework's own dates, not by when it was set, so
|
||||
* a window that ends today shows nothing that is due tomorrow.
|
||||
*/
|
||||
async homework(from: string, to: string): Promise<UntisHomework[]> {
|
||||
const me = await this.identity();
|
||||
const raw = await this.call<{
|
||||
homeWorks?: RawHomework[];
|
||||
lessonsById?: Record<string, { subjectId?: number }>;
|
||||
}>('getHomeWork2017', {
|
||||
id: me.elementId,
|
||||
type: me.elementType,
|
||||
startDate: compactDate(from),
|
||||
endDate: compactDate(to),
|
||||
});
|
||||
const master = await this.master();
|
||||
const subjects = index(master.subjects);
|
||||
return (raw.homeWorks ?? [])
|
||||
.map((item) => {
|
||||
const subjectId = raw.lessonsById?.[String(item.lessonId)]?.subjectId;
|
||||
return toHomework(item, subjectId === undefined ? undefined : named(subjects.get(subjectId)));
|
||||
})
|
||||
.sort((a, b) => a.due.localeCompare(b.due));
|
||||
}
|
||||
|
||||
/**
|
||||
* What was taught in the previous lessons of a period's series — the class
|
||||
* register's "Unterrichtsinhalt", newest first.
|
||||
*
|
||||
* The parameter is a single `periodId`; a list is rejected as "period 0 not
|
||||
* found".
|
||||
*/
|
||||
async lessonTopics(periodId: number): Promise<UntisTopic[]> {
|
||||
const raw = await this.call<{
|
||||
previousTopics?: { text?: string; periodId?: number; startDateTime?: string; endDateTime?: string }[];
|
||||
}>('getLessonTopic2017', { periodId });
|
||||
return (raw.previousTopics ?? [])
|
||||
.filter((topic) => topic.text?.trim())
|
||||
.map((topic) => {
|
||||
const start = splitLocal(topic.startDateTime ?? '');
|
||||
const end = splitLocal(topic.endDateTime ?? '');
|
||||
return {
|
||||
text: topic.text!.trim(),
|
||||
periodId: topic.periodId ?? periodId,
|
||||
date: start.date,
|
||||
start: start.time,
|
||||
end: end.time,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
/** The school's "Nachrichten des Tages" for one date. Often empty. */
|
||||
async messagesOfDay(date: string): Promise<UntisMessage[]> {
|
||||
const raw = await this.call<{ messages?: { subject?: string; text?: string }[] }>('getMessagesOfDay2017', {
|
||||
date: compactDate(date),
|
||||
});
|
||||
return (raw.messages ?? []).map((message) => ({
|
||||
subject: message.subject?.trim() ?? '',
|
||||
text: message.text?.trim() ?? '',
|
||||
}));
|
||||
}
|
||||
|
||||
/** Master data, refreshed at most every few hours: it changes with the school year. */
|
||||
private async master(): Promise<RawMasterData> {
|
||||
if (this.masterData && Date.now() - this.masterData.at < MASTER_DATA_TTL_MS) return this.masterData.data;
|
||||
const raw = await this.call<RawUserData>('getUserData2017', {});
|
||||
const data = raw.masterData ?? {};
|
||||
this.masterData = { data, at: Date.now() };
|
||||
return data;
|
||||
}
|
||||
|
||||
private toLesson(period: RawPeriod, master: RawMasterData): UntisLesson {
|
||||
const start = splitLocal(period.startDateTime);
|
||||
const end = splitLocal(period.endDateTime);
|
||||
const maps = {
|
||||
SUBJECT: index(master.subjects),
|
||||
TEACHER: index(master.teachers),
|
||||
ROOM: index(master.rooms),
|
||||
CLASS: index(master.klassen),
|
||||
};
|
||||
const of = (type: keyof typeof maps): RawElement[] => (period.elements ?? []).filter((e) => e.type === type);
|
||||
const resolve = (type: keyof typeof maps): UntisName[] =>
|
||||
of(type).map((element) => named(maps[type].get(element.id)) ?? { name: `${type.toLowerCase()} #${element.id}` });
|
||||
// Untis expresses "X instead of Y" by keeping the original in orgId.
|
||||
const replacedBy = (type: keyof typeof maps): UntisName[] =>
|
||||
of(type)
|
||||
.filter((element) => element.orgId !== undefined && element.orgId !== element.id)
|
||||
.map((element) => named(maps[type].get(element.orgId!)) ?? { name: `${type.toLowerCase()} #${element.orgId}` });
|
||||
|
||||
const statuses = period.is ?? [];
|
||||
const note = (value: string | undefined): string | undefined => value?.trim() || undefined;
|
||||
return {
|
||||
periodId: period.id,
|
||||
lessonId: period.lessonId,
|
||||
date: start.date,
|
||||
start: start.time,
|
||||
end: end.time,
|
||||
statuses,
|
||||
cancelled: statuses.includes('CANCELLED'),
|
||||
changed: statuses.includes('IRREGULAR') || statuses.includes('SUBSTITUTION'),
|
||||
subjects: resolve('SUBJECT'),
|
||||
teachers: resolve('TEACHER'),
|
||||
rooms: resolve('ROOM'),
|
||||
classes: resolve('CLASS'),
|
||||
replaced: { subjects: replacedBy('SUBJECT'), teachers: replacedBy('TEACHER'), rooms: replacedBy('ROOM') },
|
||||
notes: {
|
||||
lesson: note(period.text?.lesson),
|
||||
substitution: note(period.text?.substitution),
|
||||
info: note(period.text?.info),
|
||||
},
|
||||
homework: (period.homeWorks ?? []).map((item) => toHomework(item, undefined)),
|
||||
exam: note(period.exam?.name) ?? note(period.exam?.text),
|
||||
online: period.isOnlinePeriod === true,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
// --- helpers -------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Splits `2026-09-21T11:45Z` into date and time — **as local school time**.
|
||||
*
|
||||
* The `Z` is a lie: the school's time grid starts lessons at 08:00 and the API
|
||||
* reports exactly `08:00Z` for them. Parsing these as UTC would shift every
|
||||
* lesson by an hour or two, so the string is taken apart rather than given to
|
||||
* `new Date`.
|
||||
*/
|
||||
export function splitLocal(value: string): { date: string; time: string } {
|
||||
const match = /^(\d{4}-\d{2}-\d{2})T(\d{2}:\d{2})/.exec(value);
|
||||
if (!match) {
|
||||
// Dropping an unparsable lesson would silently shorten a school day; a
|
||||
// changed format has to be visible.
|
||||
throw new Error(`WebUntis returned a timestamp in an unexpected format: ${value}`);
|
||||
}
|
||||
return { date: match[1]!, time: match[2]! };
|
||||
}
|
||||
|
||||
function index(list: RawNamed[] | undefined): Map<number, RawNamed> {
|
||||
return new Map((list ?? []).map((entry) => [entry.id, entry]));
|
||||
}
|
||||
|
||||
function named(entry: RawNamed | undefined): UntisName | undefined {
|
||||
if (!entry) return undefined;
|
||||
const full = [entry.firstName, entry.lastName].filter(Boolean).join(' ').trim();
|
||||
const longName = entry.longName?.trim() || full || undefined;
|
||||
return { name: entry.name, ...(longName ? { longName } : {}) };
|
||||
}
|
||||
|
||||
function toHomework(item: RawHomework, subject: UntisName | undefined): UntisHomework {
|
||||
return {
|
||||
id: item.id,
|
||||
lessonId: item.lessonId,
|
||||
assigned: item.startDate,
|
||||
due: item.endDate,
|
||||
text: item.text?.trim() ?? '',
|
||||
...(item.remark?.trim() ? { remark: item.remark.trim() } : {}),
|
||||
completed: item.completed === true,
|
||||
...(subject ? { subject } : {}),
|
||||
attachments: item.attachments?.length ?? 0,
|
||||
};
|
||||
}
|
||||
|
||||
function holidaysOn(master: RawMasterData, date: string): UntisHoliday[] {
|
||||
return (master.holidays ?? [])
|
||||
.filter((holiday) => holiday.startDate <= date && date <= holiday.endDate)
|
||||
.map((holiday) => ({
|
||||
name: holiday.name.trim(),
|
||||
longName: holiday.longName.trim(),
|
||||
start: holiday.startDate,
|
||||
end: holiday.endDate,
|
||||
}));
|
||||
}
|
||||
|
||||
function nextDate(date: string): string {
|
||||
return new Date(Date.parse(`${date}T12:00:00Z`) + 86_400_000).toISOString().slice(0, 10);
|
||||
}
|
||||
|
||||
function byStart(a: UntisLesson, b: UntisLesson): number {
|
||||
return a.start.localeCompare(b.start) || a.periodId - b.periodId;
|
||||
}
|
||||
@@ -13,6 +13,7 @@ import { registerIndexTools } from './tools/index-tools.ts';
|
||||
import { registerSearchTool } from './tools/search.ts';
|
||||
import { registerRoomTools } from './tools/rooms.ts';
|
||||
import { registerSubmissionTools } from './tools/submissions.ts';
|
||||
import { registerUntisTools } from './tools/untis.ts';
|
||||
|
||||
export const SERVER_NAME = 'schulcloud-mcp';
|
||||
export const SERVER_VERSION = '0.1.0';
|
||||
@@ -43,6 +44,14 @@ How the content is organised, and the usual path through it:
|
||||
graded submission, say it was not found rather than that none was given. On a teacher account these
|
||||
tools report other people's submissions too.
|
||||
|
||||
**The timetable is not in Schulcloud.** When the untis_* tools are listed, the school's schedule lives in
|
||||
WebUntis and they are the only way to it: untis_timetable says which lessons a day actually holds, what was
|
||||
cancelled ("Entfall"), what is a substitution ("Vertretung") and what a teacher noted on a period — announced
|
||||
tests are usually in those notes. Schulcloud holds the material for those lessons, so the two go together:
|
||||
take the subject from untis_timetable, then find its course with list_courses. untis_homework is the class
|
||||
register's homework, which is a different list from Schulcloud's tasks; check both. untis_lesson_topics says
|
||||
what previous lessons of a subject actually covered.
|
||||
|
||||
When the user names a topic rather than a course, use search — the API has no search endpoint, so it walks the
|
||||
courses and matches client-side, which takes a few seconds but covers board text and file names.
|
||||
|
||||
@@ -66,6 +75,8 @@ export function createServer(config: Config, services?: Services): { server: Mcp
|
||||
registerSearchTool(server, context);
|
||||
registerSubmissionTools(server, context);
|
||||
registerIndexTools(server, context);
|
||||
// Only when a key is configured: the tools are not offered at all otherwise.
|
||||
registerUntisTools(server, context);
|
||||
registerRawTool(server, context);
|
||||
registerResources(server, context);
|
||||
registerPrompts(server, context);
|
||||
|
||||
@@ -21,6 +21,8 @@ export function registerOverviewTools(server: McpServer, context: ServerContext)
|
||||
annotations: READ_ONLY,
|
||||
},
|
||||
async () => {
|
||||
// Never fails, so the WebUntis half survives a dead Schulcloud session.
|
||||
const untis = await untisLine(context);
|
||||
try {
|
||||
const me = await context.me();
|
||||
return text(
|
||||
@@ -33,13 +35,19 @@ export function registerOverviewTools(server: McpServer, context: ServerContext)
|
||||
`- Instance: ${context.config.baseUrl}`,
|
||||
`- Permissions: ${me.permissions.length}`,
|
||||
tokenExpiryLine(context.config.jwt),
|
||||
untis,
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join('\n'),
|
||||
]),
|
||||
);
|
||||
} catch (error) {
|
||||
return toToolError(error, 'read the current user');
|
||||
const problem = toToolError(error, 'read the current user');
|
||||
// One identity tool now answers for two systems: an expired Schulcloud
|
||||
// token must not hide a working WebUntis key, or "is the server
|
||||
// reachable?" gets a misleadingly total no.
|
||||
if (!untis) return problem;
|
||||
return { ...problem, content: [...problem.content, { type: 'text' as const, text: untis }] };
|
||||
}
|
||||
},
|
||||
);
|
||||
@@ -188,6 +196,26 @@ export function registerOverviewTools(server: McpServer, context: ServerContext)
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* The WebUntis side of the account, when configured.
|
||||
*
|
||||
* Never throws: whoami is the connectivity check, so a rejected Untis key has
|
||||
* to be reported *in* the answer rather than replace it — the Schulcloud half
|
||||
* of the report is still true and still useful.
|
||||
*/
|
||||
async function untisLine(context: ServerContext): Promise<string | undefined> {
|
||||
if (!context.untis) return undefined;
|
||||
try {
|
||||
const me = await context.untis.identity();
|
||||
return (
|
||||
`- WebUntis: ${me.displayName} (${me.elementType.toLowerCase()}) at ${me.schoolName}` +
|
||||
` — timetable via untis_timetable`
|
||||
);
|
||||
} catch (error) {
|
||||
return `- WebUntis: **not reachable** — ${error instanceof Error ? error.message : String(error)}`;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* When the server's Schulcloud token runs out. Only a person can renew it, so
|
||||
* the week before is worth saying out loud wherever the account is shown.
|
||||
|
||||
335
src/mcp/tools/untis.ts
Normal file
335
src/mcp/tools/untis.ts
Normal file
@@ -0,0 +1,335 @@
|
||||
/**
|
||||
* The WebUntis tools: the school day itself.
|
||||
*
|
||||
* Registered only when the server is configured for WebUntis, so a deployment
|
||||
* without a key does not offer the model tools that can only fail.
|
||||
*/
|
||||
|
||||
import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
|
||||
import { z } from 'zod';
|
||||
import type { ServerContext } from '../../context.ts';
|
||||
import { addDays, daysBetween, germanDay, germanWeekday, isCalendarDate, schoolToday } from '../../core/dates.ts';
|
||||
import { heading, joinSections } from '../../core/text.ts';
|
||||
import {
|
||||
UntisApiError,
|
||||
type UntisClient,
|
||||
type UntisDay,
|
||||
type UntisHomework,
|
||||
type UntisLesson,
|
||||
} from '../../core/untis.ts';
|
||||
import { failure, text, toToolError } from './result.ts';
|
||||
|
||||
const READ_ONLY = { readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: true };
|
||||
|
||||
/** How far past the requested range to look when it turns out to be empty. */
|
||||
const LOOKAHEAD_DAYS = 14;
|
||||
|
||||
/**
|
||||
* Longest range either tool will fetch.
|
||||
*
|
||||
* A school week is about 40 periods, so a term's worth of timetable is
|
||||
* thousands of lines of Markdown — past the point where it helps anyone, and
|
||||
* the answer to "what do I have" is never three months of it.
|
||||
*/
|
||||
const MAX_RANGE_DAYS = 92;
|
||||
|
||||
const dateArgument = z
|
||||
.string()
|
||||
.regex(/^\d{4}-\d{2}-\d{2}$/, 'Use YYYY-MM-DD.')
|
||||
.describe('A date as YYYY-MM-DD.');
|
||||
|
||||
export function registerUntisTools(server: McpServer, context: ServerContext): void {
|
||||
const untis = context.untis;
|
||||
if (!untis) return;
|
||||
|
||||
server.registerTool(
|
||||
'untis_timetable',
|
||||
{
|
||||
title: 'Timetable (WebUntis)',
|
||||
description:
|
||||
'The lessons of a school day or a date range from WebUntis ("Stundenplan"), with cancellations ' +
|
||||
'("Entfall"), substitutions ("Vertretung"), room changes, the notes teachers attach to a period — ' +
|
||||
'announced tests are usually in there — plus homework due and any exam. This is the tool for "what ' +
|
||||
'do I have today/tomorrow", "has anything been cancelled" and for finding which subjects to prepare. ' +
|
||||
'Omit both dates for today. The material for those lessons is in Schulcloud, not here: match the ' +
|
||||
'subject to a course with list_courses. Every lesson line carries its period id for untis_lesson_topics.',
|
||||
inputSchema: {
|
||||
from: dateArgument.optional().describe('First day. Omit for today.'),
|
||||
to: dateArgument.optional().describe('Last day, inclusive. Omit for a single day.'),
|
||||
changesOnly: z
|
||||
.boolean()
|
||||
.default(false)
|
||||
.describe('Only lessons that are cancelled or changed, for "what is different this week".'),
|
||||
},
|
||||
annotations: READ_ONLY,
|
||||
},
|
||||
async ({ from, to, changesOnly }) => {
|
||||
const start = from ?? schoolToday();
|
||||
const end = to ?? start;
|
||||
const unreal = [...new Set([start, end])].filter((value) => !isCalendarDate(value));
|
||||
if (unreal.length > 0) return failure(`Not a date in the calendar: ${unreal.join(', ')}. Use YYYY-MM-DD.`);
|
||||
if (end < start) return failure(`The range ends before it starts: ${start} to ${end}.`);
|
||||
if (daysBetween(start, end) > MAX_RANGE_DAYS) {
|
||||
return failure(
|
||||
`That is ${daysBetween(start, end)} days. Ask for at most ${MAX_RANGE_DAYS} at a time — a term of ` +
|
||||
'timetable is thousands of lines.',
|
||||
);
|
||||
}
|
||||
try {
|
||||
return text(await readTimetable(untis, { from: start, to: end, changesOnly }));
|
||||
} catch (error) {
|
||||
return untisError(error, `read the timetable for ${start}${start === end ? '' : ` to ${end}`}`);
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
server.registerTool(
|
||||
'untis_homework',
|
||||
{
|
||||
title: 'Homework (WebUntis)',
|
||||
description:
|
||||
'Homework recorded in WebUntis\' class register ("Hausaufgaben"), with the day it was set and the day ' +
|
||||
'it is due. Separate from Schulcloud tasks ("Aufgaben") — a teacher uses one or the other, so check ' +
|
||||
'both when the user asks what they have to do: list_tasks covers the Schulcloud side. Defaults to the ' +
|
||||
'next two weeks.',
|
||||
inputSchema: {
|
||||
from: dateArgument.optional().describe('Earliest date to include. Omit for today.'),
|
||||
to: dateArgument.optional().describe('Latest date to include. Omit for two weeks ahead.'),
|
||||
includeCompleted: z.boolean().default(false).describe('Also list homework already ticked off.'),
|
||||
},
|
||||
annotations: READ_ONLY,
|
||||
},
|
||||
async ({ from, to, includeCompleted }) => {
|
||||
const start = from ?? schoolToday();
|
||||
const end = to ?? addDays(start, 14);
|
||||
const unreal = [...new Set([start, end])].filter((value) => !isCalendarDate(value));
|
||||
if (unreal.length > 0) return failure(`Not a date in the calendar: ${unreal.join(', ')}. Use YYYY-MM-DD.`);
|
||||
if (end < start) return failure(`The range ends before it starts: ${start} to ${end}.`);
|
||||
if (daysBetween(start, end) > MAX_RANGE_DAYS) {
|
||||
return failure(
|
||||
`That is ${daysBetween(start, end)} days. Ask for at most ${MAX_RANGE_DAYS} at a time — a term of ` +
|
||||
'timetable is thousands of lines.',
|
||||
);
|
||||
}
|
||||
try {
|
||||
const all = await untis.homework(start, end);
|
||||
const items = includeCompleted ? all : all.filter((item) => !item.completed);
|
||||
if (items.length === 0) {
|
||||
return text(
|
||||
`No homework in WebUntis between ${germanDay(start)} and ${germanDay(end)}` +
|
||||
`${includeCompleted ? '' : ' that is still open'}. Schulcloud tasks are separate — try list_tasks.`,
|
||||
);
|
||||
}
|
||||
return text(
|
||||
joinSections([
|
||||
heading(2, `Hausaufgaben ${germanDay(start)} – ${germanDay(end)} (${items.length})`),
|
||||
items.map(formatHomework).join('\n'),
|
||||
]),
|
||||
);
|
||||
} catch (error) {
|
||||
return untisError(error, 'read homework from WebUntis');
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
server.registerTool(
|
||||
'untis_lesson_topics',
|
||||
{
|
||||
title: 'What was taught (WebUntis)',
|
||||
description:
|
||||
'The class register\'s record of what previous lessons of one series actually covered ' +
|
||||
'("Unterrichtsinhalt"), newest first. Use it to prepare for the next lesson of a subject: pass the ' +
|
||||
'period id of an upcoming lesson from untis_timetable and it answers "where did we get to". Says ' +
|
||||
'nothing about material or homework — that is Schulcloud and untis_homework.',
|
||||
inputSchema: {
|
||||
periodId: z
|
||||
.number()
|
||||
.int()
|
||||
.describe('The period id of a lesson, as untis_timetable prints it in backticks.'),
|
||||
limit: z.number().int().min(1).max(50).default(10).describe('How many previous lessons to list.'),
|
||||
},
|
||||
annotations: READ_ONLY,
|
||||
},
|
||||
async ({ periodId, limit }) => {
|
||||
try {
|
||||
const topics = await untis.lessonTopics(periodId);
|
||||
if (topics.length === 0) {
|
||||
return text(
|
||||
`No lesson contents recorded for period ${periodId}. Either the class register is empty for this ` +
|
||||
'series or the teacher does not fill it in.',
|
||||
);
|
||||
}
|
||||
return text(
|
||||
joinSections([
|
||||
heading(2, `Unterrichtsinhalte (${Math.min(limit, topics.length)} of ${topics.length})`),
|
||||
topics
|
||||
.slice(0, limit)
|
||||
.map((topic) => `- ${germanDay(topic.date)} ${topic.start}–${topic.end}: ${topic.text}`)
|
||||
.join('\n'),
|
||||
]),
|
||||
);
|
||||
} catch (error) {
|
||||
return untisError(error, `read what was taught before period ${periodId}`);
|
||||
}
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* The timetable for a range as Markdown: what `untis_timetable` returns, and
|
||||
* what the Tagesvorbereitung prompt attaches, so an attached day reads exactly
|
||||
* like a fetched one.
|
||||
*/
|
||||
export async function readTimetable(
|
||||
untis: UntisClient,
|
||||
options: { from: string; to: string; changesOnly?: boolean },
|
||||
): Promise<string> {
|
||||
const { from: start, to: end, changesOnly = false } = options;
|
||||
// One request covers the range plus a lookahead, so an empty range can still
|
||||
// answer "your next lessons are on …" without a second call.
|
||||
const table = await untis.timetable(start, addDays(end, LOOKAHEAD_DAYS));
|
||||
const asked = table.days.filter((day) => day.date <= end);
|
||||
// With changesOnly a week of untouched days is noise, so only the days that
|
||||
// changed are listed at all.
|
||||
const shown = changesOnly
|
||||
? asked
|
||||
.map((day) => ({ ...day, lessons: day.lessons.filter((l) => l.cancelled || l.changed) }))
|
||||
.filter((day) => day.lessons.length > 0)
|
||||
: asked;
|
||||
const total = shown.reduce((sum, day) => sum + day.lessons.length, 0);
|
||||
|
||||
// The Nachrichten des Tages belong to a single date; skip the call for a
|
||||
// range, and never let it cost the timetable.
|
||||
const messages = start === end && !changesOnly ? await untis.messagesOfDay(start).catch(() => []) : [];
|
||||
|
||||
const nextWithLessons = table.days.find((day) => day.date > end && day.lessons.length > 0);
|
||||
return joinSections([
|
||||
heading(2, start === end ? `${germanWeekday(start)}, ${germanDay(start)}` : `${germanDay(start)} – ${germanDay(end)}`),
|
||||
changesOnly && total > 0 ? '_Only cancellations and changes._' : undefined,
|
||||
changesOnly && total === 0
|
||||
? `_Nothing cancelled or changed${start === end ? '' : ' on any day of this range'}._`
|
||||
: undefined,
|
||||
...shown.map((day) => formatDay(day, start !== end)),
|
||||
// Only about lessons: "your next change is on Friday" would be an odd thing
|
||||
// to say, and misleading when nothing has changed.
|
||||
total === 0 && !changesOnly && nextWithLessons
|
||||
? `**Next lessons:** ${germanWeekday(nextWithLessons.date)}, ${germanDay(nextWithLessons.date)} ` +
|
||||
`(${nextWithLessons.lessons.length} lesson(s)) — ask again with from="${nextWithLessons.date}".`
|
||||
: undefined,
|
||||
messages.length > 0
|
||||
? joinSections([
|
||||
heading(3, 'Nachrichten des Tages'),
|
||||
messages.map((message) => `- ${[message.subject, message.text].filter(Boolean).join(': ')}`).join('\n'),
|
||||
])
|
||||
: undefined,
|
||||
]);
|
||||
}
|
||||
|
||||
// --- formatting ----------------------------------------------------------
|
||||
|
||||
function formatDay(day: UntisDay, withHeading: boolean): string {
|
||||
const holidays = day.holidays.map((holiday) => `_${holiday.longName || holiday.name}_`).join(', ');
|
||||
// A day with no lessons and no holiday is normal at a vocational school —
|
||||
// the weeks in the company have no timetable — so say that rather than
|
||||
// leaving a bare "nothing", which reads like a failed lookup.
|
||||
const body =
|
||||
day.lessons.length > 0
|
||||
? day.lessons.map((lesson) => formatLesson(lesson, day.lessons)).join('\n')
|
||||
: holidays
|
||||
? '_No lessons._'
|
||||
: '_No lessons. Not a holiday either — a company phase or a free day._';
|
||||
return joinSections([
|
||||
withHeading ? heading(3, `${germanWeekday(day.date)}, ${germanDay(day.date)}`) : undefined,
|
||||
holidays || undefined,
|
||||
body,
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* One lesson as a line.
|
||||
*
|
||||
* A substitution arrives as two periods in the same slot — the original marked
|
||||
* CANCELLED and the replacement marked IRREGULAR — rather than as one period
|
||||
* with a changed teacher, so the replacement names what it stands in for by
|
||||
* looking at what was cancelled beside it.
|
||||
*/
|
||||
function formatLesson(lesson: UntisLesson, sameDay: UntisLesson[]): string {
|
||||
const names = (list: { name: string; longName?: string }[]): string => list.map((entry) => entry.name).join(', ');
|
||||
const subject = lesson.subjects[0];
|
||||
const subjectLabel = subject
|
||||
? `**${subject.name}**${subject.longName ? ` (${subject.longName})` : ''}`
|
||||
: '**(no subject)**';
|
||||
|
||||
const instead = [
|
||||
...lesson.replaced.teachers.map((entry) => entry.name),
|
||||
...(lesson.changed ? replacedBeside(lesson, sameDay) : []),
|
||||
];
|
||||
const status = lesson.cancelled
|
||||
? ' — **Entfall**'
|
||||
: lesson.changed
|
||||
? ` — **Vertretung**${instead.length > 0 ? ` (statt ${[...new Set(instead)].join(', ')})` : ''}`
|
||||
: '';
|
||||
const room = lesson.rooms.length > 0 ? ` · Raum ${names(lesson.rooms)}` : '';
|
||||
const replacedRoom = lesson.replaced.rooms.length > 0 ? ` (statt ${names(lesson.replaced.rooms)})` : '';
|
||||
const teachers = lesson.teachers.length > 0 ? ` · ${lesson.teachers.map(withFullName).join(', ')}` : '';
|
||||
const unknownStatus = lesson.statuses.filter((value) => !['REGULAR', 'CANCELLED', 'IRREGULAR'].includes(value));
|
||||
|
||||
const notes = [
|
||||
lesson.exam ? `**Prüfung:** ${lesson.exam}` : undefined,
|
||||
lesson.notes.info,
|
||||
lesson.notes.substitution ? `Vertretungstext: ${lesson.notes.substitution}` : undefined,
|
||||
lesson.notes.lesson,
|
||||
...lesson.homework.map((item) => `Hausaufgabe bis ${germanDay(item.due)}: ${item.text}`),
|
||||
unknownStatus.length > 0 ? `Status: ${unknownStatus.join(', ')}` : undefined,
|
||||
lesson.online ? 'Online' : undefined,
|
||||
].filter((note): note is string => Boolean(note));
|
||||
|
||||
const head =
|
||||
`- ${lesson.start}–${lesson.end} ${subjectLabel}${room}${replacedRoom}${teachers}${status} ` +
|
||||
`\`${lesson.periodId}\``;
|
||||
return notes.length > 0 ? `${head}\n${notes.map((note) => ` - ${note}`).join('\n')}` : head;
|
||||
}
|
||||
|
||||
/** The teachers of a cancelled lesson in the same slot: who this one stands in for. */
|
||||
function replacedBeside(lesson: UntisLesson, sameDay: UntisLesson[]): string[] {
|
||||
return sameDay
|
||||
.filter((other) => other.cancelled && other.start === lesson.start && other.periodId !== lesson.periodId)
|
||||
.flatMap((other) => other.teachers.map((teacher) => teacher.name));
|
||||
}
|
||||
|
||||
function withFullName(entry: { name: string; longName?: string }): string {
|
||||
return entry.longName ? `${entry.name} (${entry.longName})` : entry.name;
|
||||
}
|
||||
|
||||
function formatHomework(item: UntisHomework): string {
|
||||
const subject = item.subject ? ` — ${item.subject.name}${item.subject.longName ? ` (${item.subject.longName})` : ''}` : '';
|
||||
const done = item.completed ? ' [erledigt]' : '';
|
||||
const remark = item.remark ? ` — ${item.remark}` : '';
|
||||
const attachments = item.attachments > 0 ? ` (${item.attachments} attachment(s))` : '';
|
||||
return `- **bis ${germanDay(item.due)}**${subject}: ${item.text}${remark}${attachments}${done} (set ${germanDay(item.assigned)})`;
|
||||
}
|
||||
|
||||
/**
|
||||
* WebUntis failures the user has to act on, separated from the rest.
|
||||
*
|
||||
* A rejected key and a wrong clock are the two that no retry fixes, and they
|
||||
* are indistinguishable from "no lessons" unless they are named.
|
||||
*/
|
||||
function untisError(error: unknown, action: string) {
|
||||
if (error instanceof UntisApiError) {
|
||||
if (error.isAuthFailure) {
|
||||
return failure(
|
||||
`WebUntis rejected the server's key while trying to ${action}.\n\n` +
|
||||
'The key from WebUntis → Profil → Freigaben → Untis Mobile has been regenerated or revoked. ' +
|
||||
'Put the current one in UNTIS_SECRET and restart the server.',
|
||||
);
|
||||
}
|
||||
if (error.isClockSkew) {
|
||||
return failure(
|
||||
`WebUntis refused the one-time code while trying to ${action}: the server's clock is too far off.\n\n` +
|
||||
'The code is time-based, so the host needs a working NTP sync.',
|
||||
);
|
||||
}
|
||||
}
|
||||
return toToolError(error, action);
|
||||
}
|
||||
@@ -3,6 +3,7 @@ import { SchulcloudClient } from './core/client.ts';
|
||||
import type { SessionKeepalive } from './core/keepalive.ts';
|
||||
import { FileManager } from './core/legacy-files.ts';
|
||||
import { SessionToken } from './core/session-token.ts';
|
||||
import { UntisClient } from './core/untis.ts';
|
||||
import { Indexer } from './indexer/indexer.ts';
|
||||
import { Store } from './store/store.ts';
|
||||
|
||||
@@ -27,6 +28,12 @@ export interface Services {
|
||||
indexer: Indexer | undefined;
|
||||
/** The Schulcloud token, which `/api/token` can replace without a restart. */
|
||||
session: SessionToken;
|
||||
/**
|
||||
* WebUntis, when configured. Process-wide so its master data — 140 subjects,
|
||||
* 216 teachers, every holiday of the school year — is fetched once rather
|
||||
* than per MCP session.
|
||||
*/
|
||||
untis: UntisClient | undefined;
|
||||
/**
|
||||
* Set by the entry point that runs one, for status reports. Created there
|
||||
* rather than here because each entry point logs to a different stream.
|
||||
@@ -44,6 +51,7 @@ export async function createServices(config: Config): Promise<Services> {
|
||||
const files = new FileManager(client);
|
||||
const store = await Store.open(config.databaseUrl);
|
||||
const indexer = store ? new Indexer(client, store, config) : undefined;
|
||||
const untis = config.untis ? new UntisClient(config.untis, config.requestTimeoutMs) : undefined;
|
||||
|
||||
if (!store) {
|
||||
console.warn(
|
||||
@@ -51,7 +59,7 @@ export async function createServices(config: Config): Promise<Services> {
|
||||
'/files, /manifest and refresh_index are unavailable. Set DATABASE_URL to enable them.',
|
||||
);
|
||||
}
|
||||
return { config, client, files, store, indexer, session };
|
||||
return { config, client, files, store, indexer, session, untis };
|
||||
}
|
||||
|
||||
export async function closeServices(services: Services): Promise<void> {
|
||||
|
||||
@@ -99,3 +99,55 @@ describe('loadConfig: connector token', () => {
|
||||
assert.throws(() => loadConfig(), /must differ from MCP_AUTH_TOKEN/);
|
||||
});
|
||||
});
|
||||
|
||||
describe('loadConfig: WebUntis', () => {
|
||||
const UNTIS = {
|
||||
UNTIS_SERVER: 'ags-erfurt.webuntis.com',
|
||||
UNTIS_SCHOOL: 'ags-erfurt',
|
||||
UNTIS_USER: 'fia24b-test',
|
||||
UNTIS_SECRET: 'JBSWY3DPEHPK3PXP',
|
||||
};
|
||||
|
||||
/** The four are read from the environment, so a stray real value must not leak in. */
|
||||
function only(overrides: Partial<typeof UNTIS> = {}): void {
|
||||
process.env.TSC_URL = 'https://example.org';
|
||||
process.env.TSC_JWT_COOKIE = 'x';
|
||||
for (const name of Object.keys(UNTIS)) delete process.env[name];
|
||||
for (const [name, value] of Object.entries(overrides)) process.env[name] = value;
|
||||
}
|
||||
|
||||
it('is off when nothing is set', () => {
|
||||
only();
|
||||
assert.equal(loadConfig().untis, undefined);
|
||||
});
|
||||
|
||||
it('reads all four values', () => {
|
||||
only(UNTIS);
|
||||
assert.deepEqual(loadConfig().untis, {
|
||||
server: 'ags-erfurt.webuntis.com',
|
||||
school: 'ags-erfurt',
|
||||
user: 'fia24b-test',
|
||||
secret: 'JBSWY3DPEHPK3PXP',
|
||||
});
|
||||
});
|
||||
|
||||
it('names what is missing instead of half-configuring itself', () => {
|
||||
only({ UNTIS_SERVER: UNTIS.UNTIS_SERVER, UNTIS_SCHOOL: UNTIS.UNTIS_SCHOOL });
|
||||
assert.throws(() => loadConfig(), /missing: UNTIS_USER, UNTIS_SECRET/);
|
||||
assert.throws(() => loadConfig(), /Profil/);
|
||||
});
|
||||
|
||||
it('refuses a URL where the bare host belongs', () => {
|
||||
only({ ...UNTIS, UNTIS_SERVER: 'https://ags-erfurt.webuntis.com/WebUntis/' });
|
||||
assert.throws(() => loadConfig(), /UNTIS_SERVER must be the bare host/);
|
||||
});
|
||||
|
||||
it('refuses a key that is not base32, without echoing it', () => {
|
||||
const secret = 'not-a-base32-key!';
|
||||
only({ ...UNTIS, UNTIS_SECRET: secret });
|
||||
assert.throws(
|
||||
() => loadConfig(),
|
||||
(error: Error) => /UNTIS_SECRET/.test(error.message) && !error.message.includes(secret),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
406
test/untis.test.ts
Normal file
406
test/untis.test.ts
Normal file
@@ -0,0 +1,406 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import { afterEach, describe, it } from 'node:test';
|
||||
import {
|
||||
assertReadMethod,
|
||||
splitLocal,
|
||||
UntisApiError,
|
||||
UntisClient,
|
||||
type UntisClient as UntisClientType,
|
||||
type UntisTimetable,
|
||||
} from '../src/core/untis.ts';
|
||||
import { readTimetable } from '../src/mcp/tools/untis.ts';
|
||||
|
||||
const CONFIG = { server: 'school.webuntis.com', school: 'demo', user: 'fia24b-test', secret: 'JBSWY3DPEHPK3PXP' };
|
||||
|
||||
/** Master data in the shape the live instance returns it. */
|
||||
const MASTER = {
|
||||
timeStamp: 1,
|
||||
subjects: [
|
||||
{ id: 35, name: 'IT-FIA', longName: 'Fachtheorie FIA' },
|
||||
{ id: 130, name: 'Eth', longName: 'Ethik' },
|
||||
],
|
||||
teachers: [
|
||||
{ id: 1, name: 'Ra', firstName: 'Kristin', lastName: 'Rammelt' },
|
||||
{ id: 2, name: 'Hy', firstName: 'Justus', lastName: 'Hoyme' },
|
||||
],
|
||||
rooms: [
|
||||
{ id: 325, name: '42', longName: 'Raum 42' },
|
||||
{ id: 1, name: '001', longName: 'GR - Praxis' },
|
||||
],
|
||||
klassen: [{ id: 2757, name: 'FIA24B', longName: 'Fachinformatiker' }],
|
||||
holidays: [
|
||||
{
|
||||
name: 'Weltkindertag',
|
||||
longName: 'Weltkindertag (20.09.2026)',
|
||||
startDate: '2026-09-20',
|
||||
endDate: '2026-09-20',
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
const USER_DATA = {
|
||||
userData: {
|
||||
displayName: 'Hamm Fabian',
|
||||
elemId: 19_031,
|
||||
elemType: 'STUDENT',
|
||||
schoolName: 'Andreas-Gordon-Schule',
|
||||
rights: ['CLASSREGISTER', 'W_OWN_ABSENCE'],
|
||||
},
|
||||
masterData: MASTER,
|
||||
};
|
||||
|
||||
/** A regular lesson, a cancelled one and its replacement in the same slot. */
|
||||
const PERIODS = [
|
||||
{
|
||||
id: 100,
|
||||
lessonId: 900,
|
||||
startDateTime: '2026-09-21T08:00Z',
|
||||
endDateTime: '2026-09-21T08:45Z',
|
||||
text: { lesson: '', substitution: '', info: 'Test Projektdoku', attachments: [] },
|
||||
elements: [
|
||||
{ type: 'CLASS', id: 2757, orgId: 2757 },
|
||||
{ type: 'TEACHER', id: 1, orgId: 1 },
|
||||
{ type: 'SUBJECT', id: 35, orgId: 35 },
|
||||
{ type: 'ROOM', id: 325, orgId: 325 },
|
||||
],
|
||||
is: ['REGULAR'],
|
||||
homeWorks: [
|
||||
{ id: 7, lessonId: 900, startDate: '2026-09-14', endDate: '2026-09-21', text: 'Plakat mitbringen', completed: false },
|
||||
],
|
||||
exam: null,
|
||||
isOnlinePeriod: false,
|
||||
},
|
||||
{
|
||||
id: 101,
|
||||
lessonId: 901,
|
||||
startDateTime: '2026-09-21T10:05Z',
|
||||
endDateTime: '2026-09-21T10:50Z',
|
||||
elements: [
|
||||
{ type: 'TEACHER', id: 1, orgId: 1 },
|
||||
{ type: 'SUBJECT', id: 35, orgId: 35 },
|
||||
],
|
||||
is: ['CANCELLED'],
|
||||
},
|
||||
{
|
||||
id: 102,
|
||||
lessonId: 902,
|
||||
startDateTime: '2026-09-21T10:05Z',
|
||||
endDateTime: '2026-09-21T10:50Z',
|
||||
text: { substitution: 'Unterricht vom 30.09.2026' },
|
||||
elements: [
|
||||
{ type: 'TEACHER', id: 2, orgId: 2 },
|
||||
{ type: 'SUBJECT', id: 130, orgId: 130 },
|
||||
{ type: 'ROOM', id: 325, orgId: 1 },
|
||||
],
|
||||
is: ['IRREGULAR'],
|
||||
},
|
||||
];
|
||||
|
||||
const savedFetch = globalThis.fetch;
|
||||
afterEach(() => {
|
||||
globalThis.fetch = savedFetch;
|
||||
});
|
||||
|
||||
/** Captures every request and answers per method. */
|
||||
function stub(answers: Record<string, unknown>): { calls: { method: string; url: string; body: any }[] } {
|
||||
const calls: { method: string; url: string; body: any }[] = [];
|
||||
globalThis.fetch = (async (url: string | URL, init?: RequestInit) => {
|
||||
const body = JSON.parse(String(init?.body));
|
||||
calls.push({ method: body.method, url: String(url), body });
|
||||
const answer = answers[body.method];
|
||||
if (answer === undefined) return Response.json({ error: { code: -32_601, message: 'Method not found' } });
|
||||
if (answer instanceof Error) return Response.json({ error: { code: Number(answer.message), message: 'nope' } });
|
||||
return Response.json({ result: answer });
|
||||
}) as typeof fetch;
|
||||
return { calls };
|
||||
}
|
||||
|
||||
describe('UntisClient: request shape', () => {
|
||||
it('signs each request with a padded string code and the school in the query', async () => {
|
||||
const { calls } = stub({ getUserData2017: USER_DATA });
|
||||
await new UntisClient(CONFIG).identity();
|
||||
|
||||
const [call] = calls;
|
||||
assert.ok(call);
|
||||
assert.match(call.url, /^https:\/\/school\.webuntis\.com\/WebUntis\/jsonrpc_intern\.do\?/);
|
||||
assert.match(call.url, /m=getUserData2017/);
|
||||
assert.match(call.url, /school=demo/);
|
||||
// Without a version parameter the endpoint answers with a Java NPE.
|
||||
assert.match(call.url, /v=i3\.2/);
|
||||
const auth = call.body.params[0].auth;
|
||||
assert.equal(auth.user, 'fia24b-test');
|
||||
assert.equal(typeof auth.otp, 'string');
|
||||
assert.match(auth.otp, /^\d{6}$/);
|
||||
assert.ok(Math.abs(auth.clientTime - Date.now()) < 5_000);
|
||||
// The key itself never travels.
|
||||
assert.ok(!JSON.stringify(call.body).includes(CONFIG.secret));
|
||||
});
|
||||
|
||||
it('refuses any method outside the read-only allowlist', () => {
|
||||
assert.throws(() => assertReadMethod('submitAbsencesChecked2017'), /read-only allowlist/);
|
||||
assert.throws(() => assertReadMethod('createImmediateAbsence2017'), /read-only allowlist/);
|
||||
assert.doesNotThrow(() => assertReadMethod('getTimetable2017'));
|
||||
});
|
||||
|
||||
it('names the two failures a person has to act on', async () => {
|
||||
for (const [code, flag] of [
|
||||
[-8504, 'isAuthFailure'],
|
||||
[-8524, 'isClockSkew'],
|
||||
] as const) {
|
||||
stub({ getUserData2017: new Error(String(code)) });
|
||||
const error = await new UntisClient(CONFIG).identity().then(
|
||||
() => undefined,
|
||||
(e: unknown) => e,
|
||||
);
|
||||
assert.ok(error instanceof UntisApiError, `code ${code}`);
|
||||
assert.equal(error.code, code);
|
||||
assert.equal(error[flag], true);
|
||||
}
|
||||
});
|
||||
|
||||
it('caches the identity but not a failure', async () => {
|
||||
const failing = stub({ getUserData2017: new Error('-8504') });
|
||||
const client = new UntisClient(CONFIG);
|
||||
await client.identity().catch(() => undefined);
|
||||
await client.identity().catch(() => undefined);
|
||||
assert.equal(failing.calls.length, 2, 'a rejected key must be retried after it is fixed');
|
||||
|
||||
const working = stub({ getUserData2017: USER_DATA });
|
||||
const cached = new UntisClient(CONFIG);
|
||||
const first = await cached.identity();
|
||||
const second = await cached.identity();
|
||||
assert.equal(working.calls.length, 1);
|
||||
assert.equal(first.displayName, 'Hamm Fabian');
|
||||
assert.deepEqual(second.rights, ['CLASSREGISTER', 'W_OWN_ABSENCE']);
|
||||
});
|
||||
});
|
||||
|
||||
describe('UntisClient: timetable', () => {
|
||||
const load = async (): Promise<UntisTimetable> => {
|
||||
stub({ getUserData2017: USER_DATA, getTimetable2017: { timetable: { periods: PERIODS }, masterData: MASTER } });
|
||||
return new UntisClient(CONFIG).timetable('2026-09-19', '2026-09-21');
|
||||
};
|
||||
|
||||
it('keeps the reported clock time, which is local despite the Z', async () => {
|
||||
const table = await load();
|
||||
const monday = table.days.find((day) => day.date === '2026-09-21');
|
||||
assert.ok(monday);
|
||||
// 08:00Z must stay 08:00: the school's first lesson starts at eight.
|
||||
assert.equal(monday.lessons[0]?.start, '08:00');
|
||||
assert.equal(monday.lessons[0]?.end, '08:45');
|
||||
});
|
||||
|
||||
it('includes days without lessons, with the holiday that explains them', async () => {
|
||||
const table = await load();
|
||||
assert.deepEqual(
|
||||
table.days.map((day) => day.date),
|
||||
['2026-09-19', '2026-09-20', '2026-09-21'],
|
||||
);
|
||||
const sunday = table.days.find((day) => day.date === '2026-09-20');
|
||||
assert.equal(sunday?.lessons.length, 0);
|
||||
assert.equal(sunday?.holidays[0]?.name, 'Weltkindertag');
|
||||
assert.equal(table.days.find((day) => day.date === '2026-09-19')?.holidays.length, 0);
|
||||
});
|
||||
|
||||
it('resolves subjects, teachers, rooms and classes to their names', async () => {
|
||||
const lesson = (await load()).days.at(-1)?.lessons[0];
|
||||
assert.deepEqual(lesson?.subjects, [{ name: 'IT-FIA', longName: 'Fachtheorie FIA' }]);
|
||||
assert.deepEqual(lesson?.teachers, [{ name: 'Ra', longName: 'Kristin Rammelt' }]);
|
||||
assert.deepEqual(lesson?.rooms, [{ name: '42', longName: 'Raum 42' }]);
|
||||
assert.deepEqual(lesson?.classes, [{ name: 'FIA24B', longName: 'Fachinformatiker' }]);
|
||||
assert.equal(lesson?.notes.info, 'Test Projektdoku');
|
||||
assert.equal(lesson?.homework[0]?.due, '2026-09-21');
|
||||
assert.equal(lesson?.periodId, 100);
|
||||
});
|
||||
|
||||
it('marks a cancellation and its replacement, including what was replaced', async () => {
|
||||
const lessons = (await load()).days.at(-1)!.lessons;
|
||||
const cancelled = lessons.find((lesson) => lesson.periodId === 101);
|
||||
const replacement = lessons.find((lesson) => lesson.periodId === 102);
|
||||
assert.equal(cancelled?.cancelled, true);
|
||||
assert.equal(cancelled?.changed, false);
|
||||
assert.equal(replacement?.changed, true);
|
||||
assert.equal(replacement?.cancelled, false);
|
||||
assert.equal(replacement?.notes.substitution, 'Unterricht vom 30.09.2026');
|
||||
// The room carries an orgId of its own: the lesson is in 42 instead of 001,
|
||||
// and `replaced` is the room it moved out of.
|
||||
assert.deepEqual(replacement?.rooms, [{ name: '42', longName: 'Raum 42' }]);
|
||||
assert.deepEqual(replacement?.replaced.rooms, [{ name: '001', longName: 'GR - Praxis' }]);
|
||||
});
|
||||
|
||||
it('sorts a day by clock time', async () => {
|
||||
const starts = (await load()).days.at(-1)!.lessons.map((lesson) => lesson.start);
|
||||
assert.deepEqual(starts, ['08:00', '10:05', '10:05']);
|
||||
});
|
||||
|
||||
it('throws on a timestamp it cannot read rather than dropping the lesson', () => {
|
||||
assert.throws(() => splitLocal('21.09.2026 08:00'), /unexpected format/);
|
||||
assert.deepEqual(splitLocal('2026-09-21T08:00Z'), { date: '2026-09-21', time: '08:00' });
|
||||
});
|
||||
});
|
||||
|
||||
describe('UntisClient: homework and lesson topics', () => {
|
||||
it('resolves a homework subject through its lesson', async () => {
|
||||
stub({
|
||||
getUserData2017: USER_DATA,
|
||||
getHomeWork2017: {
|
||||
homeWorks: [
|
||||
{
|
||||
id: 16_404,
|
||||
lessonId: 71_296,
|
||||
startDate: '2026-08-24',
|
||||
endDate: '2026-08-31',
|
||||
text: 'Material Plakat Kohlbergs Stufenmodell',
|
||||
remark: null,
|
||||
completed: false,
|
||||
attachments: [],
|
||||
},
|
||||
],
|
||||
lessonsById: { '71296': { id: 71_296, subjectId: 130 } },
|
||||
},
|
||||
});
|
||||
const [item] = await new UntisClient(CONFIG).homework('2026-08-01', '2026-09-30');
|
||||
assert.equal(item?.subject?.name, 'Eth');
|
||||
assert.equal(item?.due, '2026-08-31');
|
||||
assert.equal(item?.assigned, '2026-08-24');
|
||||
assert.equal(item?.completed, false);
|
||||
});
|
||||
|
||||
it('reads what previous lessons covered, dropping the empty entries', async () => {
|
||||
stub({
|
||||
getUserData2017: USER_DATA,
|
||||
getLessonTopic2017: {
|
||||
previousTopics: [
|
||||
{ text: 'Projektplanung und Risikoanalyse', periodId: 5, startDateTime: '2026-09-02T11:45Z', endDateTime: '2026-09-02T12:30Z' },
|
||||
{ text: ' ', periodId: 6, startDateTime: '2026-09-01T11:45Z', endDateTime: '2026-09-01T12:30Z' },
|
||||
],
|
||||
},
|
||||
});
|
||||
const topics = await new UntisClient(CONFIG).lessonTopics(100);
|
||||
assert.equal(topics.length, 1);
|
||||
assert.deepEqual(topics[0], {
|
||||
text: 'Projektplanung und Risikoanalyse',
|
||||
periodId: 5,
|
||||
date: '2026-09-02',
|
||||
start: '11:45',
|
||||
end: '12:30',
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('readTimetable', () => {
|
||||
/** Only the two methods the renderer uses, so the formatting is what is tested. */
|
||||
const fake = (days: UntisTimetable['days'], messages: { subject: string; text: string }[] = []) =>
|
||||
({
|
||||
timetable: async (from: string, to: string) => ({
|
||||
from,
|
||||
to,
|
||||
days: days.filter((day) => day.date >= from && day.date <= to),
|
||||
}),
|
||||
messagesOfDay: async () => messages,
|
||||
}) as unknown as UntisClientType;
|
||||
|
||||
const lesson = (over: Partial<UntisTimetable['days'][number]['lessons'][number]> = {}) => ({
|
||||
periodId: 100,
|
||||
lessonId: 900,
|
||||
date: '2026-09-21',
|
||||
start: '08:00',
|
||||
end: '08:45',
|
||||
statuses: ['REGULAR'],
|
||||
cancelled: false,
|
||||
changed: false,
|
||||
subjects: [{ name: 'IT-FIA', longName: 'Fachtheorie FIA' }],
|
||||
teachers: [{ name: 'Ra', longName: 'Kristin Rammelt' }],
|
||||
rooms: [{ name: '42' }],
|
||||
classes: [],
|
||||
replaced: { subjects: [], teachers: [], rooms: [] },
|
||||
notes: {},
|
||||
homework: [],
|
||||
online: false,
|
||||
...over,
|
||||
});
|
||||
|
||||
it('renders a day with its weekday, subject, room, teacher and period id', async () => {
|
||||
const out = await readTimetable(fake([{ date: '2026-09-21', lessons: [lesson()], holidays: [] }]), {
|
||||
from: '2026-09-21',
|
||||
to: '2026-09-21',
|
||||
});
|
||||
assert.match(out, /## Montag, 21\.09\.2026/);
|
||||
assert.match(out, /08:00–08:45 \*\*IT-FIA\*\* \(Fachtheorie FIA\) · Raum 42 · Ra \(Kristin Rammelt\)/);
|
||||
assert.match(out, /`100`/);
|
||||
});
|
||||
|
||||
it('says Entfall, and names who a Vertretung stands in for', async () => {
|
||||
const lessons = [
|
||||
lesson({ periodId: 101, cancelled: true, statuses: ['CANCELLED'] }),
|
||||
lesson({
|
||||
periodId: 102,
|
||||
changed: true,
|
||||
statuses: ['IRREGULAR'],
|
||||
teachers: [{ name: 'Hy', longName: 'Justus Hoyme' }],
|
||||
notes: { substitution: 'Unterricht vom 30.09.2026' },
|
||||
}),
|
||||
];
|
||||
const out = await readTimetable(fake([{ date: '2026-09-21', lessons, holidays: [] }]), {
|
||||
from: '2026-09-21',
|
||||
to: '2026-09-21',
|
||||
});
|
||||
assert.match(out, /\*\*Entfall\*\*/);
|
||||
assert.match(out, /\*\*Vertretung\*\* \(statt Ra\)/);
|
||||
assert.match(out, /Vertretungstext: Unterricht vom 30\.09\.2026/);
|
||||
});
|
||||
|
||||
it('explains an empty day and points at the next one with lessons', async () => {
|
||||
const out = await readTimetable(
|
||||
fake([
|
||||
{ date: '2026-09-17', lessons: [], holidays: [] },
|
||||
{ date: '2026-09-21', lessons: [lesson()], holidays: [] },
|
||||
]),
|
||||
{ from: '2026-09-17', to: '2026-09-17' },
|
||||
);
|
||||
assert.match(out, /No lessons\. Not a holiday either/);
|
||||
assert.match(out, /\*\*Next lessons:\*\* Montag, 21\.09\.2026 \(1 lesson\(s\)\)/);
|
||||
});
|
||||
|
||||
it('names the holiday when there is one, instead of guessing', async () => {
|
||||
const out = await readTimetable(
|
||||
fake([
|
||||
{
|
||||
date: '2026-10-13',
|
||||
lessons: [],
|
||||
holidays: [{ name: 'Herbstferien', longName: 'Herbstferien (12.10.-23.10.)', start: '2026-10-12', end: '2026-10-23' }],
|
||||
},
|
||||
]),
|
||||
{ from: '2026-10-13', to: '2026-10-13' },
|
||||
);
|
||||
assert.match(out, /Herbstferien \(12\.10\.-23\.10\.\)/);
|
||||
assert.match(out, /_No lessons\._/);
|
||||
assert.doesNotMatch(out, /company phase/);
|
||||
});
|
||||
|
||||
it('with changesOnly lists only changed days, and says so when none changed', async () => {
|
||||
const days = [
|
||||
{ date: '2026-09-21', lessons: [lesson()], holidays: [] },
|
||||
{ date: '2026-09-22', lessons: [lesson({ date: '2026-09-22', periodId: 103, cancelled: true })], holidays: [] },
|
||||
];
|
||||
const out = await readTimetable(fake(days), { from: '2026-09-21', to: '2026-09-22', changesOnly: true });
|
||||
assert.match(out, /Dienstag, 22\.09\.2026/);
|
||||
assert.doesNotMatch(out, /Montag, 21\.09\.2026/);
|
||||
|
||||
const quiet = await readTimetable(fake([days[0]!]), { from: '2026-09-21', to: '2026-09-21', changesOnly: true });
|
||||
assert.match(quiet, /Nothing cancelled or changed/);
|
||||
// The next-lessons hint is about lessons, so it has no place here.
|
||||
assert.doesNotMatch(quiet, /Next lessons/);
|
||||
});
|
||||
|
||||
it('appends the Nachrichten des Tages for a single day only', async () => {
|
||||
const day = [{ date: '2026-09-21', lessons: [lesson()], holidays: [] }];
|
||||
const messages = [{ subject: 'Hitzefrei', text: 'Unterrichtsschluss 12:30' }];
|
||||
const single = await readTimetable(fake(day, messages), { from: '2026-09-21', to: '2026-09-21' });
|
||||
assert.match(single, /Nachrichten des Tages/);
|
||||
assert.match(single, /Hitzefrei: Unterrichtsschluss 12:30/);
|
||||
|
||||
const range = await readTimetable(fake(day, messages), { from: '2026-09-21', to: '2026-09-25' });
|
||||
assert.doesNotMatch(range, /Nachrichten des Tages/);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user