diff --git a/core/app/Channels/ChannelDriver.php b/core/app/Channels/ChannelDriver.php new file mode 100644 index 0000000..6403c93 --- /dev/null +++ b/core/app/Channels/ChannelDriver.php @@ -0,0 +1,20 @@ + $this->container->make(WebChannel::class), + ChannelType::Webhook => $this->container->make(WebhookChannel::class), + ChannelType::Telegram => $this->container->make(TelegramChannel::class), + }; + } +} diff --git a/core/app/Channels/DeliveryOutcome.php b/core/app/Channels/DeliveryOutcome.php new file mode 100644 index 0000000..9074141 --- /dev/null +++ b/core/app/Channels/DeliveryOutcome.php @@ -0,0 +1,23 @@ + служебное канала, ляжет в deliveries.meta */ + public readonly array $meta, + ) {} + + public static function ok(array $meta = []): self + { + return new self(true, null, $meta); + } + + public static function failed(string $error): self + { + return new self(false, $error, []); + } +} diff --git a/core/app/Channels/TelegramChannel.php b/core/app/Channels/TelegramChannel.php new file mode 100644 index 0000000..d141929 --- /dev/null +++ b/core/app/Channels/TelegramChannel.php @@ -0,0 +1,100 @@ +seenKey($config['chat_id'])) ? Presence::Present : Presence::Unknown; + } + + public function deliver(Event $event, User $user, array $config, Delivery $delivery): DeliveryOutcome + { + $text = $event->payload['title']; + if (! empty($event->payload['subtitle'])) { + $text .= "\n".$event->payload['subtitle']; + } + + try { + $response = $this->call('sendMessage', [ + 'chat_id' => $config['chat_id'], + 'text' => $text, + 'reply_markup' => ['inline_keyboard' => [[ + ['text' => 'Помню', 'callback_data' => 'ack:'.$delivery->action_token], + ['text' => $event->payload['done_label'] ?? 'Сделано', 'callback_data' => 'done:'.$delivery->action_token], + ]]], + ]); + } catch (Throwable $e) { + return DeliveryOutcome::failed($e->getMessage()); + } + + if (! $response->successful() || $response->json('ok') !== true) { + return DeliveryOutcome::failed('Telegram: '.($response->json('description') ?? "HTTP {$response->status()}")); + } + + return DeliveryOutcome::ok(['message_id' => $response->json('result.message_id')]); + } + + public function onDone(Event $event, Delivery $delivery): void + { + $messageId = $delivery->meta['message_id'] ?? null; + $chatId = $delivery->channel?->config['chat_id'] ?? null; + if ($messageId === null || $chatId === null) { + return; + } + try { + $this->call('editMessageReplyMarkup', ['chat_id' => $chatId, 'message_id' => $messageId, 'reply_markup' => ['inline_keyboard' => []]]); + } catch (Throwable) { + // best effort + } + } + + public function touch(int|string $chatId): void + { + Cache::put($this->seenKey($chatId), true, $this->seenTtl); + } + + public function sendText(int|string $chatId, string $text): void + { + try { + $this->call('sendMessage', ['chat_id' => $chatId, 'text' => $text]); + } catch (Throwable) { + // best effort + } + } + + public function answerCallback(string $callbackId, string $text): void + { + try { + $this->call('answerCallbackQuery', ['callback_query_id' => $callbackId, 'text' => $text]); + } catch (Throwable) { + // best effort + } + } + + private function call(string $method, array $params): Response + { + return Http::timeout(5)->asJson()->post("https://api.telegram.org/bot{$this->botToken}/{$method}", $params); + } + + private function seenKey(int|string $chatId): string + { + return "tg:seen:{$chatId}"; + } +} diff --git a/core/app/Channels/WebChannel.php b/core/app/Channels/WebChannel.php new file mode 100644 index 0000000..275d02f --- /dev/null +++ b/core/app/Channels/WebChannel.php @@ -0,0 +1,25 @@ +id}") ? Presence::Present : Presence::Absent; + } + + public function deliver(Event $event, User $user, array $config, Delivery $delivery): DeliveryOutcome + { + return DeliveryOutcome::ok(); + } + + public function onDone(Event $event, Delivery $delivery): void {} +} diff --git a/core/app/Channels/WebhookChannel.php b/core/app/Channels/WebhookChannel.php new file mode 100644 index 0000000..94ccf6e --- /dev/null +++ b/core/app/Channels/WebhookChannel.php @@ -0,0 +1,70 @@ +presenceTimeout)->acceptJson()->get($config['presence_url']); + if (! $response->successful()) { + return Presence::Unknown; + } + + return $response->json('present') === true ? Presence::Present : Presence::Absent; + } catch (Throwable) { + return Presence::Unknown; + } + } + + public function deliver(Event $event, User $user, array $config, Delivery $delivery): DeliveryOutcome + { + try { + $response = Http::timeout(5)->asJson()->post($config['deliver_url'], self::payload($event, $delivery)); + if ($response->successful()) { + return DeliveryOutcome::ok(); + } + + return DeliveryOutcome::failed("HTTP {$response->status()}: ".mb_substr($response->body(), 0, 200)); + } catch (Throwable $e) { + return DeliveryOutcome::failed($e->getMessage()); + } + } + + public function onDone(Event $event, Delivery $delivery): void {} + + /** @return array */ + public static function payload(Event $event, Delivery $delivery): array + { + $presented = Presenter::present($event); + + return [ + 'event' => [ + 'id' => $event->id, + 'state' => $presented['state'], + 'due_at' => $presented['due_at'], + 'source' => $event->source->name, + 'title' => $event->payload['title'], + 'subtitle' => $event->payload['subtitle'] ?? null, + 'deep_link' => $event->payload['deep_link'] ?? null, + 'done_label' => $event->payload['done_label'] ?? 'Сделано', + ], + 'actions' => [ + 'ack' => route('action.ack', $delivery->action_token), + 'done' => route('action.done', $delivery->action_token), + ], + ]; + } +} diff --git a/core/app/Delivery/EventActions.php b/core/app/Delivery/EventActions.php index 167ae33..f770e9a 100644 --- a/core/app/Delivery/EventActions.php +++ b/core/app/Delivery/EventActions.php @@ -2,6 +2,7 @@ namespace App\Delivery; +use App\Channels\ChannelRegistry; use App\Enums\EventState; use App\Models\Event; use App\Scheduling\DueResolver; @@ -12,6 +13,7 @@ final class EventActions public function __construct( private readonly DueResolver $due, private readonly QuietRule $quiet, + private readonly ChannelRegistry $channels, ) {} /** «Помню»: молчать до следующей контрольной точки текущего состояния. */ @@ -35,5 +37,9 @@ final class EventActions $event->state = EventState::Done; $event->done_at = now(); $event->save(); + + foreach ($event->deliveries()->with('channel')->get() as $delivery) { + $this->channels->for($delivery->channel->type)->onDone($event, $delivery); + } } } diff --git a/core/app/Providers/AppServiceProvider.php b/core/app/Providers/AppServiceProvider.php index 878c9b6..ac4a4ad 100644 --- a/core/app/Providers/AppServiceProvider.php +++ b/core/app/Providers/AppServiceProvider.php @@ -16,6 +16,12 @@ class AppServiceProvider extends ServiceProvider config('hado.fallback_hour_minutes'), config('hado.fallback_hours'), )); + + $this->app->singleton(\App\Channels\WebhookChannel::class, fn () => new \App\Channels\WebhookChannel(config('hado.presence_timeout'))); + $this->app->singleton(\App\Channels\TelegramChannel::class, fn () => new \App\Channels\TelegramChannel( + config('hado.telegram.token'), + config('hado.telegram.seen_ttl'), + )); } /** diff --git a/core/tests/Unit/Channels/TelegramChannelTest.php b/core/tests/Unit/Channels/TelegramChannelTest.php new file mode 100644 index 0000000..965a8f3 --- /dev/null +++ b/core/tests/Unit/Channels/TelegramChannelTest.php @@ -0,0 +1,91 @@ + DueMode::Local, 'due_date' => '2026-08-14', 'due_time' => '24:00:00', 'fire_on' => '2026-08-14', + 'after_due' => AfterDue::Expire, 'state' => EventState::Today, + 'payload' => ['title' => 'Сегодня ДР — Вася', 'subtitle' => 'исполняется 34', 'done_label' => 'Поздравил'], + ]); + $event->id = 17; + $event->setRelation('source', new Source(['name' => 'docs'])); + + return $event; + } + + public function test_presence_is_unknown_unless_recently_seen(): void + { + $user = new User(['login' => 'n', 'tz' => 'UTC']); + $this->assertSame(Presence::Unknown, $this->driver()->presence($user, ['chat_id' => 555])); + + $this->driver()->touch(555); + $this->assertSame(Presence::Present, $this->driver()->presence($user, ['chat_id' => 555])); + } + + public function test_deliver_sends_message_with_two_buttons_and_stores_message_id(): void + { + Http::fake(['api.telegram.org/*' => Http::response(['ok' => true, 'result' => ['message_id' => 9001]])]); + + $outcome = $this->driver()->deliver($this->event(), new User(['login' => 'n', 'tz' => 'UTC']), ['chat_id' => 555], new Delivery(['action_token' => 'tok'])); + + $this->assertTrue($outcome->ok); + $this->assertSame(['message_id' => 9001], $outcome->meta); + Http::assertSent(function (Request $r) { + $kb = $r['reply_markup']['inline_keyboard'][0]; + + return str_ends_with($r->url(), '/bottest-token/sendMessage') + && $r['chat_id'] === 555 + && str_contains($r['text'], 'Сегодня ДР — Вася') + && str_contains($r['text'], 'исполняется 34') + && $kb[0] === ['text' => 'Помню', 'callback_data' => 'ack:tok'] + && $kb[1] === ['text' => 'Поздравил', 'callback_data' => 'done:tok']; + }); + } + + public function test_deliver_fails_on_api_error(): void + { + Http::fake(['api.telegram.org/*' => Http::response(['ok' => false, 'description' => 'chat not found'], 400)]); + + $outcome = $this->driver()->deliver($this->event(), new User(['login' => 'n', 'tz' => 'UTC']), ['chat_id' => 555], new Delivery(['action_token' => 'tok'])); + + $this->assertFalse($outcome->ok); + $this->assertStringContainsString('chat not found', $outcome->error); + } + + public function test_on_done_clears_buttons_best_effort(): void + { + Http::fake(['api.telegram.org/*' => Http::response(['ok' => true])]); + $delivery = new Delivery(['action_token' => 'tok', 'meta' => ['message_id' => 9001]]); + $delivery->setRelation('channel', new \App\Models\Channel(['config' => ['chat_id' => 555]])); + + $this->driver()->onDone($this->event(), $delivery); + + Http::assertSent(fn (Request $r) => str_ends_with($r->url(), '/editMessageReplyMarkup') && $r['message_id'] === 9001 && $r['chat_id'] === 555); + + Http::fake(fn () => throw new \RuntimeException('down')); + $this->driver()->onDone($this->event(), $delivery); // не бросает + $this->addToAssertionCount(1); + } +} diff --git a/core/tests/Unit/Channels/WebChannelTest.php b/core/tests/Unit/Channels/WebChannelTest.php new file mode 100644 index 0000000..c08bb76 --- /dev/null +++ b/core/tests/Unit/Channels/WebChannelTest.php @@ -0,0 +1,23 @@ + 'n', 'tz' => 'UTC']); + $user->id = 7; + $driver = new WebChannel; + + $this->assertSame(Presence::Absent, $driver->presence($user, [])); + Cache::put('heartbeat:7', true, 90); + $this->assertSame(Presence::Present, $driver->presence($user, [])); + } +} diff --git a/core/tests/Unit/Channels/WebhookChannelTest.php b/core/tests/Unit/Channels/WebhookChannelTest.php new file mode 100644 index 0000000..c79ce08 --- /dev/null +++ b/core/tests/Unit/Channels/WebhookChannelTest.php @@ -0,0 +1,103 @@ + 'https://ha.test/api/webhook/hado', 'presence_url' => 'https://ha.test/api/hado/presence']; + + 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 + { + return new WebhookChannel(2); + } + + public function test_presence_from_json(): void + { + Http::fake([ + 'ha.test/api/hado/presence' => Http::sequence() + ->push(['present' => true]) + ->push(['present' => false]) + ->push('oops', 500), + ]); + $user = new User(['login' => 'n', 'tz' => 'UTC']); + + $this->assertSame(Presence::Present, $this->driver()->presence($user, $this->config)); + $this->assertSame(Presence::Absent, $this->driver()->presence($user, $this->config)); + $this->assertSame(Presence::Unknown, $this->driver()->presence($user, $this->config)); + } + + public function test_presence_unknown_on_timeout(): void + { + Http::fake(fn () => throw new ConnectionException('timeout')); + $this->assertSame(Presence::Unknown, $this->driver()->presence(new User(['login' => 'n', 'tz' => 'UTC']), $this->config)); + } + + public function test_deliver_posts_event_and_action_urls(): void + { + Http::fake(['ha.test/api/webhook/hado' => Http::response('', 200)]); + + $event = new Event([ + 'due_mode' => DueMode::Local, 'due_date' => '2026-08-14', 'due_time' => '24:00:00', 'fire_on' => '2026-08-14', + 'after_due' => AfterDue::Expire, 'state' => EventState::Today, + 'payload' => ['title' => 'Сегодня ДР — Вася', 'subtitle' => 'исполняется 34', 'deep_link' => '/people/42', 'done_label' => 'Поздравил'], + ]); + $event->id = 17; + $event->setRelation('source', new Source(['name' => 'docs'])); + $delivery = new Delivery(['action_token' => 'tok123']); + + $outcome = $this->driver()->deliver($event, new User(['login' => 'n', 'tz' => 'UTC']), $this->config, $delivery); + + $this->assertTrue($outcome->ok); + Http::assertSent(function (Request $r) { + return $r->url() === 'https://ha.test/api/webhook/hado' + && $r['event']['id'] === 17 + && $r['event']['title'] === 'Сегодня ДР — Вася' + && $r['event']['done_label'] === 'Поздравил' + && $r['event']['source'] === 'docs' + && $r['event']['due_at'] === '2026-08-14' + && str_ends_with($r['actions']['ack'], '/a/tok123/ack') + && str_ends_with($r['actions']['done'], '/a/tok123/done'); + }); + } + + public function test_deliver_reports_failure(): void + { + Http::fake(['ha.test/api/webhook/hado' => Http::response('nope', 503)]); + $event = new Event(['due_mode' => DueMode::Local, 'due_date' => '2026-08-14', 'due_time' => '24:00:00', 'fire_on' => '2026-08-14', 'after_due' => AfterDue::Expire, 'state' => EventState::Today, 'payload' => ['title' => 't']]); + $event->setRelation('source', new Source(['name' => 'docs'])); + + $outcome = $this->driver()->deliver($event, new User(['login' => 'n', 'tz' => 'UTC']), $this->config, new Delivery(['action_token' => 'x'])); + + $this->assertFalse($outcome->ok); + $this->assertStringContainsString('503', $outcome->error); + } +}