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>
This commit is contained in:
@@ -0,0 +1,103 @@
|
||||
<?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()];
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user