Files
Vasyka 85ef2f6e00 Stage 13 — Notifications: Telegram bot + multi-channel + service reminders
Schema:
- clients.telegram_chat_id (linked via /start contact-share)
- clients.notify_prefs (per-client channel order override)
- service_reminders_sent (dedup ledger for the daily cron)

Telegram (per tenant):
- TelegramService (sendMessage, getMe, setWebhook with auto-generated secret)
- Bot token stored in companies.settings.telegram.bot_token
- Webhook /telegram/webhook/{slug} validates X-Telegram-Bot-Api-Secret-Token,
  matches client by last 9 digits of phone, persists chat_id, replies confirm
- /start prompts share-contact; /stop unlinks chat_id

NotificationDispatcher refactor:
- Multi-channel: telegram first if chat_id + bot configured, then email
- Backwards-compat with legacy boolean notify.{type} flags
- 4 HTML-formatted Telegram messages (wo_ready with tracking link, payment,
  appointment, reminder)

Service reminders:
- `reminders:send` artisan command with --slug / --dry-run
- Policy: vehicles whose last closed WO is older than reminder.after_days
  (default 365). Skips if sent within reminder.cooldown_days (default 30).
- Schedule daily 09:00

Filament UI:
- Settings page: Telegram bot token field + "Test bot" + "Set webhook" actions
- Settings page: reminder_after_days + reminder_cooldown_days inputs
- ClientResource: telegram_chat_id readonly badge

Tests (6 new, all pass):
- webhook links client via shared contact
- webhook rejects wrong secret → 401
- dispatcher uses telegram when chat_id present (Http::fake)
- dispatcher falls back to email otherwise
- dispatcher returns false when no channel available
- reminder cron respects 30-day cooldown

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-27 20:14:17 +00:00

104 lines
3.5 KiB
PHP

<?php
namespace App\Services\Notifications;
use App\Models\Central\Company;
use Illuminate\Support\Facades\Http;
use Illuminate\Support\Facades\Log;
/**
* Thin client over Bot API. Tenant-aware: bot token comes from
* companies.settings.telegram.bot_token. Webhook secret used to verify
* incoming updates (Telegram sends it back as X-Telegram-Bot-Api-Secret-Token).
*/
class TelegramService
{
private const API = 'https://api.telegram.org/bot';
public function tokenFor(Company $company): ?string
{
return data_get($company->settings, 'telegram.bot_token');
}
public function webhookSecretFor(Company $company): ?string
{
return data_get($company->settings, 'telegram.webhook_secret');
}
public function webhookUrlFor(Company $company): string
{
$central = config('app.central_domain') ?: 'service.mir.md';
// We expose the webhook on the central domain so Telegram does not
// need to know about subdomain wildcards. Slug routes to tenant.
return "https://{$central}/telegram/webhook/{$company->slug}";
}
public function sendMessage(Company $company, string $chatId, string $text, array $options = []): bool
{
$token = $this->tokenFor($company);
if (! $token || ! $chatId) return false;
try {
$resp = Http::asJson()
->timeout(10)
->post(self::API . $token . '/sendMessage', array_merge([
'chat_id' => $chatId,
'text' => $text,
'parse_mode' => 'HTML',
'disable_web_page_preview' => true,
], $options));
if (! $resp->ok()) {
Log::warning('telegram.send failed', [
'tenant' => $company->slug,
'status' => $resp->status(),
'body' => $resp->body(),
]);
return false;
}
return true;
} catch (\Throwable $e) {
Log::warning('telegram.send exception', ['err' => $e->getMessage()]);
return false;
}
}
public function setWebhook(Company $company): array
{
$token = $this->tokenFor($company);
if (! $token) return ['ok' => false, 'error' => 'Lipsește bot token în setări.'];
$secret = $this->webhookSecretFor($company);
if (! $secret) {
$secret = \Illuminate\Support\Str::random(32);
$company->update([
'settings' => array_replace_recursive((array) $company->settings, [
'telegram' => ['webhook_secret' => $secret],
]),
]);
}
try {
$resp = Http::asJson()->post(self::API . $token . '/setWebhook', [
'url' => $this->webhookUrlFor($company),
'secret_token' => $secret,
'allowed_updates' => ['message', 'callback_query'],
]);
return ['ok' => $resp->ok(), 'response' => $resp->json()];
} catch (\Throwable $e) {
return ['ok' => false, 'error' => $e->getMessage()];
}
}
public function getMe(Company $company): array
{
$token = $this->tokenFor($company);
if (! $token) return ['ok' => false, 'error' => 'no_token'];
try {
$resp = Http::timeout(10)->get(self::API . $token . '/getMe');
return ['ok' => $resp->ok(), 'response' => $resp->json()];
} catch (\Throwable $e) {
return ['ok' => false, 'error' => $e->getMessage()];
}
}
}