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); +});