diff --git a/public/index.html b/public/index.html
index 74a9f93..ae295fd 100644
--- a/public/index.html
+++ b/public/index.html
@@ -306,6 +306,23 @@ pre.raw-line { background: var(--bs-tertiary-bg); padding: .75rem; border-radius
+
@@ -873,6 +890,12 @@ async function loadSettings() {
const tokens = res.tokens || [];
document.getElementById('allowedTokensInput').value = tokens.join('\n');
} catch (e) { console.error('load tokens error', e); }
+
+ try {
+ const res = await api('/config/telegram');
+ document.getElementById('telegramBotToken').value = res.bot_token || '';
+ document.getElementById('telegramChatId').value = res.chat_id || '';
+ } catch (e) { console.error('load telegram error', e); }
}
document.getElementById('saveTokensBtn').addEventListener('click', async () => {
@@ -897,6 +920,43 @@ document.getElementById('saveTokensBtn').addEventListener('click', async () => {
}
});
+document.getElementById('saveTelegramBtn').addEventListener('click', async () => {
+ const botToken = document.getElementById('telegramBotToken').value.trim();
+ const chatId = document.getElementById('telegramChatId').value.trim();
+ const statusEl = document.getElementById('telegramSaveStatus');
+ statusEl.textContent = 'Saving...';
+ statusEl.className = 'ms-2 text-secondary';
+ try {
+ await api('/config/telegram', {
+ method: 'PUT',
+ body: JSON.stringify({ bot_token: botToken, chat_id: chatId }),
+ });
+ statusEl.textContent = 'Saved';
+ statusEl.className = 'ms-2 text-success';
+ toast('Telegram config saved');
+ setTimeout(() => { statusEl.textContent = ''; }, 3000);
+ } catch (e) {
+ statusEl.textContent = 'Failed';
+ statusEl.className = 'ms-2 text-danger';
+ toast('Failed to save Telegram config', 'danger');
+ }
+});
+
+document.getElementById('testTelegramBtn').addEventListener('click', async () => {
+ const btn = document.getElementById('testTelegramBtn');
+ btn.disabled = true;
+ btn.innerHTML = ' Sending...';
+ try {
+ await api('/ingest', {
+ method: 'POST',
+ body: JSON.stringify({ line: 'Jakach Logging: test notification from settings', source: 'test' }),
+ });
+ toast('Test alert sent. Check Telegram.');
+ } catch (e) { toast('Failed to send test', 'danger'); }
+ btn.disabled = false;
+ btn.innerHTML = ' Test';
+});
+
// --- LOGS ---
async function loadLogs(query) {
if (!query) {
diff --git a/src/Api/Router.php b/src/Api/Router.php
index ccb73c3..1dfc502 100644
--- a/src/Api/Router.php
+++ b/src/Api/Router.php
@@ -84,6 +84,11 @@ class Router
$path === '/config/allowed_tokens' && $method === 'PUT'
=> $this->updateAllowedTokens(),
+ $path === '/config/telegram' && $method === 'GET'
+ => $this->getTelegramConfig(),
+ $path === '/config/telegram' && $method === 'PUT'
+ => $this->updateTelegramConfig(),
+
default => throw new \RuntimeException('Not found', 404),
};
@@ -271,4 +276,20 @@ class Router
return ['status' => 'ingested', 'line' => substr($line, 0, 100)];
}
+
+ private function getTelegramConfig(): array
+ {
+ return [
+ 'bot_token' => $this->repo->getConfig('telegram_bot_token', ''),
+ 'chat_id' => $this->repo->getConfig('telegram_chat_id', ''),
+ ];
+ }
+
+ private function updateTelegramConfig(): array
+ {
+ $body = json_decode(file_get_contents('php://input'), true);
+ $this->repo->setConfig('telegram_bot_token', $body['bot_token'] ?? '');
+ $this->repo->setConfig('telegram_chat_id', $body['chat_id'] ?? '');
+ return $this->getTelegramConfig();
+ }
}
\ No newline at end of file
diff --git a/src/Notifier/TelegramNotifier.php b/src/Notifier/TelegramNotifier.php
new file mode 100644
index 0000000..741114e
--- /dev/null
+++ b/src/Notifier/TelegramNotifier.php
@@ -0,0 +1,73 @@
+botToken = $this->repo->getConfig('telegram_bot_token');
+ $this->chatId = $this->repo->getConfig('telegram_chat_id');
+ }
+
+ public function isConfigured(): bool
+ {
+ return !empty($this->botToken) && !empty($this->chatId);
+ }
+
+ public function send(Alert $alert): bool
+ {
+ if (!$this->isConfigured()) {
+ return false;
+ }
+
+ $emoji = match ($alert->severity->value) {
+ 'emergency', 'critical_high', 'critical' => "\xF0\x9F\x94\xA5",
+ 'critical_low', 'error' => "\xE2\x9D\x8C",
+ 'warning_high', 'warning' => "\xE2\x9A\xA0\xEF\xB8\x8F",
+ 'warning_low' => "\xF0\x9F\x93\xA2",
+ default => "\xE2\x84\xB9\xEF\xB8\x8F",
+ };
+
+ $text = sprintf(
+ "%s *ALERT #%d* [%s]\n",
+ $emoji,
+ $alert->id,
+ strtoupper($alert->severity->value)
+ );
+ $text .= sprintf("*Rule:* %s\n", $alert->ruleName);
+ $text .= sprintf("*Source:* %s\n", $alert->sourceName ?? '—');
+ $text .= sprintf("*Message:* ```\n%s\n```", mb_substr($alert->message, 0, 1000));
+
+ $url = 'https://api.telegram.org/bot' . $this->botToken . '/sendMessage';
+
+ $ch = curl_init();
+ curl_setopt($ch, CURLOPT_URL, $url);
+ curl_setopt($ch, CURLOPT_POST, true);
+ curl_setopt($ch, CURLOPT_POSTFIELDS, http_build_query([
+ 'chat_id' => $this->chatId,
+ 'text' => $text,
+ 'parse_mode' => 'Markdown',
+ 'disable_web_page_preview' => true,
+ ]));
+ curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
+ curl_setopt($ch, CURLOPT_TIMEOUT, 10);
+ $response = curl_exec($ch);
+ $httpCode = curl_getinfo($ch, CURLINFO_HTTP_CODE);
+ curl_close($ch);
+
+ if ($httpCode !== 200) {
+ fprintf(STDERR, "Telegram send failed (HTTP %d): %s\n", $httpCode, $response);
+ return false;
+ }
+
+ return true;
+ }
+}
\ No newline at end of file
diff --git a/src/Storage/Repository.php b/src/Storage/Repository.php
index 0cbb14b..476e6b2 100644
--- a/src/Storage/Repository.php
+++ b/src/Storage/Repository.php
@@ -239,12 +239,25 @@ class Repository
}
public function setAllowedUserTokens(array $tokens): void
+ {
+ $this->setConfig('allowed_user_tokens', json_encode(array_values($tokens)));
+ }
+
+ public function getConfig(string $key, mixed $default = null): mixed
+ {
+ $stmt = $this->db->pdo()->prepare("SELECT value FROM config WHERE key = ?");
+ $stmt->execute([$key]);
+ $row = $stmt->fetch();
+ return $row ? $row['value'] : $default;
+ }
+
+ public function setConfig(string $key, string $value): void
{
$stmt = $this->db->pdo()->prepare(
"INSERT INTO config (key, value) VALUES (?, ?)
ON CONFLICT(key) DO UPDATE SET value = excluded.value"
);
- $stmt->execute(['allowed_user_tokens', json_encode(array_values($tokens))]);
+ $stmt->execute([$key, $value]);
}
// --- Rate Limiting ---
diff --git a/src/Worker/Orchestrator.php b/src/Worker/Orchestrator.php
index 4536049..1c70f16 100644
--- a/src/Worker/Orchestrator.php
+++ b/src/Worker/Orchestrator.php
@@ -3,6 +3,7 @@
namespace Jakach\Logging\Worker;
use Jakach\Logging\Model\{LogSource, Alert};
+use Jakach\Logging\Notifier\TelegramNotifier;
use Jakach\Logging\RuleEngine\Engine;
use Jakach\Logging\Storage\Repository;
@@ -12,6 +13,7 @@ class Orchestrator
private SocketListener $socketListener;
private Repository $repo;
private Engine $engine;
+ private TelegramNotifier $telegram;
private array $sourceMap = [];
private bool $running = true;
@@ -19,6 +21,7 @@ class Orchestrator
{
$this->repo = $repo;
$this->engine = $engine;
+ $this->telegram = new TelegramNotifier($repo);
$this->socketListener = new SocketListener(function (string $line, int $sourceId) {
$this->handleLine($line, $sourceId);
});
@@ -31,6 +34,10 @@ class Orchestrator
{
$this->loadSources();
+ if ($this->telegram->isConfigured()) {
+ fprintf(STDERR, "Telegram notifier configured\n");
+ }
+
pcntl_signal(SIGTERM, function () { $this->running = false; });
pcntl_signal(SIGINT, function () { $this->running = false; });
@@ -74,6 +81,8 @@ class Orchestrator
);
fprintf(STDERR, "%s\n", $msg);
echo $msg . "\n";
+
+ $this->telegram->send($alert);
}
}