85ef2f6e00
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>
104 lines
3.5 KiB
PHP
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()];
|
|
}
|
|
}
|
|
}
|