diff --git a/app/app/Http/Controllers/Api/ChatController.php b/app/app/Http/Controllers/Api/ChatController.php
index 1273416a..76998691 100644
--- a/app/app/Http/Controllers/Api/ChatController.php
+++ b/app/app/Http/Controllers/Api/ChatController.php
@@ -7,6 +7,7 @@ namespace App\Http\Controllers\Api;
use App\Http\Controllers\Controller;
use App\Jobs\Bot\ProcessChatMessageJob;
use App\Models\BotDialog;
+use App\Services\Bot\ChatRateLimiter;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Auth;
@@ -33,6 +34,13 @@ class ChatController extends Controller
$userId = Auth::id();
+ $refusal = app(ChatRateLimiter::class)
+ ->check($data['chat_id'], (string) $request->ip(), $userId !== null);
+
+ if ($refusal !== null) {
+ return response()->json(['message' => $refusal], 429);
+ }
+
$row = BotDialog::create([
'chat_id' => $data['chat_id'],
'direction' => 'in',
diff --git a/app/app/Mail/ChatBudgetAlertMail.php b/app/app/Mail/ChatBudgetAlertMail.php
new file mode 100644
index 00000000..a9b5e6fd
--- /dev/null
+++ b/app/app/Mail/ChatBudgetAlertMail.php
@@ -0,0 +1,37 @@
+ $this->used,
+ 'limit' => ChatRateLimiter::GLOBAL_PER_DAY,
+ ],
+ );
+ }
+}
diff --git a/app/app/Services/Bot/ChatRateLimiter.php b/app/app/Services/Bot/ChatRateLimiter.php
new file mode 100644
index 00000000..646773a1
--- /dev/null
+++ b/app/app/Services/Bot/ChatRateLimiter.php
@@ -0,0 +1,97 @@
+where('chat_id', $chatId)
+ ->where('direction', 'in')
+ ->count();
+ if ($asked >= self::PER_CHAT_TOTAL) {
+ return self::TOO_MANY;
+ }
+
+ if (! $this->dailyBudgetLeft()) {
+ return self::OVERLOADED;
+ }
+
+ return null;
+ }
+
+ /** Суточный потолок на весь чат. Пробит — гасим и один раз шлём владельцу тревогу. */
+ private function dailyBudgetLeft(): bool
+ {
+ $key = 'chat:day:'.now()->toDateString();
+ Cache::add($key, 0, now()->addDay());
+ $used = (int) Cache::increment($key);
+
+ if ($used <= self::GLOBAL_PER_DAY) {
+ return true;
+ }
+
+ if (Cache::add('chat:day:alert:'.now()->toDateString(), 1, now()->addDay())) {
+ $to = (string) config('services.support.email');
+ if ($to !== '') {
+ Mail::to($to)->queue(new ChatBudgetAlertMail($used));
+ }
+ }
+
+ return false;
+ }
+}
diff --git a/app/resources/views/emails/chat_budget_alert.blade.php b/app/resources/views/emails/chat_budget_alert.blade.php
new file mode 100644
index 00000000..d8b442e8
--- /dev/null
+++ b/app/resources/views/emails/chat_budget_alert.blade.php
@@ -0,0 +1,5 @@
+
Чат за сегодня выдал {{ $used }} ответов при потолке {{ $limit }}.
+Чат временно предлагает всем написать на support@liderra.ru и новых ответов не выдаёт —
+ деньги дальше не тратятся.
+Стоит посмотреть журнал разговоров: либо нас заваливает скрипт, либо резко вырос поток клиентов.
+ Если это нормальный рост — поднимите потолок в ChatRateLimiter::GLOBAL_PER_DAY.
diff --git a/app/tests/Feature/Bot/ChatEndpointTest.php b/app/tests/Feature/Bot/ChatEndpointTest.php
index 1e0733b5..08e9d330 100644
--- a/app/tests/Feature/Bot/ChatEndpointTest.php
+++ b/app/tests/Feature/Bot/ChatEndpointTest.php
@@ -75,3 +75,16 @@ it('не отдаёт чужой разговор', function () {
expect($this->getJson("/api/chat/{$mine}/messages?after=0")->json('messages'))->toBe([]);
});
+
+it('вежливо отказывает, когда клиент строчит слишком быстро', function () {
+ Queue::fake();
+ $chat = str_repeat('7', 32);
+
+ $this->postJson('/api/chat/message', ['chat_id' => $chat, 'text' => 'раз'])->assertOk();
+ $second = $this->postJson('/api/chat/message', ['chat_id' => $chat, 'text' => 'два']);
+
+ $second->assertStatus(429);
+ expect($second->json('message'))->toContain('подождите');
+ // Деньги не тратим: второй вопрос в очередь не попал.
+ Queue::assertPushed(ProcessChatMessageJob::class, 1);
+});
diff --git a/app/tests/Feature/Bot/ChatRateLimiterTest.php b/app/tests/Feature/Bot/ChatRateLimiterTest.php
new file mode 100644
index 00000000..c86d18a9
--- /dev/null
+++ b/app/tests/Feature/Bot/ChatRateLimiterTest.php
@@ -0,0 +1,82 @@
+check($chat, '1.2.3.4', false))->toBeNull();
+ expect($limiter->check($chat, '1.2.3.4', false))->toContain('подождите');
+});
+
+it('гостю даёт не больше двадцати вопросов в час', function () {
+ $limiter = app(ChatRateLimiter::class);
+ $chat = str_repeat('b', 32);
+
+ for ($i = 0; $i < ChatRateLimiter::GUEST_PER_HOUR; $i++) {
+ RateLimiter::clear('chat:pause:'.$chat);
+ expect($limiter->check($chat, '1.2.3.4', false))->toBeNull();
+ }
+ RateLimiter::clear('chat:pause:'.$chat);
+ expect($limiter->check($chat, '1.2.3.4', false))->toContain('несколько минут');
+});
+
+it('вошедшему клиенту даёт больше — шестьдесят в час', function () {
+ $limiter = app(ChatRateLimiter::class);
+ $chat = str_repeat('c', 32);
+
+ for ($i = 0; $i < ChatRateLimiter::GUEST_PER_HOUR + 1; $i++) {
+ RateLimiter::clear('chat:pause:'.$chat);
+ expect($limiter->check($chat, '1.2.3.4', true))->toBeNull();
+ }
+});
+
+it('режет по адресу в интернете, даже если открывать новые разговоры', function () {
+ $limiter = app(ChatRateLimiter::class);
+
+ for ($i = 0; $i < ChatRateLimiter::IP_PER_HOUR; $i++) {
+ // STR_PAD_LEFT — с дефолтным (правым) паддингом "1" и "10" дают ОДНУ и ту же
+ // строку ("1" + нули), разговоры схлопывались и тест не набирал нужный счётчик.
+ $limiter->check(str_pad((string) $i, 32, '0', STR_PAD_LEFT), '9.9.9.9', false);
+ }
+
+ expect($limiter->check(str_repeat('d', 32), '9.9.9.9', false))->toContain('несколько минут');
+});
+
+it('обрывает бесконечный разговор', function () {
+ $limiter = app(ChatRateLimiter::class);
+ $chat = str_repeat('e', 32);
+
+ for ($i = 0; $i < ChatRateLimiter::PER_CHAT_TOTAL; $i++) {
+ BotDialog::create(['chat_id' => $chat, 'direction' => 'in', 'message' => 'вопрос', 'created_at' => now()]);
+ }
+
+ expect($limiter->check($chat, '1.2.3.4', true))->toContain('несколько минут');
+});
+
+it('пробитый суточный потолок гасит чат и шлёт владельцу тревогу один раз', function () {
+ Mail::fake();
+ config(['services.support.email' => 'support@liderra.ru']);
+ $limiter = app(ChatRateLimiter::class);
+ Cache::put('chat:day:'.now()->toDateString(), ChatRateLimiter::GLOBAL_PER_DAY, now()->addDay());
+
+ expect($limiter->check(str_repeat('f', 32), '1.2.3.4', false))->toContain('support@liderra.ru');
+ expect($limiter->check(str_repeat('9', 32), '5.6.7.8', false))->toContain('support@liderra.ru');
+
+ Mail::assertQueued(ChatBudgetAlertMail::class, 1);
+});