diff --git a/config/skyview.sql b/config/skyview.sql
index 1263889..5ad536e 100644
--- a/config/skyview.sql
+++ b/config/skyview.sql
@@ -28,6 +28,7 @@ CREATE TABLE IF NOT EXISTS `app_users` (
`display_name` varchar(100) DEFAULT NULL,
`telegram_username` varchar(32) DEFAULT NULL,
`telegram_chat_id` varchar(20) DEFAULT NULL,
+ `telegram_tcrb_v_enabled` tinyint(1) NOT NULL DEFAULT 0,
`telegram_link_token` varchar(64) DEFAULT NULL,
`telegram_link_token_expires_at` datetime DEFAULT NULL,
`telegram_connected_at` datetime DEFAULT NULL,
diff --git a/php-server.err.log b/php-server.err.log
index 1754cec..4e1b150 100644
--- a/php-server.err.log
+++ b/php-server.err.log
@@ -480,3 +480,51 @@
[Sun Apr 5 09:52:06 2026] 127.0.0.1:59734 Closing
[Sun Apr 5 09:52:07 2026] 127.0.0.1:58998 [200]: GET /favicon.ico
[Sun Apr 5 09:52:07 2026] 127.0.0.1:58998 Closing
+[Sun Apr 5 10:13:34 2026] 127.0.0.1:53792 Accepted
+[Sun Apr 5 10:13:34 2026] 127.0.0.1:53792 [200]: GET /settings.php
+[Sun Apr 5 10:13:34 2026] 127.0.0.1:53792 Closing
+[Sun Apr 5 10:13:34 2026] 127.0.0.1:61142 Accepted
+[Sun Apr 5 10:13:34 2026] 127.0.0.1:61142 [200]: GET /css/style.css
+[Sun Apr 5 10:13:34 2026] 127.0.0.1:61142 Closing
+[Sun Apr 5 10:13:35 2026] 127.0.0.1:65202 Accepted
+[Sun Apr 5 10:13:35 2026] 127.0.0.1:65202 [200]: GET /images/star16x16.png
+[Sun Apr 5 10:13:35 2026] 127.0.0.1:65202 Closing
+[Sun Apr 5 10:13:35 2026] 127.0.0.1:54479 Accepted
+[Sun Apr 5 10:13:35 2026] 127.0.0.1:57464 Accepted
+[Sun Apr 5 10:13:35 2026] 127.0.0.1:54479 [200]: GET /images/star16x16_rays.png
+[Sun Apr 5 10:13:35 2026] 127.0.0.1:54479 Closing
+[Sun Apr 5 10:13:36 2026] 127.0.0.1:57464 [200]: GET /favicon.ico
+[Sun Apr 5 10:13:36 2026] 127.0.0.1:57464 Closing
+[Sun Apr 5 10:13:44 2026] 127.0.0.1:61964 Accepted
+[Sun Apr 5 10:13:44 2026] 127.0.0.1:61964 [200]: POST /settings.php
+[Sun Apr 5 10:13:44 2026] 127.0.0.1:61964 Closing
+[Sun Apr 5 10:13:44 2026] 127.0.0.1:64453 Accepted
+[Sun Apr 5 10:13:44 2026] 127.0.0.1:64453 [200]: GET /css/style.css
+[Sun Apr 5 10:13:44 2026] 127.0.0.1:64453 Closing
+[Sun Apr 5 10:13:44 2026] 127.0.0.1:59661 Accepted
+[Sun Apr 5 10:13:44 2026] 127.0.0.1:59661 [200]: GET /images/star16x16.png
+[Sun Apr 5 10:13:44 2026] 127.0.0.1:59661 Closing
+[Sun Apr 5 10:13:44 2026] 127.0.0.1:52840 Accepted
+[Sun Apr 5 10:13:44 2026] 127.0.0.1:53881 Accepted
+[Sun Apr 5 10:13:44 2026] 127.0.0.1:52840 [200]: GET /images/star16x16_rays.png
+[Sun Apr 5 10:13:44 2026] 127.0.0.1:52840 Closing
+[Sun Apr 5 10:13:45 2026] 127.0.0.1:53881 [200]: GET /favicon.ico
+[Sun Apr 5 10:13:45 2026] 127.0.0.1:53881 Closing
+[Sun Apr 5 10:15:52 2026] 127.0.0.1:58596 Accepted
+[Sun Apr 5 10:15:52 2026] 127.0.0.1:58596 [200]: GET /tcrb_lightcurve.php
+[Sun Apr 5 10:15:52 2026] 127.0.0.1:58596 Closing
+[Sun Apr 5 10:15:52 2026] 127.0.0.1:56812 Accepted
+[Sun Apr 5 10:15:52 2026] 127.0.0.1:56812 [200]: GET /css/style.css
+[Sun Apr 5 10:15:52 2026] 127.0.0.1:56812 Closing
+[Sun Apr 5 10:15:52 2026] 127.0.0.1:50508 Accepted
+[Sun Apr 5 10:15:52 2026] 127.0.0.1:61831 Accepted
+[Sun Apr 5 10:15:52 2026] 127.0.0.1:50508 [200]: GET /images/star16x16.png
+[Sun Apr 5 10:15:52 2026] 127.0.0.1:61831 [200]: GET /images/star16x16_rays.png
+[Sun Apr 5 10:15:52 2026] 127.0.0.1:50508 Closing
+[Sun Apr 5 10:15:52 2026] 127.0.0.1:61831 Closing
+[Sun Apr 5 10:15:53 2026] 127.0.0.1:56478 Accepted
+[Sun Apr 5 10:15:53 2026] 127.0.0.1:56478 [200]: GET /favicon.ico
+[Sun Apr 5 10:15:53 2026] 127.0.0.1:56478 Closing
+[Sun Apr 5 10:15:53 2026] 127.0.0.1:61416 Accepted
+[Sun Apr 5 10:18:37 2026] 127.0.0.1:61416 Closed without sending a request; it was probably just an unused speculative preconnection
+[Sun Apr 5 10:18:37 2026] 127.0.0.1:61416 Closing
diff --git a/public/settings.php b/public/settings.php
index 3898429..423e654 100644
--- a/public/settings.php
+++ b/public/settings.php
@@ -52,6 +52,7 @@ $stmtUser = $pdo->prepare("
`display_name`,
`telegram_username`,
`telegram_chat_id`,
+ `telegram_tcrb_v_enabled`,
`telegram_link_token`,
`telegram_link_token_expires_at`,
`telegram_connected_at`,
@@ -151,6 +152,7 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST') {
SET
`telegram_username` = NULL,
`telegram_chat_id` = NULL,
+ `telegram_tcrb_v_enabled` = 0,
`telegram_link_token` = NULL,
`telegram_link_token_expires_at` = NULL,
`telegram_connected_at` = NULL,
@@ -164,6 +166,32 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST') {
$successMessage = 'Die Telegram-Verknuepfung wurde entfernt.';
}
+ if ($action === 'update_tcrb_telegram_notifications') {
+ $enabled = isset($_POST['telegram_tcrb_v_enabled']) ? 1 : 0;
+
+ if ($enabled === 1 && empty($user['telegram_chat_id'])) {
+ $errors[] = 'Bitte zuerst Telegram verbinden, bevor du T-CrB-Nachrichten aktivierst.';
+ }
+
+ if (empty($errors)) {
+ $stmtNotification = $pdo->prepare("
+ UPDATE `app_users`
+ SET
+ `telegram_tcrb_v_enabled` = :enabled,
+ `updated_at` = NOW()
+ WHERE `id` = :id
+ ");
+ $stmtNotification->execute([
+ ':enabled' => $enabled,
+ ':id' => $userId,
+ ]);
+
+ $successMessage = $enabled === 1
+ ? 'T-CrB-Telegram-Nachrichten wurden aktiviert.'
+ : 'T-CrB-Telegram-Nachrichten wurden deaktiviert.';
+ }
+ }
+
if ($action === 'change_password') {
$currentPassword = $_POST['current_password'] ?? '';
$newPassword = $_POST['new_password'] ?? '';
@@ -219,6 +247,7 @@ $loggedIn = true;
$publicBasePath = './';
$telegramBotUsername = trim((string) ($telegramConfig['bot_username'] ?? ''));
$telegramHasConnection = !empty($user['telegram_chat_id']);
+$telegramTcrbVEnabled = !empty($user['telegram_tcrb_v_enabled']);
$telegramLinkToken = trim((string) ($user['telegram_link_token'] ?? ''));
$telegramLinkExpiresAt = !empty($user['telegram_link_token_expires_at'])
? new DateTimeImmutable((string) $user['telegram_link_token_expires_at'])
@@ -291,6 +320,8 @@ $telegramDeepLink = $telegramLinkIsActive && $telegramBotUsername !== ''
| Verbunden seit | = h($user['telegram_connected_at'] ?? '—') ?> |
+ T CrB letzter V-Wert: = $telegramTcrbVEnabled ? 'Aktiv' : 'Aus' ?>
+
Aktiver Verbindungscode bis = h($telegramLinkExpiresAt?->format('d.m.Y H:i')) ?>:
@@ -327,6 +358,24 @@ $telegramDeepLink = $telegramLinkIsActive && $telegramBotUsername !== ''
+
+
diff --git a/scripts/telegram/telegram_cron.php b/scripts/telegram/telegram_cron.php
index ef2d27e..96e1448 100644
--- a/scripts/telegram/telegram_cron.php
+++ b/scripts/telegram/telegram_cron.php
@@ -19,6 +19,7 @@ set_time_limit(0);
const IMPORT_RUN_SOURCE = 'telegram-cron';
$rootDir = dirname(__DIR__, 2);
+$stateFile = __DIR__ . DIRECTORY_SEPARATOR . 'telegram_cron_state.json';
$dbConfig = require $rootDir . DIRECTORY_SEPARATOR . 'config' . DIRECTORY_SEPARATOR . 'database.php';
$telegramConfig = require $rootDir . DIRECTORY_SEPARATOR . 'config' . DIRECTORY_SEPARATOR . 'telegram.php';
@@ -54,6 +55,115 @@ $nowBerlin = new DateTimeImmutable('now', $timezoneBerlin);
$nowUtc = $nowBerlin->setTimezone($timezoneUtc);
$tasks = [
+ [
+ 'key' => 'send_tcrb_latest_v',
+ 'label' => 'Letzten T-CrB-Wert mit Filter V senden',
+ 'enabled' => true,
+ 'schedule' => 'every-minute',
+ 'handler' => static function (PDO $pdo, array $telegramConfig, DateTimeImmutable $nowBerlin) use ($stateFile): array {
+ $latestObservation = fetchLatestTcrbObservationByFilter($pdo, 'V');
+ if ($latestObservation === null) {
+ return [
+ 'sent' => 0,
+ 'updated' => 0,
+ 'message' => 'Keine T-CrB-Beobachtung mit Filter V gefunden.',
+ ];
+ }
+
+ $state = loadCronState($stateFile);
+ $taskKey = 'send_tcrb_latest_v';
+ $currentSignature = buildTcrbObservationSignature($latestObservation);
+ $lastSignature = (string) (($state[$taskKey]['last_signature'] ?? ''));
+
+ if ($currentSignature !== '' && $currentSignature === $lastSignature) {
+ return [
+ 'sent' => 0,
+ 'updated' => 0,
+ 'message' => 'Kein neuer V-Messpunkt seit der letzten Telegram-Nachricht.',
+ ];
+ }
+
+ $recipients = fetchTelegramRecipients($pdo);
+ if ($recipients === []) {
+ return [
+ 'sent' => 0,
+ 'updated' => 0,
+ 'message' => 'Keine verbundenen Telegram-Empfaenger vorhanden.',
+ ];
+ }
+
+ $messageText = buildTcrbLatestVMessage($latestObservation, $nowBerlin);
+ $chartObservations = fetchRecentTcrbObservationsByFilter($pdo, 'V', 80);
+ $chartSvg = buildTcrbVChartSvg($chartObservations, $latestObservation);
+ $chartPackage = createTelegramChartPackage($chartSvg);
+ $sentCount = 0;
+ $chartSentCount = 0;
+
+ try {
+ foreach ($recipients as $recipient) {
+ $chatId = trim((string) ($recipient['telegram_chat_id'] ?? ''));
+ if ($chatId === '') {
+ continue;
+ }
+
+ $messageSent = telegramSendBotMessage($telegramConfig, $chatId, $messageText);
+ $chartSent = false;
+ if (($chartPackage['kind'] ?? '') === 'photo') {
+ $chartSent = telegramSendBotPhoto(
+ $telegramConfig,
+ $chatId,
+ (string) $chartPackage['path'],
+ 'T CrB V-Diagramm'
+ );
+ } elseif (($chartPackage['kind'] ?? '') === 'document') {
+ $chartSent = telegramSendBotDocument(
+ $telegramConfig,
+ $chatId,
+ (string) $chartPackage['path'],
+ 'T CrB V-Diagramm'
+ );
+ }
+
+ if ($chartSent) {
+ $chartSentCount++;
+ }
+
+ if ($messageSent || $chartSent) {
+ $sentCount++;
+ }
+ }
+ } finally {
+ cleanupTelegramChartPackage($chartPackage);
+ }
+
+ if ($sentCount < 1) {
+ return [
+ 'sent' => 0,
+ 'updated' => 0,
+ 'message' => 'Telegram-Versand konnte keinen Empfaenger erfolgreich erreichen.',
+ ];
+ }
+
+ $state[$taskKey] = [
+ 'last_signature' => $currentSignature,
+ 'last_sent_at' => $nowBerlin->format(DateTimeInterface::ATOM),
+ 'last_observed_at_utc' => (string) ($latestObservation['observed_at_utc'] ?? ''),
+ 'last_filter' => 'V',
+ ];
+ saveCronState($stateFile, $state);
+
+ return [
+ 'sent' => $sentCount,
+ 'updated' => 0,
+ 'message' => sprintf(
+ 'T-CrB V-Messpunkt %s an %d Telegram-Empfaenger gesendet, Diagramm an %d.',
+ (string) ($latestObservation['observed_at_utc'] ?? 'unbekannt'),
+ $sentCount,
+ $chartSentCount
+ ),
+ ];
+ },
+ ],
[
'key' => 'cleanup_expired_link_tokens',
'label' => 'Abgelaufene Telegram-Verknuepfungscodes aufraeumen',
@@ -289,8 +399,701 @@ function finishImportRun(
]);
}
+function fetchLatestTcrbObservationByFilter(PDO $pdo, string $filterCode): ?array
+{
+ $stmt = $pdo->prepare("
+ SELECT
+ `id`,
+ `observed_at_utc`,
+ `jd`,
+ `magnitude`,
+ `magnitude_error`,
+ `filter_code`,
+ `observer_code`
+ FROM `aavso_tcrb_observations`
+ WHERE `filter_code` = :filter_code
+ ORDER BY `observed_at_utc` DESC, `id` DESC
+ LIMIT 1
+ ");
+ $stmt->execute([':filter_code' => $filterCode]);
+ $row = $stmt->fetch();
+
+ return is_array($row) ? $row : null;
+}
+
+function fetchRecentTcrbObservationsByFilter(PDO $pdo, string $filterCode, int $limit = 80): array
+{
+ $stmt = $pdo->prepare("
+ SELECT
+ `id`,
+ `observed_at_utc`,
+ `jd`,
+ `magnitude`,
+ `magnitude_error`,
+ `filter_code`,
+ `observer_code`
+ FROM `aavso_tcrb_observations`
+ WHERE `filter_code` = :filter_code
+ ORDER BY `observed_at_utc` DESC, `id` DESC
+ LIMIT :row_limit
+ ");
+ $stmt->bindValue(':filter_code', $filterCode, PDO::PARAM_STR);
+ $stmt->bindValue(':row_limit', max(5, $limit), PDO::PARAM_INT);
+ $stmt->execute();
+ $rows = $stmt->fetchAll();
+
+ usort($rows, static function (array $a, array $b): int {
+ return strcmp((string) ($a['observed_at_utc'] ?? ''), (string) ($b['observed_at_utc'] ?? ''));
+ });
+
+ return $rows;
+}
+
+function fetchTelegramRecipients(PDO $pdo): array
+{
+ $stmt = $pdo->query("
+ SELECT `id`, `telegram_chat_id`, `telegram_username`
+ FROM `app_users`
+ WHERE `telegram_chat_id` IS NOT NULL
+ AND `telegram_chat_id` <> ''
+ AND `telegram_tcrb_v_enabled` = 1
+ AND `is_active` = 1
+ ORDER BY `id` ASC
+ ");
+
+ return $stmt->fetchAll();
+}
+
+function buildTcrbLatestVMessage(array $observation, DateTimeImmutable $nowBerlin): string
+{
+ $observedUtc = trim((string) ($observation['observed_at_utc'] ?? ''));
+ $observedText = $observedUtc !== '' ? $observedUtc . ' UTC' : 'unbekannt';
+ $magnitude = formatDecimal($observation['magnitude'] ?? null, 3);
+ $magnitudeError = $observation['magnitude_error'] !== null
+ ? ' +/- ' . formatDecimal($observation['magnitude_error'], 3)
+ : '';
+ $jd = formatDecimal($observation['jd'] ?? null, 6);
+ $observerCode = trim((string) ($observation['observer_code'] ?? ''));
+
+ $lines = [
+ 'SkyView T CrB Update',
+ '',
+ 'Magnitude: ' . $magnitude . $magnitudeError,
+ 'Zeitpunkt: ' . $observedText,
+ 'JD: ' . $jd,
+ ];
+
+ if ($observerCode !== '') {
+ $lines[] = 'Beobachter: ' . $observerCode;
+ }
+
+ $lines[] = '';
+ $lines[] = 'Stand: ' . $nowBerlin->format('d.m.Y H:i') . ' Europe/Berlin';
+
+ return implode("\n", $lines);
+}
+
+function buildTcrbVChartSvg(array $observations, array $latestObservation): string
+{
+ $width = 1200;
+ $height = 700;
+ $paddingLeft = 90;
+ $paddingRight = 40;
+ $paddingTop = 80;
+ $paddingBottom = 90;
+ $plotWidth = $width - $paddingLeft - $paddingRight;
+ $plotHeight = $height - $paddingTop - $paddingBottom;
+ $plotBottomY = $paddingTop + $plotHeight;
+ $plotRightX = $paddingLeft + $plotWidth;
+
+ if (count($observations) < 2) {
+ $observations = [$latestObservation];
+ }
+
+ $timestamps = [];
+ $magnitudes = [];
+ foreach ($observations as $row) {
+ $timestamps[] = strtotime((string) ($row['observed_at_utc'] ?? ''));
+ $magnitudes[] = (float) ($row['magnitude'] ?? 0.0);
+ }
+
+ $timestamps = array_values(array_filter($timestamps, static fn ($value): bool => is_int($value) || is_float($value)));
+ if ($timestamps === []) {
+ $timestamps = [time() - 3600, time()];
+ }
+
+ $minX = (float) min($timestamps);
+ $maxX = (float) max($timestamps);
+ if ($maxX <= $minX) {
+ $maxX = $minX + 3600.0;
+ }
+
+ $minY = min($magnitudes);
+ $maxY = max($magnitudes);
+ if ($maxY <= $minY) {
+ $maxY = $minY + 0.5;
+ }
+
+ $paddingMag = max(0.06, ($maxY - $minY) * 0.10);
+ $axisMinY = $minY - $paddingMag;
+ $axisMaxY = $maxY + $paddingMag;
+
+ $scaleX = static function (float $timestamp) use ($minX, $maxX, $paddingLeft, $plotWidth): float {
+ return $paddingLeft + (($timestamp - $minX) / ($maxX - $minX)) * $plotWidth;
+ };
+
+ $scaleY = static function (float $magnitude) use ($axisMinY, $axisMaxY, $paddingTop, $plotHeight): float {
+ $ratio = ($magnitude - $axisMinY) / ($axisMaxY - $axisMinY);
+ return $paddingTop + $ratio * $plotHeight;
+ };
+
+ $gridLines = [];
+ for ($i = 0; $i <= 4; $i++) {
+ $gridMag = $axisMinY + (($axisMaxY - $axisMinY) / 4) * $i;
+ $y = $scaleY($gridMag);
+ $gridLines[] = sprintf(
+ '',
+ $paddingLeft,
+ $y,
+ $paddingLeft + $plotWidth
+ );
+ $gridLines[] = sprintf(
+ '%3$s',
+ $paddingLeft - 14,
+ $y,
+ htmlspecialchars(number_format($gridMag, 2, '.', ''), ENT_QUOTES, 'UTF-8')
+ );
+ }
+
+ $pathParts = [];
+ $errorBars = [];
+ $pointsSvg = [];
+
+ foreach ($observations as $index => $row) {
+ $timestamp = strtotime((string) ($row['observed_at_utc'] ?? ''));
+ if ($timestamp === false) {
+ continue;
+ }
+
+ $magnitude = (float) ($row['magnitude'] ?? 0.0);
+ $x = $scaleX((float) $timestamp);
+ $y = $scaleY($magnitude);
+ $pathParts[] = sprintf('%s %.2f %.2f', $index === 0 ? 'M' : 'L', $x, $y);
+
+ $error = isset($row['magnitude_error']) && $row['magnitude_error'] !== null
+ ? (float) $row['magnitude_error']
+ : 0.0;
+ if ($error > 0) {
+ $yTop = $scaleY($magnitude - $error);
+ $yBottom = $scaleY($magnitude + $error);
+ $errorBars[] = sprintf(
+ '' .
+ '' .
+ '',
+ $x,
+ $yTop,
+ $yBottom,
+ $x - 7,
+ $x + 7
+ );
+ }
+
+ $isLatest = (string) ($row['observed_at_utc'] ?? '') === (string) ($latestObservation['observed_at_utc'] ?? '')
+ && (string) ($row['jd'] ?? '') === (string) ($latestObservation['jd'] ?? '');
+
+ $pointsSvg[] = sprintf(
+ '',
+ $x,
+ $y,
+ $isLatest ? 7.0 : 4.2,
+ $isLatest ? '#f4d35e' : '#7bd389',
+ $isLatest ? '#fff3bf' : '#d9f99d',
+ $isLatest ? 2.6 : 1.4
+ );
+ }
+
+ $firstLabel = date('d.m.Y H:i', (int) $minX) . ' UTC';
+ $lastLabel = date('d.m.Y H:i', (int) $maxX) . ' UTC';
+ $latestMagnitude = number_format((float) ($latestObservation['magnitude'] ?? 0.0), 3, '.', '');
+ $latestError = isset($latestObservation['magnitude_error']) && $latestObservation['magnitude_error'] !== null
+ ? ' +/- ' . number_format((float) $latestObservation['magnitude_error'], 3, '.', '')
+ : '';
+ $latestUtc = htmlspecialchars((string) ($latestObservation['observed_at_utc'] ?? ''), ENT_QUOTES, 'UTF-8');
+ $gridSvg = implode("\n ", $gridLines);
+ $errorBarsSvg = implode("\n ", $errorBars);
+ $pathSvg = implode(' ', $pathParts);
+ $pointsSvgMarkup = implode("\n ", $pointsSvg);
+
+ return sprintf(
+ <<<'SVG'
+
+SVG,
+ $width,
+ $height,
+ $width,
+ $height,
+ $width,
+ $height,
+ $paddingLeft,
+ $paddingTop,
+ $plotWidth,
+ $plotHeight,
+ $paddingLeft,
+ $paddingLeft,
+ $paddingLeft,
+ $height - 28,
+ htmlspecialchars($firstLabel, ENT_QUOTES, 'UTF-8'),
+ $width - $paddingRight,
+ $height - 28,
+ htmlspecialchars($lastLabel, ENT_QUOTES, 'UTF-8'),
+ $width - $paddingRight,
+ htmlspecialchars($latestMagnitude, ENT_QUOTES, 'UTF-8'),
+ htmlspecialchars($latestError, ENT_QUOTES, 'UTF-8'),
+ $width - $paddingRight,
+ $latestUtc,
+ $gridSvg,
+ $paddingLeft,
+ $paddingTop,
+ $paddingLeft,
+ $plotBottomY,
+ $paddingLeft,
+ $plotBottomY,
+ $plotRightX,
+ $plotBottomY,
+ $errorBarsSvg,
+ $pathSvg,
+ $pointsSvgMarkup
+ );
+}
+
+function buildTcrbObservationSignature(array $observation): string
+{
+ return implode('|', [
+ (string) ($observation['observed_at_utc'] ?? ''),
+ (string) ($observation['jd'] ?? ''),
+ (string) ($observation['magnitude'] ?? ''),
+ (string) ($observation['magnitude_error'] ?? ''),
+ (string) ($observation['filter_code'] ?? ''),
+ (string) ($observation['observer_code'] ?? ''),
+ ]);
+}
+
+function loadCronState(string $stateFile): array
+{
+ if (!is_file($stateFile)) {
+ return [];
+ }
+
+ $raw = @file_get_contents($stateFile);
+ if ($raw === false || trim($raw) === '') {
+ return [];
+ }
+
+ $decoded = json_decode($raw, true);
+
+ return is_array($decoded) ? $decoded : [];
+}
+
+function saveCronState(string $stateFile, array $state): void
+{
+ $json = json_encode($state, JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
+ if ($json === false) {
+ throw new RuntimeException('Cron-Statusdatei konnte nicht als JSON gespeichert werden.');
+ }
+
+ if (@file_put_contents($stateFile, $json . PHP_EOL, LOCK_EX) === false) {
+ throw new RuntimeException('Cron-Statusdatei konnte nicht geschrieben werden: ' . $stateFile);
+ }
+}
+
+function createTelegramChartPackage(string $svgContent): array
+{
+ $directory = sys_get_temp_dir() . DIRECTORY_SEPARATOR . 'skyview_telegram_charts';
+ if (!is_dir($directory) && !mkdir($directory, 0775, true) && !is_dir($directory)) {
+ throw new RuntimeException('Temp-Verzeichnis fuer Telegram-Diagramme konnte nicht erstellt werden.');
+ }
+
+ $baseName = 'tcrb_v_latest_chart_' . date('Ymd_His') . '_' . bin2hex(random_bytes(4));
+ $svgPath = $directory . DIRECTORY_SEPARATOR . $baseName . '.svg';
+ if (@file_put_contents($svgPath, $svgContent) === false) {
+ throw new RuntimeException('Diagrammdatei konnte nicht geschrieben werden: ' . $svgPath);
+ }
+
+ $pngPath = $directory . DIRECTORY_SEPARATOR . $baseName . '.png';
+ if (convertSvgToPng($svgPath, $pngPath)) {
+ return [
+ 'kind' => 'photo',
+ 'path' => $pngPath,
+ 'cleanup' => [$svgPath, $pngPath],
+ ];
+ }
+
+ return [
+ 'kind' => 'document',
+ 'path' => $svgPath,
+ 'cleanup' => [$svgPath],
+ ];
+}
+
+function cleanupTelegramChartPackage(array $chartPackage): void
+{
+ $files = is_array($chartPackage['cleanup'] ?? null) ? $chartPackage['cleanup'] : [];
+ foreach ($files as $file) {
+ if (is_string($file) && $file !== '') {
+ @unlink($file);
+ }
+ }
+}
+
+function convertSvgToPng(string $svgPath, string $pngPath): bool
+{
+ if (!is_file($svgPath)) {
+ return false;
+ }
+
+ $commands = [
+ sprintf('rsvg-convert -o %s %s', escapeshellarg($pngPath), escapeshellarg($svgPath)),
+ sprintf('magick %s %s', escapeshellarg($svgPath), escapeshellarg($pngPath)),
+ sprintf('convert %s %s', escapeshellarg($svgPath), escapeshellarg($pngPath)),
+ ];
+
+ foreach ($commands as $command) {
+ if (!function_exists('shell_exec')) {
+ continue;
+ }
+
+ @shell_exec($command . ' 2>&1');
+ if (is_file($pngPath) && filesize($pngPath) > 0) {
+ return true;
+ }
+ }
+
+ return false;
+}
+
+function telegramHttpPostJson(string $url, array $payload): array
+{
+ $jsonPayload = json_encode($payload, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
+ if ($jsonPayload === false) {
+ return [
+ 'ok' => false,
+ 'error' => 'Die Telegram-Anfrage konnte nicht als JSON kodiert werden.',
+ 'body' => null,
+ ];
+ }
+
+ if (DIRECTORY_SEPARATOR === '\\' && function_exists('shell_exec')) {
+ $urlBase64 = base64_encode($url);
+ $payloadBase64 = base64_encode($jsonPayload);
+ $powerShellScript = str_replace(
+ ['__URL_BASE64__', '__PAYLOAD_BASE64__'],
+ [$urlBase64, $payloadBase64],
+ <<<'PS'
+$ErrorActionPreference = 'Stop'
+$url = [System.Text.Encoding]::UTF8.GetString([System.Convert]::FromBase64String('__URL_BASE64__'))
+$body = [System.Text.Encoding]::UTF8.GetString([System.Convert]::FromBase64String('__PAYLOAD_BASE64__'))
+try {
+ $response = Invoke-RestMethod -Method Post -Uri $url -ContentType 'application/json' -Body $body
+ [PSCustomObject]@{
+ ok = $true
+ body = ($response | ConvertTo-Json -Depth 20 -Compress)
+ error = ''
+ } | ConvertTo-Json -Compress
+} catch {
+ $stream = $null
+ $responseBody = ''
+ if ($_.Exception.Response) {
+ try {
+ $stream = $_.Exception.Response.GetResponseStream()
+ if ($stream) {
+ $reader = New-Object System.IO.StreamReader($stream)
+ $responseBody = $reader.ReadToEnd()
+ }
+ } catch {
+ } finally {
+ if ($stream) {
+ $stream.Dispose()
+ }
+ }
+ }
+ [PSCustomObject]@{
+ ok = $false
+ body = $responseBody
+ error = $_.Exception.Message
+ } | ConvertTo-Json -Compress
+}
+PS
+ );
+ $encodedCommandBinary = function_exists('iconv')
+ ? iconv('UTF-8', 'UTF-16LE', $powerShellScript)
+ : $powerShellScript;
+ $encodedCommand = base64_encode($encodedCommandBinary === false ? $powerShellScript : $encodedCommandBinary);
+ $command = 'powershell -NoProfile -ExecutionPolicy Bypass -EncodedCommand ' . $encodedCommand;
+ $rawOutput = shell_exec($command);
+
+ if ($rawOutput !== null && trim($rawOutput) !== '') {
+ $decodedShell = json_decode(trim($rawOutput), true);
+ if (is_array($decodedShell)) {
+ return [
+ 'ok' => (bool) ($decodedShell['ok'] ?? false),
+ 'error' => $decodedShell['error'] ?? null,
+ 'body' => $decodedShell['body'] ?? null,
+ ];
+ }
+ }
+ }
+
+ if (function_exists('curl_init')) {
+ $ch = curl_init($url);
+ curl_setopt_array($ch, [
+ CURLOPT_POST => true,
+ CURLOPT_RETURNTRANSFER => true,
+ CURLOPT_HTTPHEADER => [
+ 'Content-Type: application/json',
+ 'Content-Length: ' . strlen($jsonPayload),
+ ],
+ CURLOPT_POSTFIELDS => $jsonPayload,
+ CURLOPT_TIMEOUT => 20,
+ ]);
+
+ $body = curl_exec($ch);
+ $error = curl_error($ch);
+ curl_close($ch);
+
+ return [
+ 'ok' => $body !== false,
+ 'error' => $body === false ? $error : null,
+ 'body' => $body !== false ? $body : null,
+ ];
+ }
+
+ $context = stream_context_create([
+ 'http' => [
+ 'method' => 'POST',
+ 'header' => implode("\r\n", [
+ 'Content-Type: application/json',
+ 'Content-Length: ' . strlen($jsonPayload),
+ ]),
+ 'content' => $jsonPayload,
+ 'timeout' => 20,
+ 'ignore_errors' => true,
+ ],
+ ]);
+
+ $body = @file_get_contents($url, false, $context);
+
+ return [
+ 'ok' => $body !== false,
+ 'error' => $body === false ? 'HTTP-Request fehlgeschlagen.' : null,
+ 'body' => $body !== false ? $body : null,
+ ];
+}
+
+function telegramHttpPostMultipart(string $url, array $fields, string $fileField, string $filePath, ?string $fileName = null): array
+{
+ if (!is_file($filePath)) {
+ return [
+ 'ok' => false,
+ 'error' => 'Datei fuer Telegram-Versand nicht gefunden: ' . $filePath,
+ 'body' => null,
+ ];
+ }
+
+ if (DIRECTORY_SEPARATOR === '\\' && function_exists('shell_exec')) {
+ $urlBase64 = base64_encode($url);
+ $filePathBase64 = base64_encode($filePath);
+ $fileNameBase64 = base64_encode((string) ($fileName ?? basename($filePath)));
+ $fieldsBase64 = base64_encode(json_encode($fields, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES) ?: '{}');
+ $fileFieldBase64 = base64_encode($fileField);
+
+ $powerShellScript = str_replace(
+ ['__URL_BASE64__', '__FILE_PATH_BASE64__', '__FILE_NAME_BASE64__', '__FIELDS_BASE64__', '__FILE_FIELD_BASE64__'],
+ [$urlBase64, $filePathBase64, $fileNameBase64, $fieldsBase64, $fileFieldBase64],
+ <<<'PS'
+$ErrorActionPreference = 'Stop'
+$url = [System.Text.Encoding]::UTF8.GetString([System.Convert]::FromBase64String('__URL_BASE64__'))
+$filePath = [System.Text.Encoding]::UTF8.GetString([System.Convert]::FromBase64String('__FILE_PATH_BASE64__'))
+$fileName = [System.Text.Encoding]::UTF8.GetString([System.Convert]::FromBase64String('__FILE_NAME_BASE64__'))
+$fileField = [System.Text.Encoding]::UTF8.GetString([System.Convert]::FromBase64String('__FILE_FIELD_BASE64__'))
+$fieldsJson = [System.Text.Encoding]::UTF8.GetString([System.Convert]::FromBase64String('__FIELDS_BASE64__'))
+$fieldsObject = $fieldsJson | ConvertFrom-Json
+$form = @{}
+foreach ($property in $fieldsObject.PSObject.Properties) {
+ $form[$property.Name] = [string] $property.Value
+}
+$form[$fileField] = Get-Item -LiteralPath $filePath
+try {
+ $response = Invoke-RestMethod -Method Post -Uri $url -Form $form
+ [PSCustomObject]@{
+ ok = $true
+ body = ($response | ConvertTo-Json -Depth 20 -Compress)
+ error = ''
+ } | ConvertTo-Json -Compress
+} catch {
+ [PSCustomObject]@{
+ ok = $false
+ body = ''
+ error = $_.Exception.Message
+ } | ConvertTo-Json -Compress
+}
+PS
+ );
+ $encodedCommandBinary = function_exists('iconv')
+ ? iconv('UTF-8', 'UTF-16LE', $powerShellScript)
+ : $powerShellScript;
+ $encodedCommand = base64_encode($encodedCommandBinary === false ? $powerShellScript : $encodedCommandBinary);
+ $command = 'powershell -NoProfile -ExecutionPolicy Bypass -EncodedCommand ' . $encodedCommand;
+ $rawOutput = shell_exec($command);
+
+ if ($rawOutput !== null && trim($rawOutput) !== '') {
+ $decodedShell = json_decode(trim($rawOutput), true);
+ if (is_array($decodedShell)) {
+ return [
+ 'ok' => (bool) ($decodedShell['ok'] ?? false),
+ 'error' => $decodedShell['error'] ?? null,
+ 'body' => $decodedShell['body'] ?? null,
+ ];
+ }
+ }
+ }
+
+ if (function_exists('curl_init')) {
+ $payload = $fields;
+ $extension = strtolower((string) pathinfo($filePath, PATHINFO_EXTENSION));
+ $mimeType = match ($extension) {
+ 'png' => 'image/png',
+ 'jpg', 'jpeg' => 'image/jpeg',
+ 'svg' => 'image/svg+xml',
+ default => 'application/octet-stream',
+ };
+ $payload[$fileField] = new CURLFile($filePath, $mimeType, $fileName ?? basename($filePath));
+
+ $ch = curl_init($url);
+ curl_setopt_array($ch, [
+ CURLOPT_POST => true,
+ CURLOPT_RETURNTRANSFER => true,
+ CURLOPT_POSTFIELDS => $payload,
+ CURLOPT_TIMEOUT => 30,
+ ]);
+
+ $body = curl_exec($ch);
+ $error = curl_error($ch);
+ curl_close($ch);
+
+ return [
+ 'ok' => $body !== false,
+ 'error' => $body === false ? $error : null,
+ 'body' => $body !== false ? $body : null,
+ ];
+ }
+
+ return [
+ 'ok' => false,
+ 'error' => 'Multipart-Versand ist ohne curl nicht verfuegbar.',
+ 'body' => null,
+ ];
+}
+
+function telegramSendBotMessage(array $telegramConfig, string $chatId, string $messageText): bool
+{
+ $botToken = trim((string) ($telegramConfig['bot_token'] ?? ''));
+ if ($botToken === '' || $chatId === '' || $messageText === '') {
+ return false;
+ }
+
+ $response = telegramHttpPostJson(
+ 'https://api.telegram.org/bot' . $botToken . '/sendMessage',
+ [
+ 'chat_id' => $chatId,
+ 'text' => $messageText,
+ ]
+ );
+
+ return (bool) ($response['ok'] ?? false);
+}
+
+function telegramSendBotDocument(array $telegramConfig, string $chatId, string $filePath, string $caption = ''): bool
+{
+ $botToken = trim((string) ($telegramConfig['bot_token'] ?? ''));
+ if ($botToken === '' || $chatId === '' || !is_file($filePath)) {
+ return false;
+ }
+
+ $response = telegramHttpPostMultipart(
+ 'https://api.telegram.org/bot' . $botToken . '/sendDocument',
+ [
+ 'chat_id' => $chatId,
+ 'caption' => $caption,
+ ],
+ 'document',
+ $filePath,
+ basename($filePath)
+ );
+
+ return (bool) ($response['ok'] ?? false);
+}
+
+function telegramSendBotPhoto(array $telegramConfig, string $chatId, string $filePath, string $caption = ''): bool
+{
+ $botToken = trim((string) ($telegramConfig['bot_token'] ?? ''));
+ if ($botToken === '' || $chatId === '' || !is_file($filePath)) {
+ return false;
+ }
+
+ $response = telegramHttpPostMultipart(
+ 'https://api.telegram.org/bot' . $botToken . '/sendPhoto',
+ [
+ 'chat_id' => $chatId,
+ 'caption' => $caption,
+ ],
+ 'photo',
+ $filePath,
+ basename($filePath)
+ );
+
+ return (bool) ($response['ok'] ?? false);
+}
+
+function formatDecimal(mixed $value, int $decimals): string
+{
+ if ($value === null || $value === '') {
+ return '—';
+ }
+
+ if (!is_numeric((string) $value)) {
+ return (string) $value;
+ }
+
+ return number_format((float) $value, $decimals, '.', '');
+}
+
function isTaskDue(string $schedule, DateTimeImmutable $nowBerlin): bool
{
+ if ($schedule === 'every-run') {
+ return true;
+ }
+
if ($schedule === 'every-minute') {
return true;
}