namespace App\Services; use App\Jobs\DispatchTelegramAlert; use App\Models\ActivityLog; use App\Models\SmsUnitTransaction; use App\Models\TelegramAlertLog; use App\Models\User; use Illuminate\Support\Facades\Cache; use Illuminate\Support\Facades\Http; use Illuminate\Support\Facades\Log; use Illuminate\Support\Str; use Throwable; class TelegramAlertService { /** * Send a platform occurrence alert. * * @param array $context */ public function occurrence(string $event, string $title, array $context = [], string $severity = 'info'): void { if (! $this->isEventEnabled($event)) { return; } $payload = [ 'event' => $event, 'title' => $title, 'severity' => $severity, 'context' => $context, ]; $this->dispatch($payload); } /** * Bridge from ActivityLog rows created by observers. */ public function fromActivityLog(ActivityLog $log): void { // Flat map keys โ€” do not use config('โ€ฆ.'.$event); Laravel treats dots as nesting. $event = config('telegram-alerts.activity_log_map', [])[$log->event] ?? null; if (! $event || ! $this->isEventEnabled($event)) { if ($event && in_array($event, ['balance.topup', 'balance.manual_add'], true)) { Log::info('TelegramAlertService: balance alert skipped (event disabled)', [ 'event' => $event, 'activity_event' => $log->event, ]); } return; } $context = array_merge($log->properties ?? [], [ 'description' => $log->description, 'causer' => $log->causer_name, 'subject' => $log->subject_name, 'activity_event' => $log->event, ]); // Platform + reseller inventory only โ€” never reseller-owned customer top-ups. if (in_array($event, ['balance.topup', 'balance.manual_add', 'balance.manual_remove'], true) && ($context['owner_scope'] ?? null) === 'reseller_customer') { Log::info('TelegramAlertService: balance alert skipped (reseller_customer scope)', [ 'event' => $event, 'source' => $context['source'] ?? null, 'customer' => $context['customer'] ?? null, ]); return; } $severity = match ($event) { 'balance.topup', 'balance.manual_add' => 'revenue', 'campaign.stuck', 'low_balance.customer', 'low_balance.provider', 'circuit_breaker.recovered' => 'warning', 'campaign.failed', 'application.error', 'log.error', 'circuit_breaker.open' => 'critical', default => 'info', }; // Collected-today is ops sugar for M-Pesa only. Never let it abort the revenue alert // (manual top-ups skip this path โ€” a throw here was silencing STK/Paybill alerts). if ($event === 'balance.topup' && in_array($context['source'] ?? null, ['mpesa', 'mpesa_manual'], true)) { try { $context['collected_today'] = $this->formatKes($this->todayMpesaCollectionsKes()); } catch (Throwable $e) { Log::warning('TelegramAlertService: collected_today failed; sending top-up alert without it', [ 'error' => $e->getMessage(), 'source' => $context['source'] ?? null, ]); } } $this->occurrence($event, $this->titleFromActivityLog($event, $context), $context, $severity); } /** * Prefer a clearer ops title for platform M-Pesa top-ups. * * @param array $context */ protected function titleFromActivityLog(string $event, array $context): string { if ($event === 'balance.topup') { $source = $context['source'] ?? null; $scope = $context['owner_scope'] ?? null; if (in_array($source, ['mpesa', 'mpesa_manual'], true)) { $channel = ($source === 'mpesa_manual') ? 'Paybill' : 'STK'; return match ($scope) { 'platform' => "Platform customer M-Pesa {$channel} top-up", 'reseller_account' => "Reseller inventory M-Pesa {$channel} top-up", 'reseller_customer' => "Reseller customer M-Pesa {$channel} top-up", 'sales' => "Sales customer M-Pesa {$channel} top-up", default => "M-Pesa {$channel} top-up", }; } } return $this->titleFromEvent($event); } /** * Report an uncaught application error. */ public function reportException(Throwable $exception): void { if (! $this->isEventEnabled('application.error')) { return; } if ($this->shouldIgnoreException($exception)) { return; } $this->occurrence('application.error', 'Application error', [ 'exception' => get_class($exception), 'message' => Str::limit($exception->getMessage(), 500), 'file' => $exception->getFile() . ':' . $exception->getLine(), 'url' => request()->fullUrl() ?? null, 'user' => auth()->user()?->email, ], 'critical'); } /** * Report a failed queue job. * * @param array $context */ public function reportFailedJob(string $jobName, Throwable $exception, array $context = []): void { if (! $this->isEventEnabled('queue.job_failed')) { return; } $this->occurrence('queue.job_failed', 'Queue job failed', array_merge($context, [ 'job' => $jobName, 'exception' => get_class($exception), 'message' => Str::limit($exception->getMessage(), 400), ]), 'critical'); } /** * Admin portal mutation audit. * * @param array $context */ public function adminMutation(string $title, array $context = []): void { if (! $this->isEventEnabled('admin.mutation')) { return; } $this->occurrence('admin.mutation', $title, $context, 'warning'); } /** * @param array $payload */ protected function dispatch(array $payload): void { if (! config('telegram-alerts.enabled')) { return; } if (empty(config('telegram-alerts.bot_token'))) { Log::warning('TelegramAlertService: bot token not configured'); return; } $dedupeKey = 'telegram_alert:' . md5($payload['event'] . '|' . ($payload['title'] ?? '') . '|' . json_encode($payload['context'] ?? [])); if (empty($payload['skip_dedupe']) && Cache::has($dedupeKey)) { return; } if (empty($payload['skip_dedupe'])) { Cache::put($dedupeKey, true, config('telegram-alerts.dedupe_seconds', 120)); } if (config('telegram-alerts.async', false)) { DispatchTelegramAlert::dispatch($payload)->onQueue(config('telegram-alerts.queue', 'default')); return; } $this->sendNow($payload); } /** * Send a test alert and return the delivery result (bypasses dedupe). * * @param array $context * @return array{success: bool, queued: bool, error: ?string} */ public function sendTestAlert(array $context = []): array { if (! config('telegram-alerts.enabled')) { return ['success' => false, 'queued' => false, 'error' => 'Telegram alerts are disabled. Enable the switch and save first.']; } if (empty(config('telegram-alerts.bot_token'))) { return ['success' => false, 'queued' => false, 'error' => 'Bot token is not configured. Enter a token and save first.']; } $chatId = config('telegram-alerts.chats.default'); if (empty($chatId)) { return ['success' => false, 'queued' => false, 'error' => 'Default chat ID is missing. Enter a chat ID and save first.']; } $payload = [ 'event' => 'telegram.test', 'title' => 'Telegram settings test', 'severity' => 'info', 'context' => array_merge($context, ['test_id' => uniqid('', true)]), 'skip_dedupe' => true, ]; if (config('telegram-alerts.async', false)) { DispatchTelegramAlert::dispatch($payload)->onQueue(config('telegram-alerts.queue', 'default')); return ['success' => true, 'queued' => true, 'error' => null]; } $sent = $this->sendNow($payload); $log = TelegramAlertLog::query()->latest('id')->first(); return [ 'success' => $sent, 'queued' => false, 'error' => $sent ? null : ($log?->error_message ?: 'Telegram API rejected the message. Check bot token, chat ID, and that the bot was added to the chat.'), ]; } /** * Apply unsaved form values for a one-off test (does not persist). * * @param array $input */ public function applyConfigOverrides(array $input): void { if (array_key_exists('telegram_alerts_enabled', $input)) { config(['telegram-alerts.enabled' => ! empty($input['telegram_alerts_enabled'])]); } if (! empty($input['telegram_bot_token'])) { config(['telegram-alerts.bot_token' => trim((string) $input['telegram_bot_token'])]); } if (! empty($input['telegram_bot_chat_id'])) { $chatId = trim((string) $input['telegram_bot_chat_id']); config([ 'telegram-alerts.chats.default' => $chatId, 'telegram-alerts.chats.critical' => $input['telegram_chat_critical'] ?? $chatId, 'telegram-alerts.chats.ops' => $input['telegram_chat_ops'] ?? $chatId, 'telegram-alerts.chats.revenue' => $input['telegram_chat_revenue'] ?? $chatId, ]); } } /** * @param array $payload */ public function sendNow(array $payload): bool { $message = $this->formatMessage($payload); $severity = $payload['severity'] ?? 'info'; $chatId = $this->resolveChatId($severity); if ($chatId === null || $chatId === '') { Log::warning('TelegramAlertService: chat_id not configured', ['severity' => $severity]); TelegramAlertLog::create([ 'event' => $payload['event'] ?? 'unknown', 'severity' => $severity, 'title' => $payload['title'] ?? '', 'message' => $message, 'context' => $payload['context'] ?? null, 'chat_id' => null, 'status' => TelegramAlertLog::STATUS_FAILED, 'error_message' => 'Telegram chat ID is not configured.', ]); return false; } $log = TelegramAlertLog::create([ 'event' => $payload['event'] ?? 'unknown', 'severity' => $severity, 'title' => $payload['title'] ?? '', 'message' => $message, 'context' => $payload['context'] ?? null, 'chat_id' => (string) $chatId, 'status' => TelegramAlertLog::STATUS_PENDING, ]); try { $response = Http::timeout(15) ->post($this->apiUrl('sendMessage'), [ 'chat_id' => $chatId, 'text' => $message, 'parse_mode' => 'HTML', 'disable_web_page_preview' => true, ]); if ($response->successful() && ($response->json('ok') === true)) { $log->update([ 'status' => TelegramAlertLog::STATUS_SENT, 'telegram_message_id' => $response->json('result.message_id'), 'sent_at' => now(), ]); $this->sendToPersonal($message); return true; } $errorMessage = $this->humanizeTelegramError($response->body()); $log->update([ 'status' => TelegramAlertLog::STATUS_FAILED, 'error_message' => Str::limit($errorMessage, 1000), ]); Log::warning('TelegramAlertService: API error', [ 'status' => $response->status(), 'body' => $response->body(), ]); } catch (Throwable $e) { $log->update([ 'status' => TelegramAlertLog::STATUS_FAILED, 'error_message' => Str::limit($e->getMessage(), 1000), ]); Log::error('TelegramAlertService: send failed', ['error' => $e->getMessage()]); } return false; } protected function sendToPersonal(string $message): void { $personalIds = array_filter(array_map('trim', explode(',', (string) config('telegram-alerts.chats.personal', '')))); if (empty($personalIds)) { return; } foreach ($personalIds as $personalId) { try { Http::timeout(10)->post($this->apiUrl('sendMessage'), [ 'chat_id' => $personalId, 'text' => $message, 'parse_mode' => 'HTML', 'disable_web_page_preview' => true, ]); } catch (Throwable $e) { Log::warning('TelegramAlertService: personal notify failed', ['error' => $e->getMessage()]); } } } /** * @param array $payload */ protected function formatMessage(array $payload): string { $emoji = match ($payload['severity'] ?? 'info') { 'critical' => '๐Ÿšจ', 'warning' => 'โš ๏ธ', 'revenue' => '๐Ÿ’ฐ', default => 'โ„น๏ธ', }; $lines = [ "{$emoji} " . $this->escape($payload['title'] ?? 'Alert') . '', '' . $this->escape($payload['event'] ?? '') . '', '๐ŸŒ Env: ' . $this->escape(config('app.env')) . ' ยท ' . config('app.name'), ]; $context = $payload['context'] ?? []; foreach ($this->flattenContext($context) as $label => $value) { if ($value === null || $value === '') { continue; } $lines[] = '' . $this->escape($label) . ': ' . $this->escape((string) $value); } $lines[] = '๐Ÿ• ' . now()->timezone(config('app.timezone'))->format('Y-m-d H:i:s T'); $message = implode("\n", $lines); return Str::limit($message, config('telegram-alerts.max_message_length', 4000), 'โ€ฆ'); } /** * @param array $context * @return array */ protected function flattenContext(array $context): array { $preferred = [ 'description', 'customer', 'user', 'email', 'owner', 'reseller', 'units', 'amount', 'collected_today', 'balance_after', 'campaign', 'campaign_uid', 'recipients', 'status', 'source', 'payment_method', 'sender_id', 'admin', 'causer', 'subject', 'route', 'method', 'section', 'provider', 'job', 'exception', 'message', 'file', 'url', 'error', 'last_error', 'downtime', 'downtime_seconds', 'opened_at', 'recovered_at', 'failures', 'threshold', 'state', 'note', 'pending_contacts', 'processed', 'total', ]; $out = []; foreach ($preferred as $key) { if (array_key_exists($key, $context) && $context[$key] !== null && $context[$key] !== '') { $out[$this->labelize($key)] = is_scalar($context[$key]) ? (string) $context[$key] : json_encode($context[$key]); } } foreach ($context as $key => $value) { if (isset($out[$this->labelize($key)]) || is_array($value) || is_object($value)) { continue; } if ($value !== null && $value !== '') { $out[$this->labelize((string) $key)] = (string) $value; } } if (! empty($context['link'])) { $out['Link'] = $context['link']; } return $out; } protected function labelize(string $key): string { return match ($key) { 'collected_today' => 'Collected today (M-Pesa)', 'last_error' => 'Last error', 'downtime_seconds' => 'Downtime (seconds)', 'opened_at' => 'Opened at', 'recovered_at' => 'Recovered at', default => Str::title(str_replace('_', ' ', $key)), }; } /** * Sum M-Pesa KES collected today for platform customers + reseller inventory. * Excludes reseller-owned customer top-ups (same scope as Telegram balance alerts). */ protected function todayMpesaCollectionsKes(): float { try { $tz = config('app.timezone', 'Africa/Nairobi'); $start = now()->timezone($tz)->startOfDay(); $end = now()->timezone($tz)->endOfDay(); return (float) SmsUnitTransaction::query() ->whereIn('source', ['mpesa', 'mpesa_manual']) ->whereBetween('created_at', [$start, $end]) ->where('amount', '>', 0) ->whereHas('user', function ($query) { $query->where(function ($query) { $query->where('is_reseller', true) ->orWhereNull('reseller_id'); }); }) ->sum('amount'); } catch (Throwable $e) { Log::warning('TelegramAlertService: todayMpesaCollectionsKes query failed', [ 'error' => $e->getMessage(), ]); return 0.0; } } protected function formatKes(float $amount): string { return 'KES ' . number_format($amount, 0, '.', ','); } protected function escape(string $text): string { return htmlspecialchars($text, ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8'); } protected function resolveChatId(string $severity): string|int { $channel = config("telegram-alerts.severity_channels.{$severity}", 'default'); $chats = config('telegram-alerts.chats', []); $chatId = $chats[$channel] ?? null; // Empty TELEGRAM_CHAT_REVENUE= (etc.) in .env must not block revenue/ops alerts. if ($chatId === null || $chatId === '') { $chatId = $chats['default'] ?? null; } return $chatId ?? ''; } protected function humanizeTelegramError(string $body): string { $json = json_decode($body, true); if (! is_array($json)) { return Str::limit($body, 500); } $description = (string) ($json['description'] ?? $body); $lower = strtolower($description); if (str_contains($lower, "can't send messages to the bot")) { return 'Wrong chat ID: this looks like a bot ID, not your personal user ID. Open your bot in Telegram, tap Start, send a message, then use chat.id from getUpdates (a positive number). Do not use the bot token, @username, or another bot\'s ID.'; } if (str_contains($lower, 'chat not found')) { return 'Chat not found: verify the chat ID and make sure the bot was added to that group or channel.'; } if (str_contains($lower, 'bot was blocked by the user')) { return 'The bot was blocked. Unblock it in Telegram or use a group/channel chat ID instead of a personal chat.'; } if (str_contains($lower, 'not a member of the') || str_contains($lower, 'need administrator')) { return 'The bot is not in that chat or lacks permission. Add the bot to the group/channel (as admin for channels) and try again.'; } return $description; } protected function apiUrl(string $method): string { return 'https://api.telegram.org/bot' . config('telegram-alerts.bot_token') . '/' . $method; } protected function isEventEnabled(string $event): bool { $overrides = self::eventToggleOverrides(); if (array_key_exists($event, $overrides)) { return (bool) $overrides[$event]; } // Events use dotted keys (e.g. balance.manual_add). Arr::get / config('a.b.c') // treats dots as nesting, so always read the flat events array by key. $defaults = config('telegram-alerts.events', []); return (bool) ($defaults[$event] ?? true); } /** * Merged event defaults + app_config overrides for the settings UI. * * @return array */ public static function resolvedEventToggles(): array { $defaults = config('telegram-alerts.events', []); $overrides = self::eventToggleOverrides(); $resolved = []; foreach ($defaults as $event => $default) { $resolved[$event] = array_key_exists($event, $overrides) ? (bool) $overrides[$event] : (bool) $default; } return $resolved; } /** * @return array */ public static function eventToggleOverrides(): array { try { $raw = \App\Helpers\Helper::app_config('telegram_event_toggles'); if ($raw === null || $raw === '' || $raw === false) { return []; } $decoded = is_array($raw) ? $raw : json_decode((string) $raw, true); if (! is_array($decoded) || $decoded === []) { return []; } $normalized = []; foreach ($decoded as $key => $value) { $event = self::normalizeEventToggleKey((string) $key); if ($event === null) { continue; } $normalized[$event] = filter_var($value, FILTER_VALIDATE_BOOLEAN); } // Honor intentional "disable all" (all false). Do not fall back to defaults. return $normalized; } catch (\Throwable) { return []; } } /** * Persist event toggles to app_config (does not touch .env). * * Accepts either: * - telegram_events[] = list of enabled event keys (preferred; dots-safe), or * - telegram_events[event] = 1 map (legacy) * * @param array $input */ public static function persistEventToggles(array $input): void { $defaults = array_keys(config('telegram-alerts.events', [])); $submitted = $input['telegram_events'] ?? []; if (! is_array($submitted)) { $submitted = []; } $enabled = []; $isList = array_is_list($submitted) || (array_keys($submitted) === range(0, count($submitted) - 1)); if ($isList) { foreach ($submitted as $value) { $event = self::normalizeEventToggleKey((string) $value); if ($event !== null) { $enabled[$event] = true; } } } else { foreach ($submitted as $key => $value) { if (empty($value)) { continue; } $event = self::normalizeEventToggleKey((string) $key); if ($event !== null) { $enabled[$event] = true; } } } $toggles = []; foreach ($defaults as $event) { $toggles[$event] = ! empty($enabled[$event]); } $row = \App\Models\AppConfig::query()->firstOrNew(['setting' => 'telegram_event_toggles']); $row->value = json_encode($toggles); $row->save(); config(['telegram-alerts.events' => array_merge(config('telegram-alerts.events', []), $toggles)]); } /** * Map a posted event id back to a known telegram-alerts.events key. */ protected static function normalizeEventToggleKey(string $key): ?string { $known = array_keys(config('telegram-alerts.events', [])); if (in_array($key, $known, true)) { return $key; } // PHP converts dots in request variable names to underscores. foreach ($known as $event) { if (str_replace('.', '_', $event) === $key) { return $event; } } return null; } protected function shouldIgnoreException(Throwable $exception): bool { $ignored = [ \Illuminate\Auth\AuthenticationException::class, \Illuminate\Auth\Access\AuthorizationException::class, \Illuminate\Validation\ValidationException::class, \Symfony\Component\HttpKernel\Exception\NotFoundHttpException::class, \Illuminate\Database\Eloquent\ModelNotFoundException::class, \Illuminate\Database\RecordsNotFoundException::class, \Illuminate\Session\TokenMismatchException::class, ]; foreach ($ignored as $class) { if ($exception instanceof $class) { return true; } } // Client HTTP errors (405, 429, 403, โ€ฆ) are scanners or bad requests, not app faults. if ($exception instanceof \Symfony\Component\HttpKernel\Exception\HttpExceptionInterface) { $status = $exception->getStatusCode(); if ($status >= 400 && $status < 500) { return true; } } return false; } protected function titleFromEvent(string $event): string { return match ($event) { 'customer.created' => 'New customer account', 'customer.registered' => 'Customer self-registration', 'admin.created' => 'New administrator account', 'balance.topup' => 'SMS balance top-up', 'balance.manual_add' => 'SMS balance added (manual)', 'balance.manual_remove' => 'SMS balance removed', 'campaign.created' => 'Campaign created', 'campaign.processing' => 'Campaign started', 'campaign.delivered' => 'Campaign completed', 'campaign.failed' => 'Campaign failed', 'campaign.paused' => 'Campaign paused', 'campaign.stuck' => 'Campaign stuck processing', 'sender_id.requested' => 'Sender ID requested', 'sender_id.order_submitted' => 'Sender ID order submitted', 'sender_id.order_paid' => 'Sender ID order paid', 'sender_id.activated' => 'Sender ID activated', 'reseller.created' => 'New reseller', 'reseller.customer_registered' => 'Reseller customer registered', 'ticket.created' => 'Support ticket opened', 'low_balance.customer' => 'Customer low balance', 'low_balance.reseller' => 'Reseller low inventory', 'low_balance.provider' => 'Provider low balance', 'admin.mutation' => 'Admin configuration change', 'circuit_breaker.open' => 'SMS provider circuit open', 'circuit_breaker.recovered' => 'SMS provider recovered', 'queue.job_failed' => 'Background job failed', 'application.error' => 'Application error', 'log.error' => 'Log error (laravel.log)', default => Str::title(str_replace(['.', '_'], ' ', $event)), }; } /** * Helper: describe customer ownership for alerts. */ public static function customerOwnerLabel(?User $user): string { if (! $user) { return 'Unknown'; } if ($user->reseller_id) { $reseller = \App\Models\Reseller::find($user->reseller_id); return 'Reseller: ' . ($reseller?->company_name ?? '#' . $user->reseller_id); } if ($user->sales_team_id) { return 'Sales team #' . $user->sales_team_id; } return 'Platform (admin-owned)'; } /** * Build admin deep link when possible. */ public static function adminUrl(string $path): string { $base = rtrim(config('app.url'), '/'); $prefix = trim(config('app.admin_path', 'admin'), '/'); return "{$base}/{$prefix}/" . ltrim($path, '/'); } } Page Not Found - Bulk SMS Application For Marketing

Page Not Found!๏ธ

The route sitemap.xml could not be found.

Back to Home Error page