Skip to main content
Glama
README.md
# relic

> Чип, через який чужа свідомість бачить твоє життя, — і лише ту його частину,
> яку ти відкрив. Назва — з Cyberpunk 2077.

Шлюз від продуктів до соцмереж і месенджерів: **ключ продукту**, **область
дії**, **облік**, **бюджет відповіді**. REST для продуктів і MCP для моделей —
на одному порту, з одними ключами.

    продукт ──(ключ продукту)──▶ relic ──▶ адаптер ──▶ інструмент ──▶ платформа
    модель (MCP) ────────────┘              telegram-archive → Telegram-Archive → Telegram
                                            youtube          → Data API v3, yt-dlp → YouTube

Двійник [exo-ai](https://github.com/eXocriador/exo-ai) (шлюз до моделей) за
механікою і незалежний від нього: шлюзи один про одного не знають. Самарі,
аналіз і відповіді робить продукт: текст звідси → промпт → exo-ai.

## Навіщо сервіс, а не бібліотека

* **Сесія особистого акаунта — одна.** Два процеси на одній сесії ламають її,
  кожен новий вхід — «новий пристрій», часті входи — шлях до бану. Сесія живе в
  одному місці, продукти просять дані.
* **Політику не можна доручити тому, кого вона обмежує.** «Продукт A бачить лише
  ці три чати» тримається тільки на боці сервера.
* **Стелі частоти — спільні**: FloodWait і ліміти API рахуються на акаунт.

**Адаптер — на інструмент, не на платформу.** Важка робота лишається в готовому
інструменті (архіватор, міст, офіційний API), адаптер лише перекладає.

## Контракт

`Authorization: Bearer <ключ продукту>` (або `X-Api-Key`). Ключ **називає
продукт** — це ім'я шукається в області й пишеться в облік.

| MCP-інструмент | REST-двійник |
|---|---|
| `list_chats` | `GET /v1/conversations?limit&offset` |
| `get_messages` | `GET /v1/conversations/:ref/messages?limit&cursor` |
| `search_messages` | `GET /v1/conversations/:ref/search?q&limit` |
| `get_messages_by_date` | `GET /v1/conversations/:ref/by-date?date&timezone&limit` |
| `get_transcript` | `GET /v1/conversations/:ref/transcript?language&cursor` |

Ще: `GET /v1/adapters` (стан входу кожного адаптера з причиною і `limited`), `GET /v1/scope`
(своя область), `GET /v1/usage` (виклики за добу), `/health/live`,
`/health/ready` (обидва без ключа). MCP — `POST /mcp`, Streamable HTTP,
stateless, відповідь JSON.

**Спільна форма даних** — одна на всі адаптери:

| сутність | поля |
|---|---|
| розмова | `ref` (`<адаптер>:<акаунт>:<id>`, стабільний), `name`, `type`, `platform`, `last_message?`, `participants?` |
| повідомлення | `id`, `date` (ISO, UTC, `Z`), `from?`, `out?`, `text?`, `reply_to?`, `topic?`, `media?`, `fwd?`, `edited?`, `deleted?`, `pinned?`, `cut?` |
| сегмент транскрипту | `start`, `end` (секунди від початку, до десятих), `text` — рядки субтитрів, злиті до ~30 с |

| код | що це | що робити продуктові |
|---|---|---|
| 200 | відповідь є | читати; `truncated: true` → звузити питання або `cursor=next` |
| 404 `not_found` | розмови немає **або** вона поза областю ключа — невідрізненно | брати ref з `list_chats` |
| 429 `budget_exhausted` | денна стеля продукту | деградувати, сьогодні не повторювати |
| 429 `rate_limited` | стеля частоти платформи (у YouTube — і вичерпана квота Data API) | повторити пізніше |
| 502 `platform_blocked` | платформа відмовляє саме серверу (бот-перевірка YouTube для IP датацентру) | **не** повторювати; вхід цілий |
| 503 `adapter_expired` | вхід адаптера відхилено | людина: перелогінити інструмент |
| 503 `adapter_down` / `scope_unavailable` | інструмент або БД недосяжні | fail-safe продукту |

## Область дії

Рядки `social_scope`: (продукт, адаптер, акаунт, розмови, `read`|`write`).
Розмови — список ref або `{*}` = «усе, що бачить акаунт адаптера». **Новий
продукт — порожня область, а не «усе».** Запис — окремий рядок `write`; поки
його немає, інструментів запису немає в `tools/list` (у цій версії їх немає
взагалі). Недоступна БД — відмова (fail-closed); облік і стелі — fail-open.

Два рівні білого списку свідомо: акаунт **інструмента** (наприклад, акаунт
переглядача архіву з білим списком чатів) — що шлюзу взагалі видно; область
**ключа** — що з цього видно конкретному продукту.

## Бюджет відповіді

Кожна відповідь — не більше `MAX_ITEMS` (100) елементів і `MAX_RESPONSE_BYTES`
(32 КБ ≈ 8–10 тис. токенів) компактного JSON. Обрізана каже `truncated`,
пояснює `note` і, де є куди гортати, дає непрозорий курсор `next`. Перше
повідомлення не випадає, а вкорочується (`cut`) — порожня сторінка не дала б
курсора. Перенесення `internal/budget` з форку
[telegram-archive-mcp](https://github.com/eXocriador/telegram-archive-mcp), тести —
ті самі межі. Заміряно на чаті в 10 тис. повідомлень: 3,3 тис. токенів замість 75 тис.

## Здоров'я бачить вхід адаптера

`/health/ready` несе `checks.<адаптер>`: `ok` / `expired` / `down` (і `skip` —
вхід вимкнений конфігом свідомо, як youtube без ключа). `expired` чи
`down` увімкненого адаптера — **503 проби**: протухлий вхід — це «шлюз
непридатний», і монітор мусить це бачити, а не дізнатись з обліку. Стеля
частоти платформи (FloodWait, 429, вичерпана квота) і відмова платформи серверу
(бот-перевірка) — не вирок, лише `limited` з причиною у `/v1/adapters`.
Проба ходить за розкладом (`ADAPTER_PROBE_INTERVAL_MS`), а не на кожен запит
монітора: відхилений вхід — теж спроба, а стелі входів у інструментів тісні.

## Адаптери

| адаптер | інструмент | вхід |
|---|---|---|
| `telegram-archive` | HTTP API переглядача [Telegram-Archive](https://github.com/GeiserX/Telegram-Archive) | окремий акаунт переглядача з `allowed_chat_refs: []`; кука `viewer_auth` шлеться руками (вона `Secure`, а шлюз ходить по HTTP у приватній мережі), перелогін на 401 |
| `youtube` | [YouTube Data API v3](https://developers.google.com/youtube/v3) (коментарі, назви) і [yt-dlp](https://github.com/yt-dlp/yt-dlp) (транскрипти) | входу в акаунт немає, акаунт один — `public`; «вхід» = ключ API (`YOUTUBE_API_KEY`), без нього `skip` |

### youtube

* **Розмова = відео**: `youtube:public:<id>`, `type: video`, `name` — назва. Ref
  будує викликач: на місці id можна дати й посилання (`watch?v=`, `youtu.be/`,
  `shorts/`, `live/`, `embed/`) — шлюз зводить його до id **до** області й обліку.
  `list_chats` відео не показує: відео — не список розмов акаунта. Окремого
  `resolve` немає: розбір рядка не вартий ще одного інструмента.
* **Повідомлення = коментарі** (`commentThreads`): гілка, за нею відповіді, які
  Data API віддає разом із нею (`reply_to` — батьківський коментар); новіші
  гілки першими. Курсор несе `pageToken` і останній відданий коментар —
  сторінку, обрізану бюджетом посередині, продовжує без повторів.
  `get_messages_by_date` — коментарі дня (обхід до 20 сторінок);
  `search_messages` — `searchTerms`.
* **Транскрипт** — окремий інструмент `get_transcript`, бо мовлення не
  «повідомлення»: ні автора, ні id. Субтитри автора мовою відео, інакше
  розпізнане YouTube (`source: manual | auto`); `language` — інша мова. Сторінки
  гортаються з кешу (година), yt-dlp — один запуск на відео.
* **Квота Data API** — 10 000 одиниць на добу на проєкт Google; сторінка
  коментарів = 1. Витрата кожного виклику — у `detail` обліку (`units=…`),
  проба ключа — 1 одиниця раз на `YOUTUBE_PROBE_INTERVAL_MS` (10 хв).
* **Запобіжник** (`adapters/youtube/guard.ts`, стан у Redis): на весь шлюз ≤ 20
  запусків yt-dlp на годину і ≤ 100 на добу, проміжок ≥ 10 с, один процес за
  раз; 429 від YouTube — пауза на годину, 5 бот-перевірок поспіль — на 30 хв;
  відео з бот-перевіркою не питається вдруге добу; кеш транскрипту — доба;
  автоперекладу субтитрів немає; власна стеля Data API — 5000 одиниць на добу з
  10 000. На паузі чи стелі шлюз відповідає сам (429 `rate_limited` з причиною),
  YouTube не питає. Без Redis стан тримається в пам'яті — стелі не знімаються.
* **Бот-перевірка.** З IP датацентру YouTube часто просить «Sign in to confirm
  you're not a bot» — тоді `get_transcript` → `platform_blocked`, а стан
  адаптера лишається `ok` з `limited` і причиною. Ні інший `player_client`, ні
  PO-токен цього не знімають (перевірено 2026-09-24); коментарі (Data API)
  перевірка не зачіпає.

Інтерфейс — `src/adapters/types.ts` (як `Publisher` в exopost: `probe` ≈
`verify`, `AdapterError.retryable`, реєстр за іменем).

## stdio-вхід MCP

`dist/mcp-stdio.js` — міст stdin/stdout ↔ `http://127.0.0.1:$PORT/mcp` того
самого контейнера з ключем продукту `STDIO_PRODUCT` з оточення. Для клієнта,
що бачить контейнер лише через `docker exec`:

```bash
claude mcp add relic -s user -- docker exec -i relic-web node dist/mcp-stdio.js
```

Політика лишається на сервері: область, стеля, бюджет і облік — ті самі.

## Розробка

```bash
pnpm install && pnpm test && pnpm typecheck
```

Ворота (`typecheck` + `test`) — у стадії `build` образу. Міграції — dbmate
(`apps/api/db/migrations`), бінарник у образі. yt-dlp — запінений реліз з
перевіркою sha256 (`ADD --checksum` у Dockerfile); тести підміняють його
справжнім процесом-скриптом.

## Ліцензія

MIT — `LICENSE`. Бюджет відповіді — перенесення нашого ж доповнення до форку
[telegram-archive-mcp](https://github.com/eXocriador/telegram-archive-mcp)
(сам форк лишається під GPL-3.0 апстріму, і його код сюди не переноситься:
обхід дня в адаптері — власна реалізація). Інструмент під адаптером —
[Telegram-Archive](https://github.com/GeiserX/Telegram-Archive), з ним relic
говорить лише по HTTP. yt-dlp (Unlicense) — окремий бінарник в образі, relic
запускає його процесом.