/** * 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 | 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(method: string, params: Record): Promise { // 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 { this.identityPromise ??= this.call('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 { const me = await this.identity(); const raw = await this.call('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 { const me = await this.identity(); const raw = await this.call<{ homeWorks?: RawHomework[]; lessonsById?: Record; }>('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 { 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 { 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 { if (this.masterData && Date.now() - this.masterData.at < MASTER_DATA_TTL_MS) return this.masterData.data; const raw = await this.call('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 { 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; }