From 90d17ec3785563eeadad3181e8e3de8e4bde9226 Mon Sep 17 00:00:00 2001 From: "nikita.hohlov" Date: Fri, 4 Sep 2026 07:52:12 -0300 Subject: [PATCH] feat: action links, telegram webhook and channels API Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_01BCrwHHnGCB5XH968Nxokqw --- .../app/Http/Controllers/ActionController.php | 39 ++++++++ .../Controllers/Me/ChannelsController.php | 83 ++++++++++++++++ .../Controllers/TelegramWebhookController.php | 87 ++++++++++++++++ core/routes/web.php | 13 +++ core/tests/Feature/ActionLinksTest.php | 66 +++++++++++++ core/tests/Feature/ChannelsApiTest.php | 63 ++++++++++++ core/tests/Feature/TelegramWebhookTest.php | 99 +++++++++++++++++++ .../Unit/Channels/WebhookChannelTest.php | 10 -- 8 files changed, 450 insertions(+), 10 deletions(-) create mode 100644 core/app/Http/Controllers/ActionController.php create mode 100644 core/app/Http/Controllers/Me/ChannelsController.php create mode 100644 core/app/Http/Controllers/TelegramWebhookController.php create mode 100644 core/tests/Feature/ActionLinksTest.php create mode 100644 core/tests/Feature/ChannelsApiTest.php create mode 100644 core/tests/Feature/TelegramWebhookTest.php diff --git a/core/app/Http/Controllers/ActionController.php b/core/app/Http/Controllers/ActionController.php new file mode 100644 index 0000000..5576093 --- /dev/null +++ b/core/app/Http/Controllers/ActionController.php @@ -0,0 +1,39 @@ +eventFor($token); + $this->actions->ack($event); + + return response()->json(['state' => $event->fresh()->state->value]); + } + + public function done(string $token): JsonResponse + { + $event = $this->eventFor($token); + $this->actions->done($event); + + return response()->json(['state' => $event->fresh()->state->value]); + } + + private function eventFor(string $token): Event + { + $delivery = Delivery::where('action_token', $token)->with('event.user')->first(); + abort_if($delivery === null, 404, 'Неизвестный токен'); + abort_if($delivery->event->isTerminal(), 410, 'Событие уже закрыто'); + + return $delivery->event; + } +} diff --git a/core/app/Http/Controllers/Me/ChannelsController.php b/core/app/Http/Controllers/Me/ChannelsController.php new file mode 100644 index 0000000..5ee0d6f --- /dev/null +++ b/core/app/Http/Controllers/Me/ChannelsController.php @@ -0,0 +1,83 @@ +attributes->get('user'); + + return response()->json(['channels' => $user->channels()->orderBy('id')->get()->map(self::present(...))->values()]); + } + + public function store(Request $request): JsonResponse + { + /** @var User $user */ + $user = $request->attributes->get('user'); + + $data = $request->validate([ + 'type' => ['required', 'in:webhook'], + 'config' => ['required', 'array'], + 'config.deliver_url' => ['required', 'url'], + 'config.presence_url' => ['required', 'url'], + ], ['type.in' => 'Через API добавляется только webhook; Telegram подключается через /start.']); + + $channel = $user->channels()->create([ + 'type' => ChannelType::Webhook, + 'config' => ['deliver_url' => $data['config']['deliver_url'], 'presence_url' => $data['config']['presence_url']], + 'enabled' => true, + ]); + + return response()->json(self::present($channel), 201); + } + + public function destroy(Request $request, int $channel): Response + { + /** @var User $user */ + $user = $request->attributes->get('user'); + $model = $user->channels()->whereKey($channel)->firstOrFail(); + if ($model->type === ChannelType::Web) { + throw ValidationException::withMessages(['channel' => 'Веб-канал удалить нельзя']); + } + $model->delete(); + + return response()->noContent(); + } + + public function telegramLink(Request $request): JsonResponse + { + /** @var User $user */ + $user = $request->attributes->get('user'); + $code = Str::upper(Str::random(6)); + Cache::put("tg:link:{$code}", $user->id, 900); + + return response()->json([ + 'code' => $code, + 'bot_url' => 'https://t.me/'.config('hado.telegram.username').'?start='.$code, + ]); + } + + /** @return array */ + public static function present(Channel $channel): array + { + return [ + 'id' => $channel->id, + 'type' => $channel->type->value, + 'config' => $channel->config, + 'enabled' => $channel->enabled, + ]; + } +} diff --git a/core/app/Http/Controllers/TelegramWebhookController.php b/core/app/Http/Controllers/TelegramWebhookController.php new file mode 100644 index 0000000..e4a176e --- /dev/null +++ b/core/app/Http/Controllers/TelegramWebhookController.php @@ -0,0 +1,87 @@ +header('X-Telegram-Bot-Api-Secret-Token', ''); + abort_unless($expected !== '' && hash_equals($expected, $given), 403); + + if (is_array($message = $request->input('message'))) { + $this->message($message); + } + if (is_array($callback = $request->input('callback_query'))) { + $this->callback($callback); + } + + return response()->noContent(); + } + + private function message(array $message): void + { + $chatId = $message['chat']['id'] ?? null; + if ($chatId === null) { + return; + } + $this->telegram->touch($chatId); + + if (! preg_match('~^/start\s+(\S+)~', (string) ($message['text'] ?? ''), $m)) { + return; + } + + $userId = Cache::pull('tg:link:'.$m[1]); + if ($userId === null) { + $this->telegram->sendText($chatId, 'Код не найден или устарел. Сгенерируй новый в инбоксе Hado.'); + + return; + } + + Channel::updateOrCreate( + ['user_id' => $userId, 'type' => ChannelType::Telegram->value], + ['config' => ['chat_id' => $chatId], 'enabled' => true], + ); + $this->telegram->sendText($chatId, 'Подключено'); + } + + private function callback(array $callback): void + { + $chatId = $callback['message']['chat']['id'] ?? null; + if ($chatId !== null) { + $this->telegram->touch($chatId); + } + + [$action, $token] = array_pad(explode(':', (string) ($callback['data'] ?? ''), 2), 2, ''); + $delivery = Delivery::where('action_token', $token)->with('event.user')->first(); + + if ($delivery === null || $delivery->event->isTerminal() || ! in_array($action, ['ack', 'done'], true)) { + $this->telegram->answerCallback((string) $callback['id'], 'Уже неактуально'); + + return; + } + + if ($action === 'done') { + $this->actions->done($delivery->event); + $this->telegram->answerCallback((string) $callback['id'], 'Закрыто'); + } else { + $this->actions->ack($delivery->event); + $this->telegram->answerCallback((string) $callback['id'], 'Ок, напомню позже'); + } + } +} diff --git a/core/routes/web.php b/core/routes/web.php index 1818baf..883c8f6 100644 --- a/core/routes/web.php +++ b/core/routes/web.php @@ -1,9 +1,18 @@ name('action.ack'); +Route::post('/a/{token}/done', [ActionController::class, 'done'])->name('action.done'); +Route::post('/hooks/telegram', TelegramWebhookController::class); + +// Под SSO хаба Route::middleware('remote.user')->group(function () { Route::get('/me', [ProfileController::class, 'show']); Route::patch('/me', [ProfileController::class, 'update']); @@ -11,4 +20,8 @@ Route::middleware('remote.user')->group(function () { Route::get('/me/events', [MeEvents::class, 'index']); Route::post('/me/events/{event}/ack', [MeEvents::class, 'ack']); Route::post('/me/events/{event}/done', [MeEvents::class, 'done']); + Route::get('/me/channels', [ChannelsController::class, 'index']); + Route::post('/me/channels', [ChannelsController::class, 'store']); + Route::delete('/me/channels/{channel}', [ChannelsController::class, 'destroy']); + Route::post('/me/channels/telegram/link', [ChannelsController::class, 'telegramLink']); }); diff --git a/core/tests/Feature/ActionLinksTest.php b/core/tests/Feature/ActionLinksTest.php new file mode 100644 index 0000000..8f7ffd4 --- /dev/null +++ b/core/tests/Feature/ActionLinksTest.php @@ -0,0 +1,66 @@ + 'docs', 'token_hash' => hash('sha256', 'x')]); + $user = User::create(['login' => 'n', 'tz' => 'UTC', 'quiet_start' => '22:00:00', 'quiet_end' => '09:00:00']); + $channel = Channel::create(['user_id' => $user->id, 'type' => ChannelType::Webhook, 'config' => ['deliver_url' => 'u', 'presence_url' => 'p'], 'enabled' => true]); + $this->event = Event::create([ + 'source_id' => $source->id, 'user_id' => $user->id, 'source_ref' => 'r', 'topic' => '', + 'due_mode' => DueMode::Local, 'due_date' => '2026-09-30', 'due_time' => '24:00:00', 'fire_on' => '2026-09-30', + 'after_due' => AfterDue::Keep, 'payload' => ['title' => 'T'], 'state' => EventState::Today, + 'quiet_until' => CarbonImmutable::parse('2000-01-01Z'), + ]); + Delivery::create(['event_id' => $this->event->id, 'channel_id' => $channel->id, 'window_start' => now(), 'action_token' => 'tok', 'result' => DeliveryResult::Ok]); + } + + protected function tearDown(): void + { + CarbonImmutable::setTestNow(); + parent::tearDown(); + } + + public function test_ack_by_token(): void + { + $this->postJson('/a/tok/ack')->assertOk()->assertJsonPath('state', 'today'); + $this->assertSame('2026-09-30T13:00:00+00:00', $this->event->fresh()->quiet_until->toIso8601String()); + } + + public function test_done_by_token_then_gone(): void + { + $this->postJson('/a/tok/done')->assertOk()->assertJsonPath('state', 'done'); + $this->assertSame(EventState::Done, $this->event->fresh()->state); + + $this->postJson('/a/tok/done')->assertStatus(410); + $this->postJson('/a/tok/ack')->assertStatus(410); + } + + public function test_unknown_token(): void + { + $this->postJson('/a/nope/done')->assertNotFound(); + } +} diff --git a/core/tests/Feature/ChannelsApiTest.php b/core/tests/Feature/ChannelsApiTest.php new file mode 100644 index 0000000..233abc4 --- /dev/null +++ b/core/tests/Feature/ChannelsApiTest.php @@ -0,0 +1,63 @@ + 'nikita']; + + public function test_lists_channels_including_auto_web(): void + { + $this->getJson('/me/channels', $this->as)->assertOk() + ->assertJsonCount(1, 'channels') + ->assertJsonPath('channels.0.type', 'web'); + } + + public function test_adds_and_deletes_webhook_channel(): void + { + $r = $this->postJson('/me/channels', ['type' => 'webhook', 'config' => ['deliver_url' => 'https://ha.test/api/webhook/hado', 'presence_url' => 'https://ha.test/api/presence']], $this->as) + ->assertCreated()->assertJsonPath('type', 'webhook'); + + $this->deleteJson('/me/channels/'.$r->json('id'), [], $this->as)->assertNoContent(); + $this->assertSame(1, Channel::count()); + } + + public function test_validates_webhook_config(): void + { + $this->postJson('/me/channels', ['type' => 'webhook', 'config' => ['deliver_url' => 'not a url']], $this->as) + ->assertStatus(422)->assertJsonValidationErrors(['config.deliver_url', 'config.presence_url']); + // telegram подключается только через /start, не через API + $this->postJson('/me/channels', ['type' => 'telegram', 'config' => ['chat_id' => 1]], $this->as) + ->assertStatus(422); + } + + public function test_web_channel_cannot_be_deleted_and_foreign_channel_is_404(): void + { + $this->getJson('/me', $this->as); + $web = User::sole()->channels()->sole(); + $this->deleteJson('/me/channels/'.$web->id, [], $this->as)->assertStatus(422); + + $this->getJson('/me', ['X-Remote-User' => 'other']); + $foreign = Channel::create(['user_id' => User::where('login', 'other')->sole()->id, 'type' => ChannelType::Webhook, 'config' => ['deliver_url' => 'u', 'presence_url' => 'p'], 'enabled' => true]); + $this->deleteJson('/me/channels/'.$foreign->id, [], $this->as)->assertNotFound(); + } + + public function test_telegram_link_code(): void + { + $r = $this->postJson('/me/channels/telegram/link', [], $this->as)->assertOk(); + $code = $r->json('code'); + + $this->assertSame(6, strlen($code)); + $this->assertSame('https://t.me/hado_test_bot?start='.$code, $r->json('bot_url')); + $this->assertSame(User::sole()->id, Cache::get("tg:link:$code")); + } +} diff --git a/core/tests/Feature/TelegramWebhookTest.php b/core/tests/Feature/TelegramWebhookTest.php new file mode 100644 index 0000000..f805320 --- /dev/null +++ b/core/tests/Feature/TelegramWebhookTest.php @@ -0,0 +1,99 @@ + Http::response(['ok' => true, 'result' => []])]); + $this->user = User::create(['login' => 'n', 'tz' => 'UTC', 'quiet_start' => '22:00:00', 'quiet_end' => '09:00:00']); + } + + private function secret(): array + { + return ['X-Telegram-Bot-Api-Secret-Token' => 'test-secret']; + } + + public function test_rejects_wrong_secret(): void + { + $this->postJson('/hooks/telegram', ['message' => []])->assertStatus(403); + $this->postJson('/hooks/telegram', ['message' => []], ['X-Telegram-Bot-Api-Secret-Token' => 'wrong'])->assertStatus(403); + } + + public function test_start_with_code_links_chat(): void + { + Cache::put('tg:link:ABC123', $this->user->id, 900); + + $this->postJson('/hooks/telegram', ['message' => ['chat' => ['id' => 555], 'text' => '/start ABC123']], $this->secret())->assertNoContent(); + + $channel = $this->user->channels()->where('type', ChannelType::Telegram->value)->sole(); + $this->assertSame(555, $channel->config['chat_id']); + $this->assertFalse(Cache::has('tg:link:ABC123'), 'код одноразовый'); + $this->assertTrue(Cache::has('tg:seen:555'), 'взаимодействие = присутствие'); + Http::assertSent(fn (Request $r) => str_ends_with($r->url(), '/sendMessage') && $r['text'] === 'Подключено'); + } + + public function test_start_with_bad_code_replies_error(): void + { + $this->postJson('/hooks/telegram', ['message' => ['chat' => ['id' => 555], 'text' => '/start NOPE']], $this->secret())->assertNoContent(); + $this->assertSame(0, Channel::count()); + Http::assertSent(fn (Request $r) => str_ends_with($r->url(), '/sendMessage') && str_contains($r['text'], 'Код')); + } + + public function test_callback_ack_and_done(): void + { + CarbonImmutable::setTestNow('2026-09-30T11:17:00Z'); + $source = Source::create(['name' => 'docs', 'token_hash' => hash('sha256', 'x')]); + $channel = Channel::create(['user_id' => $this->user->id, 'type' => ChannelType::Telegram, 'config' => ['chat_id' => 555], 'enabled' => true]); + $event = Event::create([ + 'source_id' => $source->id, 'user_id' => $this->user->id, 'source_ref' => 'r', 'topic' => '', + 'due_mode' => DueMode::Local, 'due_date' => '2026-09-30', 'due_time' => '24:00:00', 'fire_on' => '2026-09-30', + 'after_due' => AfterDue::Keep, 'payload' => ['title' => 'T'], 'state' => EventState::Today, + 'quiet_until' => CarbonImmutable::parse('2000-01-01Z'), + ]); + Delivery::create(['event_id' => $event->id, 'channel_id' => $channel->id, 'window_start' => now(), 'action_token' => 'tok', 'result' => DeliveryResult::Ok, 'meta' => ['message_id' => 9001]]); + + $cb = fn (string $data) => ['callback_query' => ['id' => 'cb1', 'data' => $data, 'message' => ['chat' => ['id' => 555], 'message_id' => 9001]]]; + + $this->postJson('/hooks/telegram', $cb('ack:tok'), $this->secret())->assertNoContent(); + $this->assertSame('2026-09-30T13:00:00+00:00', $event->fresh()->quiet_until->toIso8601String()); + Http::assertSent(fn (Request $r) => str_ends_with($r->url(), '/answerCallbackQuery') && $r['callback_query_id'] === 'cb1'); + + $this->postJson('/hooks/telegram', $cb('done:tok'), $this->secret())->assertNoContent(); + $this->assertSame(EventState::Done, $event->fresh()->state); + Http::assertSent(fn (Request $r) => str_ends_with($r->url(), '/editMessageReplyMarkup') && $r['message_id'] === 9001); + + $this->postJson('/hooks/telegram', $cb('done:tok'), $this->secret())->assertNoContent(); + Http::assertSent(fn (Request $r) => str_ends_with($r->url(), '/answerCallbackQuery') && $r['text'] === 'Уже неактуально'); + + CarbonImmutable::setTestNow(); + } + + public function test_unrelated_update_is_ignored(): void + { + $this->postJson('/hooks/telegram', ['edited_message' => ['chat' => ['id' => 1]]], $this->secret())->assertNoContent(); + } +} diff --git a/core/tests/Unit/Channels/WebhookChannelTest.php b/core/tests/Unit/Channels/WebhookChannelTest.php index c79ce08..bc991e4 100644 --- a/core/tests/Unit/Channels/WebhookChannelTest.php +++ b/core/tests/Unit/Channels/WebhookChannelTest.php @@ -14,7 +14,6 @@ use App\Models\User; use Illuminate\Http\Client\ConnectionException; use Illuminate\Http\Client\Request; use Illuminate\Support\Facades\Http; -use Illuminate\Support\Facades\Route; use Tests\TestCase; class WebhookChannelTest extends TestCase @@ -24,15 +23,6 @@ class WebhookChannelTest extends TestCase protected function setUp(): void { parent::setUp(); - Route::post('/a/{token}/ack', fn () => '')->name('action.ack'); - Route::post('/a/{token}/done', fn () => '')->name('action.done'); - // Laravel only refreshes the named-route lookup table once, right after the app's own - // route files finish loading during boot. Routes added later (e.g. here in setUp) are - // appended to the collection but never indexed by name unless we refresh explicitly, - // so route('action.ack', ...) would otherwise throw RouteNotFoundException. This does - // not affect production: Task 10's real routes live in the route files that already - // get refreshed automatically. - Route::getRoutes()->refreshNameLookups(); } private function driver(): WebhookChannel