From 7dcefbe21538df985505651f418e720e31cdbb94 Mon Sep 17 00:00:00 2001 From: Eskimue Date: Wed, 8 Apr 2026 12:34:02 +0200 Subject: [PATCH] Telegram Cron umgebaut --- scripts/telegram/bootstrap.php | 63 ++ scripts/telegram/lib/cron_helpers.php | 221 +++++ scripts/telegram/lib/tcrb_helpers.php | 514 +++++++++++ scripts/telegram/lib/telegram_api.php | 299 ++++++ scripts/telegram/tasks.php | 194 ++++ scripts/telegram/telegram_cron.php | 1202 +------------------------ 6 files changed, 1302 insertions(+), 1191 deletions(-) create mode 100644 scripts/telegram/bootstrap.php create mode 100644 scripts/telegram/lib/cron_helpers.php create mode 100644 scripts/telegram/lib/tcrb_helpers.php create mode 100644 scripts/telegram/lib/telegram_api.php create mode 100644 scripts/telegram/tasks.php diff --git a/scripts/telegram/bootstrap.php b/scripts/telegram/bootstrap.php new file mode 100644 index 0000000..6bd169c --- /dev/null +++ b/scripts/telegram/bootstrap.php @@ -0,0 +1,63 @@ + PDO::ERRMODE_EXCEPTION, + PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_ASSOC, + ] +); + +ensureImportRunsTableExists($pdo); + +$timezoneBerlin = new DateTimeZone('Europe/Berlin'); +$timezoneUtc = new DateTimeZone('UTC'); +$nowBerlin = new DateTimeImmutable('now', $timezoneBerlin); +$nowUtc = $nowBerlin->setTimezone($timezoneUtc); + +return [ + 'rootDir' => $rootDir, + 'stateFile' => $stateFile, + 'dbConfig' => $dbConfig, + 'telegramConfig' => $telegramConfig, + 'pdo' => $pdo, + 'timezoneBerlin' => $timezoneBerlin, + 'timezoneUtc' => $timezoneUtc, + 'nowBerlin' => $nowBerlin, + 'nowUtc' => $nowUtc, +]; diff --git a/scripts/telegram/lib/cron_helpers.php b/scripts/telegram/lib/cron_helpers.php new file mode 100644 index 0000000..c7bd492 --- /dev/null +++ b/scripts/telegram/lib/cron_helpers.php @@ -0,0 +1,221 @@ +exec(" + CREATE TABLE IF NOT EXISTS `app_import_runs` ( + `id` bigint(20) unsigned NOT NULL AUTO_INCREMENT, + `source` varchar(32) NOT NULL, + `source_url` varchar(500) DEFAULT NULL, + `started_at` datetime NOT NULL, + `finished_at` datetime DEFAULT NULL, + `records_received` int(11) NOT NULL DEFAULT 0, + `records_inserted` int(11) NOT NULL DEFAULT 0, + `records_updated` int(11) NOT NULL DEFAULT 0, + `status` varchar(32) NOT NULL DEFAULT 'success', + `message` text DEFAULT NULL, + PRIMARY KEY (`id`), + KEY `idx_app_import_runs_source` (`source`), + KEY `idx_app_import_runs_started_at` (`started_at`) + ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci + "); +} + + +function openCronLock(): mixed +{ + $lockFile = sys_get_temp_dir() . DIRECTORY_SEPARATOR . 'skyview_telegram_cron.lock'; + $handle = fopen($lockFile, 'c+'); + if ($handle === false) { + return null; + } + + if (!flock($handle, LOCK_EX | LOCK_NB)) { + fclose($handle); + return null; + } + + return $handle; +} + + +function startImportRun(PDO $pdo, string $source, ?string $sourceUrl): int +{ + $stmt = $pdo->prepare(" + INSERT INTO `app_import_runs` ( + `source`, + `source_url`, + `started_at`, + `status`, + `message` + ) VALUES ( + :source, + :source_url, + NOW(), + 'running', + NULL + ) + "); + $stmt->execute([ + ':source' => $source, + ':source_url' => $sourceUrl, + ]); + + return (int) $pdo->lastInsertId(); +} + + +function finishImportRun( + PDO $pdo, + int $runId, + int $recordsReceived, + int $recordsInserted, + int $recordsUpdated, + string $status, + ?string $message +): void { + $stmt = $pdo->prepare(" + UPDATE `app_import_runs` + SET + `finished_at` = NOW(), + `records_received` = :records_received, + `records_inserted` = :records_inserted, + `records_updated` = :records_updated, + `status` = :status, + `message` = :message + WHERE `id` = :id + LIMIT 1 + "); + $stmt->execute([ + ':records_received' => $recordsReceived, + ':records_inserted' => $recordsInserted, + ':records_updated' => $recordsUpdated, + ':status' => $status, + ':message' => $message, + ':id' => $runId, + ]); +} + + +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 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; + } + + if (preg_match('/^every-(\d+)-minutes$/', $schedule, $matches)) { + $interval = max(1, (int) $matches[1]); + return ((int) $nowBerlin->format('i')) % $interval === 0; + } + + if (preg_match('/^hourly-minute-(\d{2})$/', $schedule, $matches)) { + return $nowBerlin->format('i') === $matches[1]; + } + + if (preg_match('/^daily-(\d{2}):(\d{2})$/', $schedule, $matches)) { + return $nowBerlin->format('H') === $matches[1] + && $nowBerlin->format('i') === $matches[2]; + } + + if (preg_match('/^weekday-(mon|tue|wed|thu|fri|sat|sun)-(\d{2}):(\d{2})$/', $schedule, $matches)) { + $weekdayMap = [ + 'mon' => '1', + 'tue' => '2', + 'wed' => '3', + 'thu' => '4', + 'fri' => '5', + 'sat' => '6', + 'sun' => '7', + ]; + + return $nowBerlin->format('N') === ($weekdayMap[$matches[1]] ?? '') + && $nowBerlin->format('H') === $matches[2] + && $nowBerlin->format('i') === $matches[3]; + } + + return false; +} + + +function writeMessage(string $message, bool $isError = false): void +{ + $prefix = '[' . (new DateTimeImmutable('now'))->format('Y-m-d H:i:s') . '] '; + $defaultStream = $isError ? 'php://stderr' : 'php://stdout'; + + if ($isError && defined('STDERR')) { + fwrite(STDERR, $prefix . $message . PHP_EOL); + return; + } + + if (!$isError && defined('STDOUT')) { + fwrite(STDOUT, $prefix . $message . PHP_EOL); + return; + } + + $stream = @fopen($defaultStream, 'wb'); + if ($stream === false) { + error_log($prefix . $message); + return; + } + + fwrite($stream, $prefix . $message . PHP_EOL); + fclose($stream); +} + diff --git a/scripts/telegram/lib/tcrb_helpers.php b/scripts/telegram/lib/tcrb_helpers.php new file mode 100644 index 0000000..1114acd --- /dev/null +++ b/scripts/telegram/lib/tcrb_helpers.php @@ -0,0 +1,514 @@ +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 fetchRecentTcrbObservationsByFilterWindow(PDO $pdo, string $filterCode, string $windowSpec = '-2 months'): array +{ + $windowStartUtc = (new DateTimeImmutable('now', new DateTimeZone('UTC'))) + ->modify($windowSpec) + ->format('Y-m-d H:i:s'); + + $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 + AND `observed_at_utc` >= :window_start_utc + ORDER BY `observed_at_utc` ASC, `id` ASC + "); + $stmt->execute([ + ':filter_code' => $filterCode, + ':window_start_utc' => $windowStartUtc, + ]); + $rows = $stmt->fetchAll(); + + if ($rows === []) { + $fallbackStmt = $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 30 + "); + $fallbackStmt->execute([ + ':filter_code' => $filterCode, + ]); + $rows = $fallbackStmt->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 = formatGermanDateTime($observedUtc); + $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'] ?? '')); + $nextPossibleEruptionDate = calculateNextPossibleTcrbEruptionDate($nowBerlin); + + $lines = [ + 'SkyViews TCrBotti mit dem Update', + '', + 'Magnitude: ' . $magnitude . $magnitudeError, + 'Zeitpunkt: ' . $observedText, + 'JD: ' . $jd, + 'Nächster möglicher Ausbruch: '. $nextPossibleEruptionDate, + ]; + + + return implode("\n", $lines); +} + + +function calculateNextPossibleTcrbEruptionDate(DateTimeImmutable $referenceDate): string +{ + $eruptionIntervalDays = 227.5687; + $lastEruptionUtc = new DateTimeImmutable('1946-02-09 00:00:00', new DateTimeZone('UTC')); + $referenceUtc = $referenceDate->setTimezone(new DateTimeZone('UTC')); + $secondsPerDay = 86400; + $eruptionIntervalSeconds = (int) round($eruptionIntervalDays * $secondsPerDay); + $elapsedSecondsSinceLastEruption = max(0, $referenceUtc->getTimestamp() - $lastEruptionUtc->getTimestamp()); + $elapsedIntervals = (int) floor($elapsedSecondsSinceLastEruption / $eruptionIntervalSeconds); + $nextPossibleEruptionUtc = $lastEruptionUtc->modify('+' . (($elapsedIntervals + 1) * $eruptionIntervalSeconds) . ' seconds'); + + return $nextPossibleEruptionUtc->format('d.m.Y'); +} + + +function formatGermanDateTime(?string $value): string +{ + if ($value === null || trim($value) === '') { + return 'unbekannt'; + } + + try { + $date = new DateTimeImmutable($value, new DateTimeZone('UTC')); + } catch (Exception) { + return $value; + } + + return $date->format('d.m.Y H:i:s') . ' UTC'; +} + + +function buildTcrbVChartSvg(array $observations, array $latestObservation): string +{ + $filterColors = loadFilterColors(); + $vColor = (string) ($filterColors['V'] ?? '#92FF00'); + $vStrokeColor = darkenHexColor($vColor, 0.35); + $vHighlightColor = lightenHexColor($vColor, 0.35); + $vHighlightStrokeColor = lightenHexColor($vColor, 0.6); + + $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, + htmlspecialchars($vHighlightColor, ENT_QUOTES, 'UTF-8') + ); + } + + $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 ? $vHighlightColor : $vColor, + $isLatest ? $vHighlightStrokeColor : $vHighlightColor, + $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 2 Monate - Astronomiemuseum der Sternwarte Sonneberg + %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($vHighlightColor, ENT_QUOTES, 'UTF-8'), + 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, + htmlspecialchars($vStrokeColor, ENT_QUOTES, 'UTF-8'), + $pointsSvgMarkup + ); +} + + +function loadFilterColors(): array +{ + static $colors = null; + global $filterColorConfigPath; + + if (is_array($colors)) { + return $colors; + } + + $colors = []; + if (!is_string($filterColorConfigPath) || !is_file($filterColorConfigPath)) { + return $colors; + } + + $raw = file_get_contents($filterColorConfigPath); + $decoded = is_string($raw) ? json_decode($raw, true) : null; + if (!is_array($decoded)) { + return $colors; + } + + foreach ($decoded as $filterCode => $config) { + if (is_array($config) && !empty($config['color'])) { + $colors[(string) $filterCode] = (string) $config['color']; + } + } + + return $colors; +} + + +function darkenHexColor(string $hexColor, float $amount): string +{ + return adjustHexColor($hexColor, -abs($amount)); +} + + +function lightenHexColor(string $hexColor, float $amount): string +{ + return adjustHexColor($hexColor, abs($amount)); +} + + +function adjustHexColor(string $hexColor, float $amount): string +{ + $hex = ltrim(trim($hexColor), '#'); + if (!preg_match('/^[0-9a-fA-F]{6}$/', $hex)) { + return '#92FF00'; + } + + $channels = [ + hexdec(substr($hex, 0, 2)), + hexdec(substr($hex, 2, 2)), + hexdec(substr($hex, 4, 2)), + ]; + + foreach ($channels as $index => $value) { + if ($amount >= 0) { + $channels[$index] = (int) round($value + ((255 - $value) * $amount)); + } else { + $channels[$index] = (int) round($value * (1 + $amount)); + } + $channels[$index] = max(0, min(255, $channels[$index])); + } + + return sprintf('#%02X%02X%02X', $channels[0], $channels[1], $channels[2]); +} + + +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 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; +} + + diff --git a/scripts/telegram/lib/telegram_api.php b/scripts/telegram/lib/telegram_api.php new file mode 100644 index 0000000..877446b --- /dev/null +++ b/scripts/telegram/lib/telegram_api.php @@ -0,0 +1,299 @@ + 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' => appendTelegramServiceFooter($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' => appendTelegramServiceFooter($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' => appendTelegramServiceFooter($caption), + ], + 'photo', + $filePath, + basename($filePath) + ); + + return (bool) ($response['ok'] ?? false); +} + + diff --git a/scripts/telegram/tasks.php b/scripts/telegram/tasks.php new file mode 100644 index 0000000..6d480bf --- /dev/null +++ b/scripts/telegram/tasks.php @@ -0,0 +1,194 @@ + '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 = fetchRecentTcrbObservationsByFilterWindow($pdo, 'V', '-2 months'); + $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', + 'enabled' => true, + 'schedule' => 'every-15-minutes', + 'handler' => static function (PDO $pdo, array $telegramConfig, DateTimeImmutable $nowBerlin): array { + $stmt = $pdo->prepare(" + UPDATE `app_users` + SET + `telegram_link_token` = NULL, + `telegram_link_token_expires_at` = NULL, + `updated_at` = NOW() + WHERE `telegram_link_token` IS NOT NULL + AND `telegram_link_token` <> '' + AND `telegram_link_token_expires_at` IS NOT NULL + AND `telegram_link_token_expires_at` < NOW() + "); + $stmt->execute(); + + return [ + 'sent' => 0, + 'updated' => (int) $stmt->rowCount(), + 'message' => 'Abgelaufene Verbindungscodes bereinigt.', + ]; + }, + ], + [ + 'key' => 'daily_morning_report_placeholder', + 'label' => 'Morgendlichen Telegram-Bericht vorbereiten', + 'enabled' => false, + 'schedule' => 'daily-08:00', + 'handler' => static function (PDO $pdo, array $telegramConfig, DateTimeImmutable $nowBerlin): array { + return [ + 'sent' => 0, + 'updated' => 0, + 'message' => 'Platzhalter fuer den spaeteren Morgenbericht.', + ]; + }, + ], + [ + 'key' => 'daily_evening_report_placeholder', + 'label' => 'Abendlichen Telegram-Bericht vorbereiten', + 'enabled' => false, + 'schedule' => 'daily-19:30', + 'handler' => static function (PDO $pdo, array $telegramConfig, DateTimeImmutable $nowBerlin): array { + return [ + 'sent' => 0, + 'updated' => 0, + 'message' => 'Platzhalter fuer den spaeteren Abendbericht.', + ]; + }, + ], + [ + 'key' => 'connected_users_heartbeat_placeholder', + 'label' => 'Verbundene Telegram-Nutzer pruefen', + 'enabled' => false, + 'schedule' => 'hourly-minute-05', + 'handler' => static function (PDO $pdo, array $telegramConfig, DateTimeImmutable $nowBerlin): array { + $count = (int) $pdo->query(" + SELECT COUNT(*) + FROM `app_users` + WHERE `telegram_chat_id` IS NOT NULL + AND `telegram_chat_id` <> '' + ")->fetchColumn(); + + return [ + 'sent' => 0, + 'updated' => 0, + 'message' => 'Aktuell verbundene Telegram-Nutzer: ' . $count, + ]; + }, + ], +]; diff --git a/scripts/telegram/telegram_cron.php b/scripts/telegram/telegram_cron.php index e594519..394d33b 100644 --- a/scripts/telegram/telegram_cron.php +++ b/scripts/telegram/telegram_cron.php @@ -4,6 +4,10 @@ declare(strict_types=1); /** * Zentrale Telegram-Cron-Datei fuer wiederkehrende Aufgaben. * + * Diese Datei ist der schmale Einstiegspunkt fuer den kompletten Telegram-Cron. + * Sie laedt Bootstrap und Task-Definitionen, setzt den Lock gegen Doppelstarts, + * fuehrt faellige Tasks aus und protokolliert den gesamten Lauf in app_import_runs. + * * Empfohlener Cron: * jede Minute aufrufen * @@ -12,38 +16,16 @@ declare(strict_types=1); * C:\Users\ich\source\repos\skyview.astronomiemuseum.de\scripts\telegram\telegram_cron.php */ -error_reporting(E_ALL); -ini_set('display_errors', '1'); -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'; -require_once $rootDir . DIRECTORY_SEPARATOR . 'config' . DIRECTORY_SEPARATOR . 'telegram_message_footer.php'; -$filterColorConfigPath = $rootDir . DIRECTORY_SEPARATOR . 'config' . DIRECTORY_SEPARATOR . 'filter_colors.json'; +[ + 'stateFile' => $stateFile, + 'telegramConfig' => $telegramConfig, + 'pdo' => $pdo, + 'nowBerlin' => $nowBerlin, +] = require __DIR__ . DIRECTORY_SEPARATOR . 'bootstrap.php'; -$dsn = sprintf( - 'mysql:host=%s;port=%d;dbname=%s;charset=%s', - $dbConfig['host'], - (int) ($dbConfig['port'] ?? 3306), - $dbConfig['dbname'], - $dbConfig['charset'] ?? 'utf8mb4' -); - -$pdo = new PDO( - $dsn, - $dbConfig['user'], - $dbConfig['pass'], - [ - PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION, - PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_ASSOC, - ] -); - -ensureImportRunsTableExists($pdo); +$tasks = require __DIR__ . DIRECTORY_SEPARATOR . 'tasks.php'; $lockHandle = openCronLock(); if ($lockHandle === null) { @@ -51,195 +33,6 @@ if ($lockHandle === null) { exit(1); } -$timezoneBerlin = new DateTimeZone('Europe/Berlin'); -$timezoneUtc = new DateTimeZone('UTC'); -$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 = fetchRecentTcrbObservationsByFilterWindow($pdo, 'V', '-2 months'); - $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', - 'enabled' => true, - 'schedule' => 'every-15-minutes', - 'handler' => static function (PDO $pdo, array $telegramConfig, DateTimeImmutable $nowBerlin): array { - $stmt = $pdo->prepare(" - UPDATE `app_users` - SET - `telegram_link_token` = NULL, - `telegram_link_token_expires_at` = NULL, - `updated_at` = NOW() - WHERE `telegram_link_token` IS NOT NULL - AND `telegram_link_token` <> '' - AND `telegram_link_token_expires_at` IS NOT NULL - AND `telegram_link_token_expires_at` < NOW() - "); - $stmt->execute(); - - return [ - 'sent' => 0, - 'updated' => (int) $stmt->rowCount(), - 'message' => 'Abgelaufene Verbindungscodes bereinigt.', - ]; - }, - ], - [ - 'key' => 'daily_morning_report_placeholder', - 'label' => 'Morgendlichen Telegram-Bericht vorbereiten', - 'enabled' => false, - 'schedule' => 'daily-08:00', - 'handler' => static function (PDO $pdo, array $telegramConfig, DateTimeImmutable $nowBerlin): array { - return [ - 'sent' => 0, - 'updated' => 0, - 'message' => 'Platzhalter fuer den spaeteren Morgenbericht.', - ]; - }, - ], - [ - 'key' => 'daily_evening_report_placeholder', - 'label' => 'Abendlichen Telegram-Bericht vorbereiten', - 'enabled' => false, - 'schedule' => 'daily-19:30', - 'handler' => static function (PDO $pdo, array $telegramConfig, DateTimeImmutable $nowBerlin): array { - return [ - 'sent' => 0, - 'updated' => 0, - 'message' => 'Platzhalter fuer den spaeteren Abendbericht.', - ]; - }, - ], - [ - 'key' => 'connected_users_heartbeat_placeholder', - 'label' => 'Verbundene Telegram-Nutzer pruefen', - 'enabled' => false, - 'schedule' => 'hourly-minute-05', - 'handler' => static function (PDO $pdo, array $telegramConfig, DateTimeImmutable $nowBerlin): array { - $count = (int) $pdo->query(" - SELECT COUNT(*) - FROM `app_users` - WHERE `telegram_chat_id` IS NOT NULL - AND `telegram_chat_id` <> '' - ")->fetchColumn(); - - return [ - 'sent' => 0, - 'updated' => 0, - 'message' => 'Aktuell verbundene Telegram-Nutzer: ' . $count, - ]; - }, - ], -]; - $runId = startImportRun($pdo, IMPORT_RUN_SOURCE, null); $recordsReceived = count($tasks); $recordsInserted = 0; @@ -307,976 +100,3 @@ try { flock($lockHandle, LOCK_UN); fclose($lockHandle); } - -function ensureImportRunsTableExists(PDO $pdo): void -{ - $pdo->exec(" - CREATE TABLE IF NOT EXISTS `app_import_runs` ( - `id` bigint(20) unsigned NOT NULL AUTO_INCREMENT, - `source` varchar(32) NOT NULL, - `source_url` varchar(500) DEFAULT NULL, - `started_at` datetime NOT NULL, - `finished_at` datetime DEFAULT NULL, - `records_received` int(11) NOT NULL DEFAULT 0, - `records_inserted` int(11) NOT NULL DEFAULT 0, - `records_updated` int(11) NOT NULL DEFAULT 0, - `status` varchar(32) NOT NULL DEFAULT 'success', - `message` text DEFAULT NULL, - PRIMARY KEY (`id`), - KEY `idx_app_import_runs_source` (`source`), - KEY `idx_app_import_runs_started_at` (`started_at`) - ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci - "); -} - -function openCronLock(): mixed -{ - $lockFile = sys_get_temp_dir() . DIRECTORY_SEPARATOR . 'skyview_telegram_cron.lock'; - $handle = fopen($lockFile, 'c+'); - if ($handle === false) { - return null; - } - - if (!flock($handle, LOCK_EX | LOCK_NB)) { - fclose($handle); - return null; - } - - return $handle; -} - -function startImportRun(PDO $pdo, string $source, ?string $sourceUrl): int -{ - $stmt = $pdo->prepare(" - INSERT INTO `app_import_runs` ( - `source`, - `source_url`, - `started_at`, - `status`, - `message` - ) VALUES ( - :source, - :source_url, - NOW(), - 'running', - NULL - ) - "); - $stmt->execute([ - ':source' => $source, - ':source_url' => $sourceUrl, - ]); - - return (int) $pdo->lastInsertId(); -} - -function finishImportRun( - PDO $pdo, - int $runId, - int $recordsReceived, - int $recordsInserted, - int $recordsUpdated, - string $status, - ?string $message -): void { - $stmt = $pdo->prepare(" - UPDATE `app_import_runs` - SET - `finished_at` = NOW(), - `records_received` = :records_received, - `records_inserted` = :records_inserted, - `records_updated` = :records_updated, - `status` = :status, - `message` = :message - WHERE `id` = :id - LIMIT 1 - "); - $stmt->execute([ - ':records_received' => $recordsReceived, - ':records_inserted' => $recordsInserted, - ':records_updated' => $recordsUpdated, - ':status' => $status, - ':message' => $message, - ':id' => $runId, - ]); -} - -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 fetchRecentTcrbObservationsByFilterWindow(PDO $pdo, string $filterCode, string $windowSpec = '-2 months'): array -{ - $windowStartUtc = (new DateTimeImmutable('now', new DateTimeZone('UTC'))) - ->modify($windowSpec) - ->format('Y-m-d H:i:s'); - - $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 - AND `observed_at_utc` >= :window_start_utc - ORDER BY `observed_at_utc` ASC, `id` ASC - "); - $stmt->execute([ - ':filter_code' => $filterCode, - ':window_start_utc' => $windowStartUtc, - ]); - $rows = $stmt->fetchAll(); - - if ($rows === []) { - $fallbackStmt = $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 30 - "); - $fallbackStmt->execute([ - ':filter_code' => $filterCode, - ]); - $rows = $fallbackStmt->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 = formatGermanDateTime($observedUtc); - $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'] ?? '')); - $nextPossibleEruptionDate = calculateNextPossibleTcrbEruptionDate($nowBerlin); - - $lines = [ - 'SkyViews TCrBotti mit dem Update', - '', - 'Magnitude: ' . $magnitude . $magnitudeError, - 'Zeitpunkt: ' . $observedText, - 'JD: ' . $jd, - 'Nächster möglicher Ausbruch: '. $nextPossibleEruptionDate, - ]; - - - return implode("\n", $lines); -} - -function calculateNextPossibleTcrbEruptionDate(DateTimeImmutable $referenceDate): string -{ - $eruptionIntervalDays = 227.5687; - $lastEruptionUtc = new DateTimeImmutable('1946-02-09 00:00:00', new DateTimeZone('UTC')); - $referenceUtc = $referenceDate->setTimezone(new DateTimeZone('UTC')); - $secondsPerDay = 86400; - $eruptionIntervalSeconds = (int) round($eruptionIntervalDays * $secondsPerDay); - $elapsedSecondsSinceLastEruption = max(0, $referenceUtc->getTimestamp() - $lastEruptionUtc->getTimestamp()); - $elapsedIntervals = (int) floor($elapsedSecondsSinceLastEruption / $eruptionIntervalSeconds); - $nextPossibleEruptionUtc = $lastEruptionUtc->modify('+' . (($elapsedIntervals + 1) * $eruptionIntervalSeconds) . ' seconds'); - - return $nextPossibleEruptionUtc->format('d.m.Y'); -} - -function formatGermanDateTime(?string $value): string -{ - if ($value === null || trim($value) === '') { - return 'unbekannt'; - } - - try { - $date = new DateTimeImmutable($value, new DateTimeZone('UTC')); - } catch (Exception) { - return $value; - } - - return $date->format('d.m.Y H:i:s') . ' UTC'; -} - -function buildTcrbVChartSvg(array $observations, array $latestObservation): string -{ - $filterColors = loadFilterColors(); - $vColor = (string) ($filterColors['V'] ?? '#92FF00'); - $vStrokeColor = darkenHexColor($vColor, 0.35); - $vHighlightColor = lightenHexColor($vColor, 0.35); - $vHighlightStrokeColor = lightenHexColor($vColor, 0.6); - - $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, - htmlspecialchars($vHighlightColor, ENT_QUOTES, 'UTF-8') - ); - } - - $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 ? $vHighlightColor : $vColor, - $isLatest ? $vHighlightStrokeColor : $vHighlightColor, - $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 2 Monate - Astronomiemuseum der Sternwarte Sonneberg - %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($vHighlightColor, ENT_QUOTES, 'UTF-8'), - 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, - htmlspecialchars($vStrokeColor, ENT_QUOTES, 'UTF-8'), - $pointsSvgMarkup - ); -} - -function loadFilterColors(): array -{ - static $colors = null; - global $filterColorConfigPath; - - if (is_array($colors)) { - return $colors; - } - - $colors = []; - if (!is_string($filterColorConfigPath) || !is_file($filterColorConfigPath)) { - return $colors; - } - - $raw = file_get_contents($filterColorConfigPath); - $decoded = is_string($raw) ? json_decode($raw, true) : null; - if (!is_array($decoded)) { - return $colors; - } - - foreach ($decoded as $filterCode => $config) { - if (is_array($config) && !empty($config['color'])) { - $colors[(string) $filterCode] = (string) $config['color']; - } - } - - return $colors; -} - -function darkenHexColor(string $hexColor, float $amount): string -{ - return adjustHexColor($hexColor, -abs($amount)); -} - -function lightenHexColor(string $hexColor, float $amount): string -{ - return adjustHexColor($hexColor, abs($amount)); -} - -function adjustHexColor(string $hexColor, float $amount): string -{ - $hex = ltrim(trim($hexColor), '#'); - if (!preg_match('/^[0-9a-fA-F]{6}$/', $hex)) { - return '#92FF00'; - } - - $channels = [ - hexdec(substr($hex, 0, 2)), - hexdec(substr($hex, 2, 2)), - hexdec(substr($hex, 4, 2)), - ]; - - foreach ($channels as $index => $value) { - if ($amount >= 0) { - $channels[$index] = (int) round($value + ((255 - $value) * $amount)); - } else { - $channels[$index] = (int) round($value * (1 + $amount)); - } - $channels[$index] = max(0, min(255, $channels[$index])); - } - - return sprintf('#%02X%02X%02X', $channels[0], $channels[1], $channels[2]); -} - -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' => appendTelegramServiceFooter($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' => appendTelegramServiceFooter($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' => appendTelegramServiceFooter($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; - } - - if (preg_match('/^every-(\d+)-minutes$/', $schedule, $matches)) { - $interval = max(1, (int) $matches[1]); - return ((int) $nowBerlin->format('i')) % $interval === 0; - } - - if (preg_match('/^hourly-minute-(\d{2})$/', $schedule, $matches)) { - return $nowBerlin->format('i') === $matches[1]; - } - - if (preg_match('/^daily-(\d{2}):(\d{2})$/', $schedule, $matches)) { - return $nowBerlin->format('H') === $matches[1] - && $nowBerlin->format('i') === $matches[2]; - } - - if (preg_match('/^weekday-(mon|tue|wed|thu|fri|sat|sun)-(\d{2}):(\d{2})$/', $schedule, $matches)) { - $weekdayMap = [ - 'mon' => '1', - 'tue' => '2', - 'wed' => '3', - 'thu' => '4', - 'fri' => '5', - 'sat' => '6', - 'sun' => '7', - ]; - - return $nowBerlin->format('N') === ($weekdayMap[$matches[1]] ?? '') - && $nowBerlin->format('H') === $matches[2] - && $nowBerlin->format('i') === $matches[3]; - } - - return false; -} - -function writeMessage(string $message, bool $isError = false): void -{ - $prefix = '[' . (new DateTimeImmutable('now'))->format('Y-m-d H:i:s') . '] '; - $defaultStream = $isError ? 'php://stderr' : 'php://stdout'; - - if ($isError && defined('STDERR')) { - fwrite(STDERR, $prefix . $message . PHP_EOL); - return; - } - - if (!$isError && defined('STDOUT')) { - fwrite(STDOUT, $prefix . $message . PHP_EOL); - return; - } - - $stream = @fopen($defaultStream, 'wb'); - if ($stream === false) { - error_log($prefix . $message); - return; - } - - fwrite($stream, $prefix . $message . PHP_EOL); - fclose($stream); -}