feat: web, webhook and telegram channel drivers

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BCrwHHnGCB5XH968Nxokqw
This commit is contained in:
nikita.hohlov
2026-09-04 07:18:37 -03:00
parent 1b03fe0796
commit 1b0a559e3c
11 changed files with 487 additions and 0 deletions

View File

@@ -0,0 +1,20 @@
<?php
namespace App\Channels;
use App\Enums\Presence;
use App\Models\Delivery;
use App\Models\Event;
use App\Models\User;
interface ChannelDriver
{
/** Пользователь сейчас доступен через этот канал? */
public function presence(User $user, array $config): Presence;
/** Доставить напоминание. Не бросает: ошибки — в DeliveryOutcome::failed. */
public function deliver(Event $event, User $user, array $config, Delivery $delivery): DeliveryOutcome;
/** Событие закрыто: убрать кнопки и т.п. Best effort, никогда не бросает. */
public function onDone(Event $event, Delivery $delivery): void;
}

View File

@@ -0,0 +1,20 @@
<?php
namespace App\Channels;
use App\Enums\ChannelType;
use Illuminate\Contracts\Container\Container;
final class ChannelRegistry
{
public function __construct(private readonly Container $container) {}
public function for(ChannelType $type): ChannelDriver
{
return match ($type) {
ChannelType::Web => $this->container->make(WebChannel::class),
ChannelType::Webhook => $this->container->make(WebhookChannel::class),
ChannelType::Telegram => $this->container->make(TelegramChannel::class),
};
}
}

View File

@@ -0,0 +1,23 @@
<?php
namespace App\Channels;
final class DeliveryOutcome
{
private function __construct(
public readonly bool $ok,
public readonly ?string $error,
/** @var array<string,mixed> служебное канала, ляжет в 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, []);
}
}

View File

@@ -0,0 +1,100 @@
<?php
namespace App\Channels;
use App\Enums\Presence;
use App\Models\Delivery;
use App\Models\Event;
use App\Models\User;
use Illuminate\Http\Client\Response;
use Illuminate\Support\Facades\Cache;
use Illuminate\Support\Facades\Http;
use Throwable;
final class TelegramChannel implements ChannelDriver
{
public function __construct(
private readonly string $botToken,
private readonly int $seenTtl,
) {}
public function presence(User $user, array $config): Presence
{
// Бот не видит онлайн-статус: present только если недавно жал кнопку или писал.
return Cache::has($this->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}";
}
}

View File

@@ -0,0 +1,25 @@
<?php
namespace App\Channels;
use App\Enums\Presence;
use App\Models\Delivery;
use App\Models\Event;
use App\Models\User;
use Illuminate\Support\Facades\Cache;
/** Инбокс. Ничего не шлёт (страница и так показывает всё), но присутствие определяет. */
final class WebChannel implements ChannelDriver
{
public function presence(User $user, array $config): Presence
{
return Cache::has("heartbeat:{$user->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 {}
}

View File

@@ -0,0 +1,70 @@
<?php
namespace App\Channels;
use App\Enums\Presence;
use App\Http\Controllers\Api\EventsController as Presenter;
use App\Models\Delivery;
use App\Models\Event;
use App\Models\User;
use Illuminate\Support\Facades\Http;
use Throwable;
/** Универсальный webhook. В нашем случае — Home Assistant. */
final class WebhookChannel implements ChannelDriver
{
public function __construct(private readonly int $presenceTimeout) {}
public function presence(User $user, array $config): Presence
{
try {
$response = Http::timeout($this->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<string,mixed> */
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),
],
];
}
}

View File

@@ -2,6 +2,7 @@
namespace App\Delivery; namespace App\Delivery;
use App\Channels\ChannelRegistry;
use App\Enums\EventState; use App\Enums\EventState;
use App\Models\Event; use App\Models\Event;
use App\Scheduling\DueResolver; use App\Scheduling\DueResolver;
@@ -12,6 +13,7 @@ final class EventActions
public function __construct( public function __construct(
private readonly DueResolver $due, private readonly DueResolver $due,
private readonly QuietRule $quiet, private readonly QuietRule $quiet,
private readonly ChannelRegistry $channels,
) {} ) {}
/** «Помню»: молчать до следующей контрольной точки текущего состояния. */ /** «Помню»: молчать до следующей контрольной точки текущего состояния. */
@@ -35,5 +37,9 @@ final class EventActions
$event->state = EventState::Done; $event->state = EventState::Done;
$event->done_at = now(); $event->done_at = now();
$event->save(); $event->save();
foreach ($event->deliveries()->with('channel')->get() as $delivery) {
$this->channels->for($delivery->channel->type)->onDone($event, $delivery);
}
} }
} }

View File

@@ -16,6 +16,12 @@ class AppServiceProvider extends ServiceProvider
config('hado.fallback_hour_minutes'), config('hado.fallback_hour_minutes'),
config('hado.fallback_hours'), 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'),
));
} }
/** /**

View File

@@ -0,0 +1,91 @@
<?php
namespace Tests\Unit\Channels;
use App\Channels\TelegramChannel;
use App\Enums\AfterDue;
use App\Enums\DueMode;
use App\Enums\EventState;
use App\Enums\Presence;
use App\Models\Delivery;
use App\Models\Event;
use App\Models\Source;
use App\Models\User;
use Illuminate\Http\Client\Request;
use Illuminate\Support\Facades\Http;
use Tests\TestCase;
class TelegramChannelTest extends TestCase
{
private function driver(): TelegramChannel
{
return new TelegramChannel('test-token', 600);
}
private function event(): Event
{
$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', '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);
}
}

View File

@@ -0,0 +1,23 @@
<?php
namespace Tests\Unit\Channels;
use App\Channels\WebChannel;
use App\Enums\Presence;
use App\Models\User;
use Illuminate\Support\Facades\Cache;
use Tests\TestCase;
class WebChannelTest extends TestCase
{
public function test_presence_follows_heartbeat(): void
{
$user = new User(['login' => '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, []));
}
}

View File

@@ -0,0 +1,103 @@
<?php
namespace Tests\Unit\Channels;
use App\Channels\WebhookChannel;
use App\Enums\AfterDue;
use App\Enums\DueMode;
use App\Enums\EventState;
use App\Enums\Presence;
use App\Models\Delivery;
use App\Models\Event;
use App\Models\Source;
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
{
private array $config = ['deliver_url' => '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);
}
}