Schulcloud says what was uploaded and WebUntis says what was scheduled. Neither says what was *taught* — which point the teacher laboured, which example landed, what "will definitely come up". That lives in two places this server could not reach: the notes the user takes in the lesson, and WebUntis' class register. Notes are a directory of Markdown files (NOTES_DIR), not a table. They have to be writable from a phone in a classroom, readable when Postgres is down, and outlive this project, and files are the only shape that is all three — so the files are the truth and the index is a view of them, the same split as file_texts and the mirror. list_notes and get_note read disk, so they answer before the first crawl; search, what_changed and all three German prompts read them alongside the Schulcloud material. add_note writes one, and is the only thing in this server that writes anything. That is not a hole in the read-only invariant but a different store: it is bounded to NOTES_DIR by the same safeComponent/resolveWithin pair that stops a hostile Schulcloud filename escaping the mirror, so a note titled ../../.ssh/authorized_keys becomes a filename. Schulcloud and WebUntis stay GET-only and allowlisted respectively. NOTES_READONLY refuses writes outright. Appending targets the *lesson*, not the title: "halt das auch noch fest" mid-lesson carries a new title, and deriving the path from it would start a second note every time, which is the one thing append exists to prevent. Notes.app has no export — its bodies are compressed protobuf and the iCloud copy is encrypted — so scripting the app is not the clumsy route to the notes but the only one. scripts/export-apple-notes.js reads them through AppleScript into one JSON object per line, and `schulcloud note import` converts the HTML to Markdown, takes the Notes folder as the subject and the *creation* date as the lesson's date. Attachments cannot come across; a note that was a photo of the board imports as a line saying so, because importing it empty would hide the loss. The class register needed one API property to become cheap: getLessonTopic2017 answers per *series*, not per period, so a term is reconstructed by asking about the latest period of each lesson series and merging back by id — a few dozen calls for a school year rather than one per lesson. untis_lesson_topics now takes a subject as well as a period id, and UNTIS_HISTORY_DAYS of register goes into the index under a kind of its own, so "what did we actually do before the test" is searchable. Sharing the snapshot rather than duplicating it caught one thing on the way: the search tool's live path had to learn notes too, or fresh=true would have quietly disagreed with the index. 305 tests; 88/89 smoke against the local instance, the one failure being the H5P service that instance does not run. The live smoke could not be retaken: that session has lapsed and needs a fresh jwt cookie. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
433 lines
19 KiB
TypeScript
433 lines
19 KiB
TypeScript
/**
|
||
* 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 { collectLessonLog, type LessonLogEntry } from '../../core/untis-history.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;
|
||
|
||
/** How far back a subject's class register is read when no range is given. */
|
||
const DEFAULT_HISTORY_DAYS = 120;
|
||
|
||
/**
|
||
* Longest class-register range.
|
||
*
|
||
* Wider than the timetable's limit on purpose — the point of the subject form
|
||
* is to cover a term or a year, and the payload is one line per lesson that
|
||
* recorded something, not per period. It still needs a ceiling: the range is
|
||
* fetched in 90-day windows plus a call per lesson series, so "since 2019"
|
||
* would be a few hundred requests against a server that rate-limits.
|
||
*/
|
||
const MAX_HISTORY_DAYS = 400;
|
||
|
||
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 lessons actually covered ("Unterrichtsinhalt"), newest first — ' +
|
||
'the teacher\'s own account of each lesson, which exists nowhere in Schulcloud. Two ways in: pass a ' +
|
||
'**subject** ("Deutsch", "LF07") to read back over a whole term, which is how to reconstruct what a ' +
|
||
'course has done and what a test will cover; or pass the **periodId** of one upcoming lesson from ' +
|
||
'untis_timetable to answer "where did we get to" for that series. With a subject it also returns the ' +
|
||
'notes teachers left on those lessons and the homework they set. Says nothing about the material ' +
|
||
'itself — that is Schulcloud — and nothing about what the user wrote down, which is list_notes.',
|
||
inputSchema: {
|
||
subject: z
|
||
.string()
|
||
.optional()
|
||
.describe('Subject name or code, matched as a fragment against both, e.g. "Deutsch" or "LF07".'),
|
||
periodId: z
|
||
.number()
|
||
.int()
|
||
.optional()
|
||
.describe('The period id of one lesson, as untis_timetable prints it in backticks. Covers that series only.'),
|
||
from: dateArgument.optional().describe(`With a subject: earliest day. Defaults to ${DEFAULT_HISTORY_DAYS} days back.`),
|
||
to: dateArgument.optional().describe('With a subject: latest day. Defaults to today.'),
|
||
limit: z.number().int().min(1).max(100).default(20).describe('How many lessons to list.'),
|
||
},
|
||
annotations: READ_ONLY,
|
||
},
|
||
async ({ subject, periodId, from, to, limit }) => {
|
||
if (subject === undefined && periodId === undefined) {
|
||
return failure(
|
||
'Give either a subject ("Deutsch") to read a whole term of the class register, or the periodId of ' +
|
||
'one lesson from untis_timetable to read just its series.',
|
||
);
|
||
}
|
||
if (subject !== undefined && periodId !== undefined) {
|
||
return failure('Give a subject or a periodId, not both: they are two different ways of choosing lessons.');
|
||
}
|
||
|
||
if (periodId !== undefined) {
|
||
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. Try the subject instead — another series of the same ' +
|
||
'subject may be filled 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}`);
|
||
}
|
||
}
|
||
|
||
const end = to ?? schoolToday();
|
||
const start = from ?? addDays(end, -DEFAULT_HISTORY_DAYS);
|
||
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_HISTORY_DAYS) {
|
||
return failure(
|
||
`That is ${daysBetween(start, end)} days of class register. Ask for at most ${MAX_HISTORY_DAYS} — ` +
|
||
'a longer range is fetched in 90-day windows plus a call per lesson series.',
|
||
);
|
||
}
|
||
|
||
try {
|
||
const log = await collectLessonLog(untis, { from: start, to: end, subject: subject! });
|
||
if (log.periodsSeen === 0) {
|
||
return text(
|
||
`No lessons of "${subject}" between ${germanDay(start)} and ${germanDay(end)}. Check the subject ` +
|
||
'against untis_timetable — the register uses the school\'s own codes.',
|
||
);
|
||
}
|
||
if (log.entries.length === 0) {
|
||
return text(
|
||
`${log.periodsSeen} lesson(s) of "${subject}" took place between ${germanDay(start)} and ` +
|
||
`${germanDay(end)}, but nothing was recorded for any of them — this teacher does not fill in the ` +
|
||
'class register. The material in Schulcloud is then the only record; try get_course or search.',
|
||
);
|
||
}
|
||
return text(
|
||
joinSections([
|
||
heading(2, `Unterricht „${subject}“ — ${germanDay(start)} bis ${germanDay(end)}`),
|
||
`_${log.entries.length} of ${log.periodsSeen} lesson(s) have an entry in the class register._`,
|
||
log.entries.slice(0, limit).map(formatLogEntry).join('\n'),
|
||
log.entries.length > limit
|
||
? `_${log.entries.length - limit} older lesson(s) not shown — raise limit or narrow the range._`
|
||
: undefined,
|
||
log.failures.length > 0
|
||
? `_${log.failures.length} lesson series could not be read, so some entries may be missing._`
|
||
: undefined,
|
||
]),
|
||
);
|
||
} catch (error) {
|
||
return untisError(error, `read the class register for "${subject}"`);
|
||
}
|
||
},
|
||
);
|
||
}
|
||
|
||
/** One class-register entry: the topic, what the teacher noted, and what was set. */
|
||
function formatLogEntry(entry: LessonLogEntry): string {
|
||
const teachers = entry.teachers.length > 0 ? ` · ${entry.teachers.join(', ')}` : '';
|
||
const extra = [
|
||
entry.notes.info,
|
||
entry.notes.lesson,
|
||
entry.notes.substitution ? `Vertretungstext: ${entry.notes.substitution}` : undefined,
|
||
entry.exam ? `**Prüfung:** ${entry.exam}` : undefined,
|
||
...entry.homework.map((item) => `Hausaufgabe bis ${germanDay(item.due)}: ${item.text}`),
|
||
].filter((value): value is string => Boolean(value));
|
||
const head = `- **${germanDay(entry.date)}** ${entry.start}–${entry.end}${teachers} \`${entry.periodId}\`` +
|
||
`${entry.topic ? `: ${entry.topic}` : ''}`;
|
||
return extra.length > 0 ? `${head}\n${extra.map((line) => ` - ${line}`).join('\n')}` : head;
|
||
}
|
||
|
||
/**
|
||
* 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);
|
||
}
|