PDO::ERRMODE_EXCEPTION, PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_ASSOC, ] ); ensureImportRunsTableExists($pdo); $lockHandle = openCronLock(); if ($lockHandle === null) { writeMessage('Telegram-Cron laeuft bereits. Dieser Start wird beendet.', true); 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; $recordsUpdated = 0; $messages = []; try { foreach ($tasks as $task) { if (($task['enabled'] ?? false) !== true) { $messages[] = sprintf('[%s] deaktiviert', (string) $task['key']); continue; } $schedule = (string) ($task['schedule'] ?? ''); if (!isTaskDue($schedule, $nowBerlin)) { $messages[] = sprintf('[%s] noch nicht faellig (%s)', (string) $task['key'], $schedule); continue; } $messages[] = sprintf('[%s] gestartet (%s)', (string) $task['key'], (string) ($task['label'] ?? $task['key'])); $handler = $task['handler'] ?? null; if (!is_callable($handler)) { throw new RuntimeException('Task-Handler ist nicht aufrufbar: ' . (string) $task['key']); } $result = $handler($pdo, $telegramConfig, $nowBerlin); $recordsInserted += (int) ($result['sent'] ?? 0); $recordsUpdated += (int) ($result['updated'] ?? 0); $messages[] = sprintf( '[%s] ok - %s', (string) $task['key'], trim((string) ($result['message'] ?? 'ohne Rueckmeldung')) ); } finishImportRun( $pdo, $runId, $recordsReceived, $recordsInserted, $recordsUpdated, 'success', implode("\n", $messages) ); writeMessage('Telegram-Cron abgeschlossen.'); foreach ($messages as $message) { writeMessage($message); } } catch (Throwable $exception) { finishImportRun( $pdo, $runId, $recordsReceived, $recordsInserted, $recordsUpdated, 'error', $exception->getMessage() ); writeMessage('Telegram-Cron mit Fehler beendet: ' . $exception->getMessage(), true); exit(1); } finally { 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 = $observedUtc !== '' ? $observedUtc . ' UTC' : 'unbekannt'; $magnitude = formatDecimal($observation['magnitude'] ?? null, 3); $magnitudeError = $observation['magnitude_error'] !== null ? ' +/- ' . formatDecimal($observation['magnitude_error'], 3) : ''; $jd = formatDecimal($observation['jd'] ?? null, 6); $observerCode = trim((string) ($observation['observer_code'] ?? '')); $lines = [ 'SkyView T CrB Update', '', 'Magnitude: ' . $magnitude . $magnitudeError, 'Zeitpunkt: ' . $observedText, 'JD: ' . $jd, ]; if ($observerCode !== '') { $lines[] = 'Beobachter: ' . $observerCode; } $lines[] = ''; $lines[] = 'Stand: ' . $nowBerlin->format('d.m.Y H:i') . ' Europe/Berlin'; return implode("\n", $lines); } function buildTcrbVChartSvg(array $observations, array $latestObservation): string { $width = 1200; $height = 700; $paddingLeft = 90; $paddingRight = 40; $paddingTop = 80; $paddingBottom = 90; $plotWidth = $width - $paddingLeft - $paddingRight; $plotHeight = $height - $paddingTop - $paddingBottom; $plotBottomY = $paddingTop + $plotHeight; $plotRightX = $paddingLeft + $plotWidth; if (count($observations) < 2) { $observations = [$latestObservation]; } $timestamps = []; $magnitudes = []; foreach ($observations as $row) { $timestamps[] = strtotime((string) ($row['observed_at_utc'] ?? '')); $magnitudes[] = (float) ($row['magnitude'] ?? 0.0); } $timestamps = array_values(array_filter($timestamps, static fn ($value): bool => is_int($value) || is_float($value))); if ($timestamps === []) { $timestamps = [time() - 3600, time()]; } $minX = (float) min($timestamps); $maxX = (float) max($timestamps); if ($maxX <= $minX) { $maxX = $minX + 3600.0; } $minY = min($magnitudes); $maxY = max($magnitudes); if ($maxY <= $minY) { $maxY = $minY + 0.5; } $paddingMag = max(0.06, ($maxY - $minY) * 0.10); $axisMinY = $minY - $paddingMag; $axisMaxY = $maxY + $paddingMag; $scaleX = static function (float $timestamp) use ($minX, $maxX, $paddingLeft, $plotWidth): float { return $paddingLeft + (($timestamp - $minX) / ($maxX - $minX)) * $plotWidth; }; $scaleY = static function (float $magnitude) use ($axisMinY, $axisMaxY, $paddingTop, $plotHeight): float { $ratio = ($magnitude - $axisMinY) / ($axisMaxY - $axisMinY); return $paddingTop + $ratio * $plotHeight; }; $gridLines = []; for ($i = 0; $i <= 4; $i++) { $gridMag = $axisMinY + (($axisMaxY - $axisMinY) / 4) * $i; $y = $scaleY($gridMag); $gridLines[] = sprintf( '', $paddingLeft, $y, $paddingLeft + $plotWidth ); $gridLines[] = sprintf( '%3$s', $paddingLeft - 14, $y, htmlspecialchars(number_format($gridMag, 2, '.', ''), ENT_QUOTES, 'UTF-8') ); } $pathParts = []; $errorBars = []; $pointsSvg = []; foreach ($observations as $index => $row) { $timestamp = strtotime((string) ($row['observed_at_utc'] ?? '')); if ($timestamp === false) { continue; } $magnitude = (float) ($row['magnitude'] ?? 0.0); $x = $scaleX((float) $timestamp); $y = $scaleY($magnitude); $pathParts[] = sprintf('%s %.2f %.2f', $index === 0 ? 'M' : 'L', $x, $y); $error = isset($row['magnitude_error']) && $row['magnitude_error'] !== null ? (float) $row['magnitude_error'] : 0.0; if ($error > 0) { $yTop = $scaleY($magnitude - $error); $yBottom = $scaleY($magnitude + $error); $errorBars[] = sprintf( '' . '' . '', $x, $yTop, $yBottom, $x - 7, $x + 7 ); } $isLatest = (string) ($row['observed_at_utc'] ?? '') === (string) ($latestObservation['observed_at_utc'] ?? '') && (string) ($row['jd'] ?? '') === (string) ($latestObservation['jd'] ?? ''); $pointsSvg[] = sprintf( '', $x, $y, $isLatest ? 7.0 : 4.2, $isLatest ? '#f4d35e' : '#7bd389', $isLatest ? '#fff3bf' : '#d9f99d', $isLatest ? 2.6 : 1.4 ); } $firstLabel = date('d.m.Y H:i', (int) $minX) . ' UTC'; $lastLabel = date('d.m.Y H:i', (int) $maxX) . ' UTC'; $latestMagnitude = number_format((float) ($latestObservation['magnitude'] ?? 0.0), 3, '.', ''); $latestError = isset($latestObservation['magnitude_error']) && $latestObservation['magnitude_error'] !== null ? ' +/- ' . number_format((float) $latestObservation['magnitude_error'], 3, '.', '') : ''; $latestUtc = htmlspecialchars((string) ($latestObservation['observed_at_utc'] ?? ''), ENT_QUOTES, 'UTF-8'); $gridSvg = implode("\n ", $gridLines); $errorBarsSvg = implode("\n ", $errorBars); $pathSvg = implode(' ', $pathParts); $pointsSvgMarkup = implode("\n ", $pointsSvg); return sprintf( <<<'SVG' T CrB Lichtkurve - Filter V letzte 2 Monate %s %s Aktuell: %s%s %s UTC %s %s %s SVG, $width, $height, $width, $height, $width, $height, $paddingLeft, $paddingTop, $plotWidth, $plotHeight, $paddingLeft, $paddingLeft, $paddingLeft, $height - 28, htmlspecialchars($firstLabel, ENT_QUOTES, 'UTF-8'), $width - $paddingRight, $height - 28, htmlspecialchars($lastLabel, ENT_QUOTES, 'UTF-8'), $width - $paddingRight, htmlspecialchars($latestMagnitude, ENT_QUOTES, 'UTF-8'), htmlspecialchars($latestError, ENT_QUOTES, 'UTF-8'), $width - $paddingRight, $latestUtc, $gridSvg, $paddingLeft, $paddingTop, $paddingLeft, $plotBottomY, $paddingLeft, $plotBottomY, $plotRightX, $plotBottomY, $errorBarsSvg, $pathSvg, $pointsSvgMarkup ); } function buildTcrbObservationSignature(array $observation): string { return implode('|', [ (string) ($observation['observed_at_utc'] ?? ''), (string) ($observation['jd'] ?? ''), (string) ($observation['magnitude'] ?? ''), (string) ($observation['magnitude_error'] ?? ''), (string) ($observation['filter_code'] ?? ''), (string) ($observation['observer_code'] ?? ''), ]); } function loadCronState(string $stateFile): array { if (!is_file($stateFile)) { return []; } $raw = @file_get_contents($stateFile); if ($raw === false || trim($raw) === '') { return []; } $decoded = json_decode($raw, true); return is_array($decoded) ? $decoded : []; } function saveCronState(string $stateFile, array $state): void { $json = json_encode($state, JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES); if ($json === false) { throw new RuntimeException('Cron-Statusdatei konnte nicht als JSON gespeichert werden.'); } if (@file_put_contents($stateFile, $json . PHP_EOL, LOCK_EX) === false) { throw new RuntimeException('Cron-Statusdatei konnte nicht geschrieben werden: ' . $stateFile); } } function createTelegramChartPackage(string $svgContent): array { $directory = sys_get_temp_dir() . DIRECTORY_SEPARATOR . 'skyview_telegram_charts'; if (!is_dir($directory) && !mkdir($directory, 0775, true) && !is_dir($directory)) { throw new RuntimeException('Temp-Verzeichnis fuer Telegram-Diagramme konnte nicht erstellt werden.'); } $baseName = 'tcrb_v_latest_chart_' . date('Ymd_His') . '_' . bin2hex(random_bytes(4)); $svgPath = $directory . DIRECTORY_SEPARATOR . $baseName . '.svg'; if (@file_put_contents($svgPath, $svgContent) === false) { throw new RuntimeException('Diagrammdatei konnte nicht geschrieben werden: ' . $svgPath); } $pngPath = $directory . DIRECTORY_SEPARATOR . $baseName . '.png'; if (convertSvgToPng($svgPath, $pngPath)) { return [ 'kind' => 'photo', 'path' => $pngPath, 'cleanup' => [$svgPath, $pngPath], ]; } return [ 'kind' => 'document', 'path' => $svgPath, 'cleanup' => [$svgPath], ]; } function cleanupTelegramChartPackage(array $chartPackage): void { $files = is_array($chartPackage['cleanup'] ?? null) ? $chartPackage['cleanup'] : []; foreach ($files as $file) { if (is_string($file) && $file !== '') { @unlink($file); } } } function convertSvgToPng(string $svgPath, string $pngPath): bool { if (!is_file($svgPath)) { return false; } $commands = [ sprintf('rsvg-convert -o %s %s', escapeshellarg($pngPath), escapeshellarg($svgPath)), sprintf('magick %s %s', escapeshellarg($svgPath), escapeshellarg($pngPath)), sprintf('convert %s %s', escapeshellarg($svgPath), escapeshellarg($pngPath)), ]; foreach ($commands as $command) { if (!function_exists('shell_exec')) { continue; } @shell_exec($command . ' 2>&1'); if (is_file($pngPath) && filesize($pngPath) > 0) { return true; } } return false; } function telegramHttpPostJson(string $url, array $payload): array { $jsonPayload = json_encode($payload, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES); if ($jsonPayload === false) { return [ 'ok' => false, 'error' => 'Die Telegram-Anfrage konnte nicht als JSON kodiert werden.', 'body' => null, ]; } if (DIRECTORY_SEPARATOR === '\\' && function_exists('shell_exec')) { $urlBase64 = base64_encode($url); $payloadBase64 = base64_encode($jsonPayload); $powerShellScript = str_replace( ['__URL_BASE64__', '__PAYLOAD_BASE64__'], [$urlBase64, $payloadBase64], <<<'PS' $ErrorActionPreference = 'Stop' $url = [System.Text.Encoding]::UTF8.GetString([System.Convert]::FromBase64String('__URL_BASE64__')) $body = [System.Text.Encoding]::UTF8.GetString([System.Convert]::FromBase64String('__PAYLOAD_BASE64__')) try { $response = Invoke-RestMethod -Method Post -Uri $url -ContentType 'application/json' -Body $body [PSCustomObject]@{ ok = $true body = ($response | ConvertTo-Json -Depth 20 -Compress) error = '' } | ConvertTo-Json -Compress } catch { $stream = $null $responseBody = '' if ($_.Exception.Response) { try { $stream = $_.Exception.Response.GetResponseStream() if ($stream) { $reader = New-Object System.IO.StreamReader($stream) $responseBody = $reader.ReadToEnd() } } catch { } finally { if ($stream) { $stream.Dispose() } } } [PSCustomObject]@{ ok = $false body = $responseBody error = $_.Exception.Message } | ConvertTo-Json -Compress } PS ); $encodedCommandBinary = function_exists('iconv') ? iconv('UTF-8', 'UTF-16LE', $powerShellScript) : $powerShellScript; $encodedCommand = base64_encode($encodedCommandBinary === false ? $powerShellScript : $encodedCommandBinary); $command = 'powershell -NoProfile -ExecutionPolicy Bypass -EncodedCommand ' . $encodedCommand; $rawOutput = shell_exec($command); if ($rawOutput !== null && trim($rawOutput) !== '') { $decodedShell = json_decode(trim($rawOutput), true); if (is_array($decodedShell)) { return [ 'ok' => (bool) ($decodedShell['ok'] ?? false), 'error' => $decodedShell['error'] ?? null, 'body' => $decodedShell['body'] ?? null, ]; } } } if (function_exists('curl_init')) { $ch = curl_init($url); curl_setopt_array($ch, [ CURLOPT_POST => true, CURLOPT_RETURNTRANSFER => true, CURLOPT_HTTPHEADER => [ 'Content-Type: application/json', 'Content-Length: ' . strlen($jsonPayload), ], CURLOPT_POSTFIELDS => $jsonPayload, CURLOPT_TIMEOUT => 20, ]); $body = curl_exec($ch); $error = curl_error($ch); curl_close($ch); return [ 'ok' => $body !== false, 'error' => $body === false ? $error : null, 'body' => $body !== false ? $body : null, ]; } $context = stream_context_create([ 'http' => [ 'method' => 'POST', 'header' => implode("\r\n", [ 'Content-Type: application/json', 'Content-Length: ' . strlen($jsonPayload), ]), 'content' => $jsonPayload, 'timeout' => 20, 'ignore_errors' => true, ], ]); $body = @file_get_contents($url, false, $context); return [ 'ok' => $body !== false, 'error' => $body === false ? 'HTTP-Request fehlgeschlagen.' : null, 'body' => $body !== false ? $body : null, ]; } function telegramHttpPostMultipart(string $url, array $fields, string $fileField, string $filePath, ?string $fileName = null): array { if (!is_file($filePath)) { return [ 'ok' => false, 'error' => 'Datei fuer Telegram-Versand nicht gefunden: ' . $filePath, 'body' => null, ]; } if (DIRECTORY_SEPARATOR === '\\' && function_exists('shell_exec')) { $urlBase64 = base64_encode($url); $filePathBase64 = base64_encode($filePath); $fileNameBase64 = base64_encode((string) ($fileName ?? basename($filePath))); $fieldsBase64 = base64_encode(json_encode($fields, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES) ?: '{}'); $fileFieldBase64 = base64_encode($fileField); $powerShellScript = str_replace( ['__URL_BASE64__', '__FILE_PATH_BASE64__', '__FILE_NAME_BASE64__', '__FIELDS_BASE64__', '__FILE_FIELD_BASE64__'], [$urlBase64, $filePathBase64, $fileNameBase64, $fieldsBase64, $fileFieldBase64], <<<'PS' $ErrorActionPreference = 'Stop' $url = [System.Text.Encoding]::UTF8.GetString([System.Convert]::FromBase64String('__URL_BASE64__')) $filePath = [System.Text.Encoding]::UTF8.GetString([System.Convert]::FromBase64String('__FILE_PATH_BASE64__')) $fileName = [System.Text.Encoding]::UTF8.GetString([System.Convert]::FromBase64String('__FILE_NAME_BASE64__')) $fileField = [System.Text.Encoding]::UTF8.GetString([System.Convert]::FromBase64String('__FILE_FIELD_BASE64__')) $fieldsJson = [System.Text.Encoding]::UTF8.GetString([System.Convert]::FromBase64String('__FIELDS_BASE64__')) $fieldsObject = $fieldsJson | ConvertFrom-Json $form = @{} foreach ($property in $fieldsObject.PSObject.Properties) { $form[$property.Name] = [string] $property.Value } $form[$fileField] = Get-Item -LiteralPath $filePath try { $response = Invoke-RestMethod -Method Post -Uri $url -Form $form [PSCustomObject]@{ ok = $true body = ($response | ConvertTo-Json -Depth 20 -Compress) error = '' } | ConvertTo-Json -Compress } catch { [PSCustomObject]@{ ok = $false body = '' error = $_.Exception.Message } | ConvertTo-Json -Compress } PS ); $encodedCommandBinary = function_exists('iconv') ? iconv('UTF-8', 'UTF-16LE', $powerShellScript) : $powerShellScript; $encodedCommand = base64_encode($encodedCommandBinary === false ? $powerShellScript : $encodedCommandBinary); $command = 'powershell -NoProfile -ExecutionPolicy Bypass -EncodedCommand ' . $encodedCommand; $rawOutput = shell_exec($command); if ($rawOutput !== null && trim($rawOutput) !== '') { $decodedShell = json_decode(trim($rawOutput), true); if (is_array($decodedShell)) { return [ 'ok' => (bool) ($decodedShell['ok'] ?? false), 'error' => $decodedShell['error'] ?? null, 'body' => $decodedShell['body'] ?? null, ]; } } } if (function_exists('curl_init')) { $payload = $fields; $extension = strtolower((string) pathinfo($filePath, PATHINFO_EXTENSION)); $mimeType = match ($extension) { 'png' => 'image/png', 'jpg', 'jpeg' => 'image/jpeg', 'svg' => 'image/svg+xml', default => 'application/octet-stream', }; $payload[$fileField] = new CURLFile($filePath, $mimeType, $fileName ?? basename($filePath)); $ch = curl_init($url); curl_setopt_array($ch, [ CURLOPT_POST => true, CURLOPT_RETURNTRANSFER => true, CURLOPT_POSTFIELDS => $payload, CURLOPT_TIMEOUT => 30, ]); $body = curl_exec($ch); $error = curl_error($ch); curl_close($ch); return [ 'ok' => $body !== false, 'error' => $body === false ? $error : null, 'body' => $body !== false ? $body : null, ]; } return [ 'ok' => false, 'error' => 'Multipart-Versand ist ohne curl nicht verfuegbar.', 'body' => null, ]; } function telegramSendBotMessage(array $telegramConfig, string $chatId, string $messageText): bool { $botToken = trim((string) ($telegramConfig['bot_token'] ?? '')); if ($botToken === '' || $chatId === '' || $messageText === '') { return false; } $response = telegramHttpPostJson( 'https://api.telegram.org/bot' . $botToken . '/sendMessage', [ 'chat_id' => $chatId, 'text' => $messageText, ] ); return (bool) ($response['ok'] ?? false); } function telegramSendBotDocument(array $telegramConfig, string $chatId, string $filePath, string $caption = ''): bool { $botToken = trim((string) ($telegramConfig['bot_token'] ?? '')); if ($botToken === '' || $chatId === '' || !is_file($filePath)) { return false; } $response = telegramHttpPostMultipart( 'https://api.telegram.org/bot' . $botToken . '/sendDocument', [ 'chat_id' => $chatId, 'caption' => $caption, ], 'document', $filePath, basename($filePath) ); return (bool) ($response['ok'] ?? false); } function telegramSendBotPhoto(array $telegramConfig, string $chatId, string $filePath, string $caption = ''): bool { $botToken = trim((string) ($telegramConfig['bot_token'] ?? '')); if ($botToken === '' || $chatId === '' || !is_file($filePath)) { return false; } $response = telegramHttpPostMultipart( 'https://api.telegram.org/bot' . $botToken . '/sendPhoto', [ 'chat_id' => $chatId, 'caption' => $caption, ], 'photo', $filePath, basename($filePath) ); return (bool) ($response['ok'] ?? false); } function formatDecimal(mixed $value, int $decimals): string { if ($value === null || $value === '') { return '—'; } if (!is_numeric((string) $value)) { return (string) $value; } return number_format((float) $value, $decimals, '.', ''); } function isTaskDue(string $schedule, DateTimeImmutable $nowBerlin): bool { if ($schedule === 'every-run') { return true; } if ($schedule === 'every-minute') { return true; } 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); }