TCRB senden eingabeut

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