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'
+
+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'
-
-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);
-}