From be35b7398d38db551519958e5ec6a27a1263f4bc Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Thomas=20M=C3=BCller?= Date: Sun, 5 Apr 2026 10:25:50 +0200 Subject: [PATCH] TCRB senden eingabeut --- config/skyview.sql | 1 + php-server.err.log | 48 ++ public/settings.php | 49 ++ scripts/telegram/telegram_cron.php | 803 +++++++++++++++++++++++++++++ 4 files changed, 901 insertions(+) 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 +

T CrB letzter V-Wert:

+

Aktiver Verbindungscode bis format('d.m.Y H:i')) ?>: @@ -327,6 +358,24 @@ $telegramDeepLink = $telegramLinkIsActive && $telegramBotUsername !== ''

+ +
+ + + +

Die Auswahl wird erst aktiv, nachdem dein Telegram-Konto verbunden ist.

+ + +
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' + + + + + + + + + + T CrB Lichtkurve - Filter V + letzte 80 Messpunkte, kleinere Magnituden sind heller + %s + %s + Aktuell: %s%s + %s UTC + %s + + + %s + + %s + +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; }