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:
20
core/app/Channels/ChannelDriver.php
Normal file
20
core/app/Channels/ChannelDriver.php
Normal 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;
|
||||
}
|
||||
20
core/app/Channels/ChannelRegistry.php
Normal file
20
core/app/Channels/ChannelRegistry.php
Normal 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),
|
||||
};
|
||||
}
|
||||
}
|
||||
23
core/app/Channels/DeliveryOutcome.php
Normal file
23
core/app/Channels/DeliveryOutcome.php
Normal 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, []);
|
||||
}
|
||||
}
|
||||
100
core/app/Channels/TelegramChannel.php
Normal file
100
core/app/Channels/TelegramChannel.php
Normal 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}";
|
||||
}
|
||||
}
|
||||
25
core/app/Channels/WebChannel.php
Normal file
25
core/app/Channels/WebChannel.php
Normal 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 {}
|
||||
}
|
||||
70
core/app/Channels/WebhookChannel.php
Normal file
70
core/app/Channels/WebhookChannel.php
Normal 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),
|
||||
],
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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'),
|
||||
));
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
Reference in New Issue
Block a user