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,100 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers;
|
||||
|
||||
use App\Models\Central\Company;
|
||||
use App\Models\Tenant\Client;
|
||||
use App\Services\Notifications\TelegramService;
|
||||
use App\Tenancy\TenantManager;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
|
||||
/**
|
||||
* Receives Telegram updates per tenant. URL: /telegram/webhook/{slug}
|
||||
*
|
||||
* To link a Telegram account to a Client record, the bot expects the user
|
||||
* to share their phone via Telegram's contact share button (Telegram lets
|
||||
* users send their own phone with one tap). We match the shared phone (or
|
||||
* the message text fallback) to clients.phone and persist chat_id.
|
||||
*/
|
||||
class TelegramWebhookController extends Controller
|
||||
{
|
||||
public function handle(Request $request, string $slug, TelegramService $telegram)
|
||||
{
|
||||
$company = Company::where('slug', $slug)->first();
|
||||
if (! $company) return response()->json(['ok' => false], 404);
|
||||
|
||||
$expectedSecret = $telegram->webhookSecretFor($company);
|
||||
$providedSecret = $request->header('X-Telegram-Bot-Api-Secret-Token');
|
||||
if ($expectedSecret && $providedSecret !== $expectedSecret) {
|
||||
Log::warning('telegram.webhook bad secret', ['tenant' => $slug]);
|
||||
return response()->json(['ok' => false], 401);
|
||||
}
|
||||
|
||||
app(TenantManager::class)->setCurrent($company);
|
||||
|
||||
$message = $request->input('message', []);
|
||||
$chatId = (string) data_get($message, 'chat.id', '');
|
||||
if (! $chatId) {
|
||||
return response()->json(['ok' => true]);
|
||||
}
|
||||
|
||||
$contact = data_get($message, 'contact');
|
||||
$text = trim((string) data_get($message, 'text', ''));
|
||||
|
||||
$client = null;
|
||||
$phoneRaw = null;
|
||||
|
||||
if ($contact) {
|
||||
$phoneRaw = data_get($contact, 'phone_number');
|
||||
} elseif (preg_match('/(\+?[0-9\-\s\(\)]{7,})/', $text, $m)) {
|
||||
$phoneRaw = $m[1];
|
||||
}
|
||||
|
||||
if ($phoneRaw) {
|
||||
$needle = Client::normalizePhone($phoneRaw);
|
||||
if ($needle) {
|
||||
$client = Client::whereRaw(
|
||||
"REPLACE(REPLACE(REPLACE(REPLACE(phone, ' ', ''), '-', ''), '(', ''), ')', '') LIKE ?",
|
||||
['%' . substr($needle, -9) . '%']
|
||||
)->first();
|
||||
}
|
||||
}
|
||||
|
||||
if (! $client && $text === '/start') {
|
||||
$telegram->sendMessage($company, $chatId,
|
||||
'Salut! Pentru a primi notificări despre mașina ta, ' .
|
||||
'apasă butonul „Share contact" sau trimite numărul tău de telefon.'
|
||||
);
|
||||
return response()->json(['ok' => true]);
|
||||
}
|
||||
|
||||
if ($client) {
|
||||
$client->telegram_chat_id = $chatId;
|
||||
$client->saveQuietly();
|
||||
|
||||
$name = $company->display_name ?? $company->name;
|
||||
$telegram->sendMessage($company, $chatId,
|
||||
"Te-am identificat — <b>{$client->name}</b>.\n" .
|
||||
"Vei primi aici notificări despre fișele tale de la <b>{$name}</b>.\n\n" .
|
||||
"Trimite /stop oricând ca să oprești notificările."
|
||||
);
|
||||
return response()->json(['ok' => true]);
|
||||
}
|
||||
|
||||
if ($text === '/stop') {
|
||||
Client::where('telegram_chat_id', $chatId)->update(['telegram_chat_id' => null]);
|
||||
$telegram->sendMessage($company, $chatId, 'Notificările au fost oprite.');
|
||||
return response()->json(['ok' => true]);
|
||||
}
|
||||
|
||||
if ($phoneRaw) {
|
||||
$telegram->sendMessage($company, $chatId,
|
||||
"Nu am găsit un client cu acest număr la {$company->name}. " .
|
||||
"Verifică telefonul sau contactează service-ul."
|
||||
);
|
||||
}
|
||||
|
||||
return response()->json(['ok' => true]);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user