Guide for implementing Telegram bot notifications for Sabai
This skill covers implementing Telegram notifications for the Sabai booking system.
Sabai uses 2 bots and 2 groups:
| Bot | Group | Events | Audience |
|---|---|---|---|
| OPS Bot | OPS Group | NEW, PAID | Owner, Dispatcher |
| Therapist Bot | Therapists Group | READY | All Therapists, Owner, Dispatcher |
This separation ensures:
# OPS Bot (for Owner/Dispatcher)
OPS_BOT_TOKEN=1234567890:ABCdefGHIjklMNOpqrSTUvwxYZ
OPS_GROUP_CHAT_ID=-1001234567890
# Therapist Bot (for Masters)
THERAPIST_BOT_TOKEN=0987654321:ZYXwvuTSRqpONMlkjIHGfedCBA
THERAPIST_GROUP_CHAT_ID=-1009876543210
# Base URL for links
APP_URL=https://sabai.uz
// config/telegram.php
return [
'ops' => [
'token' => env('OPS_BOT_TOKEN'),
'chat_id' => env('OPS_GROUP_CHAT_ID'),
],
'therapist' => [
'token' => env('THERAPIST_BOT_TOKEN'),
'chat_id' => env('THERAPIST_GROUP_CHAT_ID'),
],
'enabled' => env('TELEGRAM_ENABLED', true),
'api_url' => 'https://api.telegram.org/bot',
];
// app/Services/TelegramService.php
namespace App\Services;
use App\Models\Order;
use App\Models\Therapist;
use Illuminate\Support\Facades\Http;
use Illuminate\Support\Facades\Log;
class TelegramService
{
/**
* Send NEW order notification to OPS group
*/
public function sendNew(Order $order): bool
{
if (!config('telegram.enabled')) {
return false;
}
$order->load(['client', 'therapist', 'slot', 'massageType', 'oilType']);
$oilShort = $order->oilType ? " ({$order->oilType->name})" : '';
$message = "๐ *NEW* | #{$order->id}\n\n";
$message .= "{$order->massageType->name}{$oilShort} ยท 60 min ยท {$order->formatted_amount}\n";
$message .= "Master: {$order->therapist->name}\n";
$message .= "Time: {$order->slot->date} {$order->slot->start_time}\n";
$message .= "Phone: `{$order->client->phone}`\n\n";
$message .= "[Open in Admin]({$this->adminOrderUrl($order)})";
return $this->sendToOps($message);
}
/**
* Send PAID notification to OPS group
*/
public function sendPaid(Order $order): bool
{
if (!config('telegram.enabled')) {
return false;
}
$order->load(['therapist', 'slot']);
$message = "โ
*PAID* | #{$order->id}\n\n";
$message .= "{$order->formatted_amount} ยท {$order->pay_provider}\n";
$message .= "Master: {$order->therapist->name} ยท {$order->slot->date} {$order->slot->start_time}\n\n";
$message .= "[Open in Admin]({$this->adminOrderUrl($order)})";
return $this->sendToOps($message);
}
/**
* Send READY notification to Therapists group
* Only sent when: CONFIRMED + PAID + RESERVED
*/
public function sendReady(Order $order): bool
{
if (!config('telegram.enabled')) {
return false;
}
// Verify conditions
if (!$this->canSendReady($order)) {
Log::warning("Attempted to send READY for order #{$order->id} but conditions not met");
return false;
}
$order->load([
'client', 'therapist', 'slot', 'massageType', 'oilType', 'confirmation'
]);
$conf = $order->confirmation;
$oilShort = $order->oilType ? " ({$order->oilType->name})" : '';
$message = "โ
*READY* | #{$order->id}\n\n";
// Basic info
$message .= "๐จโโ๏ธ *Master:* {$order->therapist->name}\n";
$message .= "๐ *Time:* {$order->slot->date} {$order->slot->start_time} (60 min)\n";
$message .= "๐ *Massage:* {$order->massageType->name}{$oilShort}\n\n";
// Client info
$message .= "๐ฑ *Client*\n";
$message .= "Phone: `{$order->client->phone}`\n";
$message .= "Name: {$order->client->name ?: 'โ'}\n";
$message .= "On-site phone: {$conf->onsite_phone ?: 'โ'}\n\n";
// Address
$message .= "๐ *Address*\n";
$message .= "{$conf->address}\n";
$elevatorText = $conf->elevator ? 'Yes' : 'No';
$message .= "Entrance: {$conf->entrance ?: 'โ'} ยท Floor: {$conf->floor ?: 'โ'} ยท Elevator: {$elevatorText}\n";
$message .= "Parking: {$conf->parking ?: 'โ'}\n";
$message .= "Landmark: {$conf->landmark ?: 'โ'}\n\n";
// Notes
$message .= "๐ *Notes / Restrictions*\n";
$message .= "Constraints: {$conf->constraints ?: 'โ'}\n";
$message .= "Note to therapist: {$conf->note_to_therapist ?: 'โ'}\n";
$spaceText = $conf->space_ok ? 'Yes' : 'No';
$petsText = $conf->pets ? 'Yes' : 'No';
$message .= "Space 2ร2: {$spaceText} ยท Pets: {$petsText}\n\n";
// Payment
$message .= "๐ณ *Payment*\n";
$message .= "โ
PAID ยท {$order->formatted_amount} ยท {$order->pay_provider}\n\n";
// Links
$message .= "๐ *Links:*\n";
$message .= "[My Day]({$this->masterDayUrl($order)})\n";
$message .= "[Order Details]({$this->publicOrderUrl($order)})";
return $this->sendToTherapists($message);
}
/**
* Check if READY notification can be sent
*/
private function canSendReady(Order $order): bool
{
// Must be RESERVED status
if ($order->status !== Order::STATUS_RESERVED) {
return false;
}
// Must be PAID
if ($order->payment_status !== Order::PAY_PAID) {
return false;
}
// Must have confirmation with CONFIRMED outcome
if (!$order->confirmation || $order->confirmation->call_outcome !== 'CONFIRMED') {
return false;
}
// Must have address filled
if (empty($order->confirmation->address)) {
return false;
}
// Must not be already sent
if ($order->ready_sent_at) {
return false;
}
return true;
}
/**
* Send work order text directly to therapist
*/
public function sendToTherapist(Therapist $therapist, string $text): bool
{
if (!$therapist->telegram_chat_id) {
Log::warning("Therapist #{$therapist->id} has no telegram_chat_id");
return false;
}
return $this->sendMessage(
config('telegram.therapist.token'),
$therapist->telegram_chat_id,
$text
);
}
/**
* Resend READY notification (manual trigger from admin)
*/
public function resendReady(Order $order): bool
{
// Temporarily clear ready_sent_at to allow resend
$originalSentAt = $order->ready_sent_at;
// Force refresh conditions check with original data
if ($order->status !== Order::STATUS_RESERVED ||
$order->payment_status !== Order::PAY_PAID) {
return false;
}
// Temporarily allow
$order->ready_sent_at = null;
$result = $this->sendReady($order);
// Restore if failed
if (!$result) {
$order->ready_sent_at = $originalSentAt;
}
return $result;
}
/**
* Send message to OPS group
*/
private function sendToOps(string $message): bool
{
return $this->sendMessage(
config('telegram.ops.token'),
config('telegram.ops.chat_id'),
$message
);
}
/**
* Send message to Therapists group
*/
private function sendToTherapists(string $message): bool
{
return $this->sendMessage(
config('telegram.therapist.token'),
config('telegram.therapist.chat_id'),
$message
);
}
/**
* Send message via Telegram API
*/
private function sendMessage(string $token, string $chatId, string $text): bool
{
try {
$response = Http::timeout(10)
->post(config('telegram.api_url') . $token . '/sendMessage', [
'chat_id' => $chatId,
'text' => $text,
'parse_mode' => 'Markdown',
'disable_web_page_preview' => true,
]);
if (!$response->successful()) {
Log::error('Telegram API error', [
'status' => $response->status(),
'body' => $response->body(),
]);
return false;
}
return true;
} catch (\Exception $e) {
Log::error('Telegram send failed', [
'error' => $e->getMessage(),
]);
return false;
}
}
/**
* Generate admin order URL
*/
private function adminOrderUrl(Order $order): string
{
return config('app.url') . "/admin/orders/{$order->id}";
}
/**
* Generate master day view URL
*/
private function masterDayUrl(Order $order): string
{
$date = $order->slot->date;
return config('app.url') . "/m/{$order->therapist->public_token}/day/{$date}";
}
/**
* Generate public order URL
*/
private function publicOrderUrl(Order $order): string
{
return config('app.url') . "/o/{$order->public_token}";
}
}
๐ *NEW* | #123
Traditional Thai ยท 60 min ยท 500 000 UZS
Master: Anvar
Time: 2024-01-15 14:00
Phone: `+998901234567`
[Open in Admin](https://sabai.uz/admin/orders/123)
โ
*PAID* | #123
500 000 UZS ยท payme
Master: Anvar ยท 2024-01-15 14:00
[Open in Admin](https://sabai.uz/admin/orders/123)
โ
*READY* | #123
๐จโโ๏ธ *Master:* Anvar
๐ *Time:* 2024-01-15 14:00 (60 min)
๐ *Massage:* Relax Oil Massage (Lavender oil)
๐ฑ *Client*
Phone: `+998901234567`
Name: Sardor
On-site phone: +998909876543
๐ *Address*
Mirzo Ulugbek, Buyuk Ipak Yoli 123
Entrance: 2 ยท Floor: 5 ยท Elevator: Yes
Parking: Near building entrance
Landmark: Opposite to Makro
๐ *Notes / Restrictions*
Constraints: Lower back pain
Note to therapist: Extra focus on shoulders
Space 2ร2: Yes ยท Pets: No
๐ณ *Payment*
โ
PAID ยท 500 000 UZS ยท payme
๐ *Links:*
[My Day](https://sabai.uz/m/abc123token/day/2024-01-15)
[Order Details](https://sabai.uz/o/xyz789token)
// app/Services/OrderService.php
public function create(array $data): Order
{
return DB::transaction(function () use ($data) {
// ... create order logic ...
// Send NEW notification
app(TelegramService::class)->sendNew($order);
return $order;
});
}
public function confirmPayment(Order $order, array $webhookData): void
{
DB::transaction(function () use ($order, $webhookData) {
// ... update payment status ...
// Send PAID notification
app(TelegramService::class)->sendPaid($order);
// Auto-reserve and send READY if applicable
if ($this->shouldAutoReserve($order)) {
$this->confirmBooking($order);
}
});
}
public function confirmBooking(Order $order): void
{
DB::transaction(function () use ($order) {
// ... reserve slot ...
// Try to send READY notification
$telegram = app(TelegramService::class);
if ($telegram->sendReady($order)) {
$order->update(['ready_sent_at' => now()]);
$order->logEvent('ready_sent');
}
});
}
// app/Http/Controllers/Admin/OrderController.php
public function resendReady(Order $order)
{
$this->authorize('send work orders');
$result = app(TelegramService::class)->resendReady($order);
if ($result) {
$order->update(['ready_sent_at' => now()]);
return back()->with('success', 'READY notification resent');
}
return back()->with('error', 'Failed to resend notification');
}
Route::post('/admin/orders/{order}/resend-ready', [OrderController::class, 'resendReady'])
->name('admin.orders.resend-ready');
<template>
<div class="telegram-section" v-if="order.status === 'RESERVED'">
<h4>Telegram Notifications</h4>
<div v-if="order.ready_sent_at" class="status-info">
<span class="badge success">READY sent</span>
<span class="timestamp">{{ order.ready_sent_at }}</span>
</div>
<button
@click="resendReady"
:disabled="loading"
class="btn-secondary"
>
{{ loading ? 'Sending...' : 'Resend to Therapists' }}
</button>
</div>
</template>
<script setup>
import { ref } from 'vue'
import { router } from '@inertiajs/vue3'
const props = defineProps({ order: Object })
const loading = ref(false)
const resendReady = () => {
loading.value = true
router.post(route('admin.orders.resend-ready', props.order.id), {}, {
onFinish: () => loading.value = false,
})
}
</script>
// TelegramService.php
private function sendMessage(string $token, string $chatId, string $text): bool
{
try {
$response = Http::timeout(10)
->retry(3, 100)
->post(config('telegram.api_url') . $token . '/sendMessage', [
'chat_id' => $chatId,
'text' => $text,
'parse_mode' => 'Markdown',
]);
if (!$response->successful()) {
Log::error('Telegram API error', [
'status' => $response->status(),
'body' => $response->body(),
'chat_id' => $chatId,
]);
return false;
}
return true;
} catch (\Illuminate\Http\Client\ConnectionException $e) {
Log::warning('Telegram connection timeout', ['error' => $e->getMessage()]);
return false;
} catch (\Exception $e) {
Log::error('Telegram unexpected error', ['error' => $e->getMessage()]);
return false;
}
}
Important: Telegram errors should NOT break business operations. Always catch exceptions and log them, but allow the main flow to continue.
// tests/Feature/TelegramNotificationTest.php
class TelegramNotificationTest extends TestCase
{
use RefreshDatabase;
public function test_new_notification_sent_on_order_creation()
{
Http::fake(['*telegram*' => Http::response(['ok' => true])]);
// Create order
$response = $this->postJson('/api/booking/orders', [
'slot_id' => Slot::factory()->free()->create()->id,
'massage_type_id' => MassageType::factory()->create()->id,
'phone' => '+998901234567',
'privacy_consent' => true,
]);
$response->assertStatus(201);
Http::assertSent(function ($request) {
return str_contains($request->url(), 'sendMessage') &&
str_contains($request['text'], 'NEW');
});
}
public function test_ready_sent_only_when_conditions_met()
{
Http::fake(['*telegram*' => Http::response(['ok' => true])]);
$order = Order::factory()->create([
'status' => Order::STATUS_RESERVED,
'payment_status' => Order::PAY_PAID,
]);
OrderConfirmation::factory()->create([
'order_id' => $order->id,
'call_outcome' => 'CONFIRMED',
'address' => 'Test Address',
]);
$telegram = app(TelegramService::class);
$result = $telegram->sendReady($order);
$this->assertTrue($result);
Http::assertSent(function ($request) {
return str_contains($request['text'], 'READY');
});
}
public function test_ready_not_sent_if_not_paid()
{
Http::fake();
$order = Order::factory()->create([
'status' => Order::STATUS_RESERVED,
'payment_status' => Order::PAY_INVOICED, // Not paid!
]);
$telegram = app(TelegramService::class);
$result = $telegram->sendReady($order);
$this->assertFalse($result);
Http::assertNothingSent();
}
}