Files
hado/docs/superpowers/specs/2026-09-03-hado-core-design.md
2026-09-03 23:21:59 -03:00

24 KiB
Raw Blame History

Hado Core — дизайн ядра умных уведомлений

Дата: 2026-09-03. Статус: согласован в брейншторме, ждёт ревью.

1. Что это и зачем

Hado (波) — отдельный переиспользуемый сервис уведомлений для всех сервисов экосистемы (документы, будущие остальные). Клиенты присылают ядру события с датой, ядро владеет состоянием, само считает, когда и куда напоминать, и само доставляет по каналам, в которых пользователь сейчас доступен.

Принципы, которые нельзя нарушать:

  • Ядро не знает доменов. Ни ДР, ни паспортов, ни седул. Только событие, дата, готовый к показу payload. Подключение нового клиента — токен в реестре, правок ядра ноль.
  • Клиенты пушат, ядро хранит. Как шина: клиент делает upsert, ничего у себя не хранит и не планирует. Ядро само разносит по каналам.
  • Напоминание — когда ты доступен, не когда стрелка на девяти. Иначе это ещё одни «Напоминания» Apple, которые стираются на автомате.
  • «Помню» и «Сделано» — разные вещи. Первое гасит текущее напоминание, второе закрывает событие. Бейдж падает только от второго.

2. Границы

В этой спеке: бэкенд ядра (Laravel + Postgres), API для клиентов, API для пользователя, веб-инбокс, каналы telegram и webhook, доставка по присутствию.

Вне спеки (отдельные проекты, путь открыт):

  • iOS-приложение: бейдж, виджет «Грядёт», Live Activity, канал apns.
  • Разбор PDF через LLM — это клиент документов, не ядро.
  • Голосовой ассистент — подключается как webhook-канал через Home Assistant, ядру про него знать не нужно.

3. Понятия

  • Источник (source) — сервис-клиент с токеном. docs, потом другие.
  • Пользователь (user) — логин из X-Remote-User хаба sekai. У него свой часовой пояс, тихие часы и каналы.
  • Событие (event) — то, что имеет срок. Идентичность: (source, source_ref, topic). Ядро само собирает ключ из этих трёх полей, клиент ничего не считает.
  • Срок (due) — календарный день, опционально с моментом внутри дня.
  • Старт (fire_on) — день, с которого начинается подготовка. Если подготовки нет, клиент шлёт fire_at = due_at.
  • Каскад — точки подготовки между стартом и сроком, ядро строит их по формуле.
  • Окно — интервал, внутри которого ядро ищет момент доставить напоминание.
  • Присутствие — ответ канала на вопрос «пользователь сейчас тут?».

4. Модель данных

sources
  id, name UNIQUE, token_hash, created_at

users
  id, login UNIQUE, tz (IANA, напр. America/Montevideo),
  quiet_start TIME (default 22:00), quiet_end TIME (default 09:00),
  created_at, updated_at

events
  id
  source_id        FK sources
  user_id          FK users
  source_ref       string      -- "person:42"
  topic            string      -- "birthday", по умолчанию ""
  due_date         date
  due_time         time        -- момент срока внутри дня; 24:00:00 = весь день
  fire_on          date        -- старт подготовки; = due_date, если подготовки нет
  after_due        enum(keep, expire)
  payload          jsonb       -- title, subtitle, deep_link, done_label
  state            enum(scheduled, preparing, today, overdue, done, expired, withdrawn)
  quiet_until      timestamptz -- «молчать до»; по умолчанию в прошлом
  done_at          timestamptz nullable
  created_at, updated_at
  UNIQUE (source_id, source_ref, topic)

channels
  id, user_id FK, type enum(web, telegram, webhook), config jsonb, enabled bool,
  created_at, updated_at
  -- web.config:      {}  (создаётся автоматически, не удаляется)
  -- telegram.config: { chat_id }
  -- webhook.config:  { deliver_url, presence_url }

deliveries
  id, event_id FK, channel_id FK,
  window_start     timestamptz -- начало окна, в которое доставлено
  action_token     string UNIQUE -- для ссылок «Помню»/«Сделано» из канала
  sent_at, result enum(ok, failed), error text nullable

Даты события хранятся как календарные, без пояса. Пояс берётся у пользователя в момент вычисления. Переехал в другую страну, сменил tz — все окна сдвинулись сами, пересчитывать нечего.

Кэш присутствия живёт в cache-store (ключ presence:{channel_id}, TTL 60 с), в таблицах его нет.

5. Контракт для клиентов

Авторизация: Authorization: Bearer <token источника>. Источник берётся из токена, в URL и теле его нет.

Upsert пачкой

PUT /api/events
{
  "events": [
    {
      "user":       "nikita",
      "source_ref": "person:42",
      "topic":      "birthday",
      "due_at":     "2026-08-14",             // или "2026-09-10T14:00"
      "fire_at":    "2026-08-14",             // = due_at, если подготовки нет
      "after_due":  "expire",
      "payload": {
        "title":      "Сегодня ДР — Вася",
        "subtitle":   "исполняется 34",
        "deep_link":  "/people/42",
        "done_label": "Поздравил"             // опционально, по умолчанию «Сделано»
      }
    }
  ]
}
→ 200 { "events": [ { "source_ref": "person:42", "topic": "birthday", "id": 17, "state": "scheduled" } ] }

Правила разбора due_at: строка YYYY-MM-DDdue_date, due_time = 24:00 (весь день). Строка YYYY-MM-DDTHH:MMdue_date + due_time. Смещение в строке игнорируется, дата календарная. fire_at — только YYYY-MM-DD, не позже due_date. Валидация: fire_at <= due_at, user непустой, payload.title непустой, до 500 событий в пачке. Одна плохая запись → 422 на всю пачку с указанием индекса, ничего не применяется.

Правила upsert по существующему событию (та же тройка идентичности):

что пришло что делает ядро
due_at тот же обновляет fire_on, after_due, payload, user. Если состояние терминальное (done/expired/withdrawn) — не трогает. Иначе пересчитывает состояние из дат (см. §6).
due_at другой это новый заход (ДР следующего года, перенесённый рейс): обновляет всё, сбрасывает quiet_until, пересчитывает состояние из дат даже из терминального.

Так рекуррентность решается без правок ядра: клиент шлёт ДР Васи каждый год с тем же person:42 + birthday и новым due_at.

Снятие и чтение

DELETE /api/events?source_ref=person:42&topic=birthday   → 204, state = withdrawn
GET    /api/events?source_ref=person:42&topic=birthday   → 200 { id, state, due_at, ... }

topic в запросе можно опустить — тогда пустая строка.

6. Жизненный цикл события

state — это и есть фаза. Хранится в базе, меняет крон (тик раз в минуту) по календарю пользователя в его tz.

state смысл напоминает в бейдже
scheduled день старта ещё не пришёл нет нет
preparing идёт подготовка (fire_on <= сегодня < due_date) раз в день нет
today день срока, момент срока ещё не наступил каждый час да
overdue момент срока прошёл, after_due = keep раз в день да
done нажал «Сделано» нет нет
expired момент срока прошёл, after_due = expire нет нет
withdrawn клиент прислал DELETE нет нет

Переходы:

  • scheduled → preparing: наступило 00:00 дня fire_on, и fire_on < due_date.
  • scheduled → today, preparing → today: наступило 00:00 дня due_date.
  • today → overdue: наступил момент due_date + due_time, after_due = keep.
  • today → expired: наступил момент due_date + due_time, after_due = expire.
  • любое из scheduled / preparing / today / overduedone: пользователь нажал «Сделано» (из scheduled — заранее, из инбокса).
  • любое → withdrawn: DELETE от клиента.

Пересчёт состояния из дат (при upsert): берётся «сейчас» в tz пользователя и применяются те же условия, что и переходы. Событие, пришедшее уже в день срока, сразу становится today; пришедшее с fire_on в прошлом — preparing. Задним числом ничего не доставляется.

Бейдж = число событий пользователя в today + overdue.

7. Каскад

Точки подготовки нужны только для preparing и только чтобы знать, до какого дня молчать после «Помню». Не хранятся, считаются из fire_on и due_date:

d = due_date - fire_on            // дней
points = []
while d >= 1:
    points.append(due_date - d)
    d = d div 2
fire_on за точки, дней до срока
0 нет точек, preparing не бывает
1 1
7 7, 3, 1
30 30, 15, 7, 3, 1
60 60, 30, 15, 7, 3, 1
180 180, 90, 45, 22, 11, 5, 2, 1

8. Окна, «Помню», «Сделано»

Напоминание не шлётся в момент — ищется внутри окна. Окна лежат внутри активных часов пользователя [quiet_end, quiet_start), по умолчанию 9:0022:00 в его tz.

state окна fallback-запас
preparing одно в день: 9:0022:00 30 мин
today каждый час: 9:0010:00, 10:0011:00, … 21:0022:00 10 мин
overdue одно в день: 9:0022:00 30 мин

Если момент срока в today наступает посреди окна, окно обрезается по нему.

Событие получает не больше одной доставки на окно (deliveries.window_start), и только если now >= quiet_until.

«Помню» (ack) — «молчи до следующей контрольной точки». Ставит quiet_until:

state quiet_until
preparing 00:00 ближайшей точки каскада после сегодня; если точек больше нет — 00:00 due_date
today начало окна через одно (следующий час пропускаем)
overdue 00:00 послезавтра (завтрашнее окно пропускаем)

Пример preparing: fire_on за 30 дней, напомнило на 30, 29, 28; «Помню» на 28 → тишина до 15, там снова каждый день до следующего «Помню» → до 7.

Пример today: напомнило в 11:00, «Помню» → 12:00 молчит, 13:00 снова.

«Сделано» (done) — событие в done, done_at = now, напоминания и бейдж снимаются. Работает в любой момент, хоть за два месяца до срока.

Действия доступны тремя путями: кнопки в веб-инбоксе (POST /me/events/{id}/ack|done, под SSO), кнопки Telegram (callback ack:{token} / done:{token}) и подписанные ссылки для webhook (POST /a/{token}/ack, POST /a/{token}/done). token — это deliveries.action_token, живёт до терминального состояния события.

9. Доставка по присутствию

Каждый канал реализует один контракт:

interface Channel
{
    /** present | absent | unknown */
    public function presence(User $user, array $config): Presence;

    public function deliver(Event $event, User $user, array $config, string $actionToken): DeliveryResult;
}

Как каналы отвечают на presence:

канал present иначе
webhook (HA) GET presence_url вернул {"present": true} absent при false, unknown при ошибке/таймауте
telegram пользователь писал боту или жал кнопку за последние 10 мин unknown (бот не видит онлайн-статус)
web (инбокс) вкладка инбокса шлёт POST /me/heartbeat раз в 30 с, последний был < 90 с назад absent

web — обычный канал в таблице channels, создаётся автоматически вместе с пользователем, удалить нельзя. Его deliver ничего не шлёт (инбокс и так показывает всё), только пишет запись в deliveries: окно закрыто, пользователь увидел в инбоксе. Зато присутствие он определяет полноценно: если открыта вкладка, доставлять в Telegram незачем.

Алгоритм тика (раз в минуту) для каждого события в preparing/today/overdue, находящегося в открытом окне, без доставки в этом окне и с now >= quiet_until:

  1. Спросить presence() у всех включённых каналов пользователя и у web. Параллельно, таймаут 2 с, ответ кэшируется на 60 с.
  2. Есть present → доставить только в них. Окно закрыто.
  3. Никого present, до конца окна больше fallback-запаса → ждать следующего тика.
  4. Никого present, до конца окна меньше fallback-запаса → доставить во все включённые каналы. Лучше один раз наугад, чем пропустить окно.

Сама доставка — job в очереди с ретраями (3 попытки, экспоненциально), чтобы медленный HA не тормозил тик. Результат каждой попытки в deliveries.

У пользователя без каналов доставок нет, события видны только в инбоксе. Это нормальное состояние, не ошибка.

Что шлют каналы

telegram: одно сообщение title + subtitle + inline-кнопки «Помню» и done_label. Повтор в следующем окне — новое сообщение. После done кнопки на старых сообщениях убираются (edit reply markup, best effort).

webhook: POST deliver_url

{
  "event": { "id": 17, "state": "today", "due_at": "2026-08-14", "source": "docs",
             "title": "Сегодня ДР — Вася", "subtitle": "исполняется 34",
             "deep_link": "/people/42", "done_label": "Поздравил" },
  "actions": { "ack": "https://hado.../a/<token>/ack", "done": "https://hado.../a/<token>/done" }
}

10. Пользователь: пояс, тихие часы, инбокс

GET    /me                       → { login, tz, quiet_start, quiet_end, badge }
PATCH  /me                       { tz?, quiet_start?, quiet_end? }
GET    /me/events?state=...      → список (по умолчанию все нетерминальные)
POST   /me/events/{id}/ack
POST   /me/events/{id}/done
POST   /me/heartbeat
GET    /me/channels
POST   /me/channels              { type: webhook, config: {deliver_url, presence_url} }
DELETE /me/channels/{id}
POST   /me/channels/telegram/link → { code, bot_url }   // затем /start <code> боту

Пользователь создаётся при первом запросе с новым X-Remote-User, tz из HADO_DEFAULT_TZ. Смена tz — руками через PATCH /me или из HA; позже iOS будет обновлять сама.

Веб-инбокс — одна Blade-страница под SSO: список событий по группам today, overdue, preparing, scheduled, у каждого кнопки «Помню» / «Сделано», deep_link ведёт в клиент. Блок настроек: пояс, тихие часы, каналы, привязка Telegram. Страница держит heartbeat. Никакого SPA.

11. Доступ

Три входа, три механизма:

вход маршруты авторизация
клиенты /api/* Bearer-токен источника; php artisan hado:source:create <name> печатает токен один раз, в базе хэш
пользователь /, /me/* sekai forward_auth, ядро доверяет X-Remote-User (сеть изолирована, как в спеке sekai)
действия из каналов /a/{token}/*, /hooks/telegram случайный action_token доставки; Telegram дополнительно проверяет X-Telegram-Bot-Api-Secret-Token

/api/*, /a/* и /hooks/* в Caddy идут мимо forward_auth, всё остальное через него. X-Remote-User на этих маршрутах игнорируется.

12. Деплой

Docker compose, как у sekai:

  • app — serversideup/php, HTTP.
  • scheduler — тот же образ, php artisan schedule:work, тик раз в минуту.
  • worker — тот же образ, php artisan queue:work, доставки и опросы присутствия.
  • postgres.
  • Cache/queue driver — database на старте (одна зависимость меньше); Redis, если упрёмся.

Env: APP_URL, DB_*, HADO_DEFAULT_TZ, TELEGRAM_BOT_TOKEN, TELEGRAM_WEBHOOK_SECRET, HADO_PRESENCE_TIMEOUT=2, HADO_FALLBACK_DAY=30, HADO_FALLBACK_HOUR=10.

В Caddy хаба один vhost hado.<домен> с двумя матчерами: публичные пути (/api/*, /a/*, /hooks/*) напрямую, остальное через forward_auth.

13. Тесты

Время везде через Carbon::setTestNow, присутствие через фейковые каналы.

Юнит:

  • Каскад: таблица из §7, включая fire_on = due_date → пусто.
  • Переходы состояний из §6, включая пересчёт при upsert с датами в прошлом.
  • quiet_until по трём состояниям из §8.
  • Алгоритм окна: доставил только в present; ждал при отсутствии; fallback во все за N минут до конца; одно окно — одна доставка; quiet_until блокирует; окно today обрезается моментом срока; смена tz сдвигает окна; тихие часы.

Фичевые (HTTP):

  • Upsert пачкой: создание, повтор с тем же due_at (payload обновился, done остался), повтор с новым due_at (реанимация из done), 422 на плохую запись, 401 на чужой токен.
  • DELETE → withdrawn; GET состояния.
  • /me/* под X-Remote-User: автосоздание пользователя, ack/done, бейдж.
  • /a/{token}/done закрывает событие; неверный токен → 404.
  • Telegram: webhook с секретом, callback ack:/done:, привязка через код.

Каналы с Http::fake(): доставка и presence у webhook (в т.ч. таймаут → unknown), Telegram sendMessage и editMessageReplyMarkup.

14. Отвергнутые варианты

  • Ядро опрашивает клиентов (derive). Заставляло бы каждый клиент хранить и планировать уведомления у себя. Шина работает наоборот: клиенты пушат.
  • Тип события как enum day-of / deadline. Склеивал два независимых свойства (есть ли подготовка, актуально ли после срока). Заменён на fire_at = due_at и after_due.
  • Фиксированное расписание в ядре (30, 15, 7). Не подходит событиям с разной глубиной подготовки. Заменено каскадом половинок от fire_on.
  • Пояс из due_at. Пользователь переезжает, пояс — его свойство, не события.
  • Таблица шагов (nudges). Одно поле quiet_until плюс лог доставок покрывают то же самое без второй сущности.
  • snoozed_until и ручное откладывание. Никто не будет вручную двигать даты; «Помню» делает это по правилу состояния.