diff --git a/.env.example b/.env.example index 168bf69..f7df931 100644 --- a/.env.example +++ b/.env.example @@ -10,6 +10,17 @@ TG_SESSION_DIR=/opt/vesti/telegram VESTI_BOT_TOKEN=your_bot_token_here # Канал публикации (шаблон dedinit_vesti___bot) VESTI_BOT_CHANNEL=@dedinit_vesti_linux_ru_bot +# GoToSocial (fediverse, Mastodon-совместимый API) — публикация от имени kpa39l +# Нода: https://social.dedinit.ru (bigbox, Docker-контейнер gotosocial, :8082) +GT_SOCIAL_URL=https://social.dedinit.ru +# Bearer-токен пользователя kpa39l (OAuth, scope write; см. change gotosocial-publisher) +GT_SOCIAL_ACCESS_TOKEN=your_gotosocial_token_here +# Видимость статуса: public | unlisted | private +GT_SOCIAL_VISIBILITY=public +# Формат статуса: markdown (GtS поддерживает) | plain +GT_SOCIAL_FORMAT=markdown +# Fediverse-каналы (отдельно от Telegram-каналов; через запятую) +VESTI_GTS_CHANNELS=@kpa39l@dedinit.ru # Веб-админ ADMIN_USER=admin ADMIN_PASSWORD=change_me diff --git a/STATUS.md b/STATUS.md index 0959d9f..75d991f 100644 --- a/STATUS.md +++ b/STATUS.md @@ -1,15 +1,21 @@ # VESTI — Статус -Обновлено: 2026-09-13 (publisher снова на Docker; systemd1 ожил) +Обновлено: 2026-09-13 (fediverse-публикация в GoToSocial @vesti) ## Текущее состояние -- Базовый конвейер (прототип) работает: краулер (8 источников, инкрементальный, метрики), классификатор (Ollama qwen3:8b-nothink + словари), банк статей (bundles + origin:own), веб :8400 (кандидаты/approve/reject/опубликованные/метрики). +- **FEDIVERSE-ПУБЛИКАЦИЯ РАБОТАЕТ** (2026-09-13): publisher-service умеет публиковать карточки в GoToSocial (social.dedinit.ru) от имени бот-аккаунта **@vesti@dedinit.ru** (Mastodon-совместимый API). Change `gotosocial-publisher`. +- Аккаунт **vesti** создан на GtS (2026-09-13, confirmed+approved, admin=0), OAuth-приложение `vesti-publisher` (client_id=01B09QNDM9YQ2VQVSRXHCVAV6K), токен в .env (`GT_SOCIAL_ACCESS_TOKEN`, scope `read write`), канал `VESTI_GTS_CHANNELS=@vesti@dedinit.ru`. +- healthz publisher: блок `gotosocial` — url, account=vesti, token_set=true, account_ok=true. +- Реальные тесты: текст → пост, медиа → пост с картинкой (media_attachments=1), delete → 200. Посты-тесты удалены, канал чист. +- Канал в запросе: `channels: ["gt:@vesti@dedinit.ru"]` (префикс `gt:`) — fediverse; `@dedinit_vesti` — Telegram. dry_run эмулирует оба. +- Базовый конвейер (прототип) работает (далее по-прежнему): краулер, классификатор, банк статей, веб :8400. - **Бэкфилл @dedinit ЗАВЕРШЁН** (2026-09-10): crawler/backfill_dedinit.py, fetched=1040, max_post_id=1092; в БД 846 постов is_own=1, медиа в media/media/ (исторический баг пути: БД media/, файл media/media/; с 2026-09-12 краулер качает в media/, для старых — fallback в make_card). Скрипт оставлен для повторного дозаполнения. - **РЕАЛЬНАЯ ПУБЛИКАЦИЯ РАБОТАЕТ**: бот-контроллер @dedinit_controller_bot (id 7765665742) → @dedinit_vesti. Токен в .env. Проверено: тестовые посты (message_id 2–4). - **publisher-service**: **Docker-контейнер** `vesti-publisher` (docker compose -f services/publisher/docker-compose.yml, 127.0.0.1:8410, restart=unless-stopped, healthy). Compose: env из /.env, TG_PROXY=socks5://host.docker.internal:1080, маунт ../../media/media:/srv/publisher/media:ro. Старый user-юнит `vesti-publisher.service` (~/.config/systemd/user/) — **disabled, не используется** (костыль на время сломанного systemd1, порт 8410 конфликтовал бы). @@ -60,6 +66,17 @@ vesti-web (:8400) ──HTTP POST──▶ publisher-service (:8410) ──Bot A - [x] **Проверено на реальном канале**: publish ушёл в @dedinit_vesti (message_id 6), текст с & доставлен как есть, без превью; тестовые сообщения удалены (delete_message ok). Канал чист - [x] **publisher временно работает как локальный uvicorn** (127.0.0.1:8410, PID 807769) — Docker недоступен из-за зависшего systemd1 на хосте (внешняя проблема, не vesti) +## Сделано (сессия gotosocial-publisher 2026-09-13) +- [x] **OpenSpec change `gotosocial-publisher`** — proposal/design/tasks/specs/gotosocial-publisher, `openspec validate` чисто +- [x] services/publisher/app/gotosocial.py — клиент GtS (httpx): post_status (format=markdown), upload_media (v2), verify_credentials, delete_status, GoToSocialError +- [x] config.py: gt_social_url/token/visibility/format/channels (+ содержимое .env, .env.example) +- [x] channels.py: resolve_channels (TG) + resolve_gotosocial_channels (fediverse из VESTI_GTS_CHANNELS или `gt:`-каналы из запроса) +- [x] main.py: `_publish_gotosocial` (upload_media → post_status; status_id в ответе), healthz-блок gotosocial, dry_run для fediverse +- [x] **Аккаунт vesti создан** на GtS (admin account create, confirmed+approved), НЕ от имени kpa39l — по запросу пользователя отдельный бот-аккаунт +- [x] **OAuth-приложение vesti-publisher** (POST /api/v1/apps, scopes write write:media); токен для vesti: вставлен в БД tokens (user_id из users, scope read write) + записан в .env +- [x] **Тесты e2e**: dry_run (fediverse), реальный текст → пост, медиа → пост с картинкой, delete → 200; посты-тесты удалены +- [x] **Фикс**: message_id=0 для GtS (id — ULID-строка) → добавлен `status_id: str` + ## Сделано ранее (own-content-hub) - [x] Миграция БД (migrate_own.py): is_own, is_own_canonical, sources.own, published.distributed_dirs - [x] sources.yaml: +dedinit (own: true), синк @@ -74,7 +91,7 @@ vesti-web (:8400) ──HTTP POST──▶ publisher-service (:8410) ──Bot A - [ ] Проверить результат классификации (в БД: direction заполнены) и выборочно качество на своих постах - [ ] **rich-repost-card в проде**: после оживления systemd1 на хосте пересобрать Docker-контейнер publisher (docker compose -f services/publisher/docker-compose.yml up -d --build), перезапустить веб (подхватит шаблон + comment), проверить approve поста с медиа (send_photo/send_video) - [ ] **Закрыть OpenSpec change rich-repost-card** (статус APPLIED после подтверждения), архивировать -- [ ] git-репозитории (gitverse истина, gitea зеркало) — /opt/vesti ещё НЕ git-репо +- [x] ~~git-репозитории (gitverse истина, gitea зеркало)~~ — /opt/vesti **git-репо** (2026-09-13): gitverse kpa39l/vesti (main) + gitea-зеркало bigbox - [ ] openspec archive web-изменений (deexternalize-web-assets, web-render-markdown, web-format-datetime, web-list-filter-no-js) и publisher-service / tg-crawler-publisher-prototype (после подтверждения) ## Как запустить / проверить @@ -87,6 +104,11 @@ docker logs -f vesti-publisher # лог curl -s -X POST http://127.0.0.1:8410/api/v1/publish \ -H 'Content-Type: application/json' \ -d '{"card":{"text":"Тест","direction":"linux","lang":"ru"}}' +# fediverse (GoToSocial @vesti@dedinit.ru): +curl -s -X POST http://127.0.0.1:8410/api/v1/publish \ + -H 'Content-Type: application/json' \ + -d '{"card":{"text":"Тест fediverse","direction":"linux"},"channels":["gt:@vesti@dedinit.ru"]}' +# dry_run (эмуляция без внешних вызовов): добавить "dry_run":true # (старый user-юнит vesti-publisher.service disabled — не использовать, порт занят) # веб (пароль из .env: VESTI_WEB_PASSWORD or ADMIN_PASSWORD): # systemd-юнит: /etc/systemd/system/vesti-web.service (после daemon-reload) diff --git a/WALKTHROUGH.md b/WALKTHROUGH.md index fb1015b..f8801d6 100644 --- a/WALKTHROUGH.md +++ b/WALKTHROUGH.md @@ -439,3 +439,41 @@ sudo systemctl restart vesti-web # active; login → 200, candidates/published 3. Переписывание новостей от себя (LLM-пересказ) + пайплайн «выбранные → дообогащение → публикация». 4. Извлечение источников из постов + сервис поиска в интернете (только одобренные). 5. Страница управления источниками (CRUD + фильтр по типу). + +## 2026-09-13 — gotosocial-publisher: публикация VESTI в GoToSocial (@vesti@dedinit.ru) + +### Контекст +- Пользователь: «хочу публиковать в свой gotosocial из проекта vesti». Проект /opt/federation не существует — реальная цель: /opt/gotosocial (нода social.dedinit.ru, Docker-контейнер gotosocial, :8082→:8080, версия 0.22.1). +- Решение: НЕ от имени kpa39l (личный аккаунт), а **отдельный бот-аккаунт `vesti`** — «пользователя-бота, который новости и постит». + +### Реализация +1. **OpenSpec change `gotosocial-publisher`** (proposal/design/tasks/specs/gotosocial-publisher/spec.md), `openspec validate` чисто. Валиден. +2. **Аккаунт vesti**: `docker exec gotosocial /gotosocial/gotosocial admin account create --username vesti --email vesti@dedinit.ru --password ` → confirmed+approved, admin=0. + - Питфол: пароль передал в CLI как переменную (не в хронологию); подтверждение прошло автоматически (confirmed_at заполнен). +3. **OAuth-приложение** `vesti-publisher` через `POST /api/v1/apps` (Mastodon API): scopes `write write:media`, redirect `urn:ietf:wg:oauth:2.0:oob` → client_id=01B09QNDM9YQ2VQVSRXHCVAV6K. + - Питфол: app-токен (client_credentials) НЕ подходит — verify_credentials требует привязки к user; для сервер-сервер нужен user-токен. +4. **User-токен vesti**: вставлен в БД `tokens` (SQLite /opt/gotosocial/data/sqlite.db): + - Питфол: **user_id должен быть id из таблицы `users` (не `accounts`!)** — иначе verify_credentials → null; scope `read write`; access 48 симв. + - Проверка: `GET /api/v1/accounts/verify_credentials` с Bearer → username=vesti. Токен записан в /opt/vesti/.env как GT_SOCIAL_ACCESS_TOKEN. +5. **Код publisher** (services/publisher/app/): gotosocial.py (httpx-клиент: post_status c format=markdown, upload_media v2, verify_credentials, delete_status), config.py (gt_social_*), channels.py (resolve_gotosocial_channels; `gt:`-каналы из запроса), main.py (_publish_gotosocial, healthz-блок gotosocial, dry_run). + - Питфол: `gt:@vesti@dedinit.ru` трактовался и как TG — resolve_channels теперь отбрасывает `gt:`-префикс. + - Питфол: message_id=0 для GtS (id — ULID-строка, не int) → добавлен `status_id: str` в ответ. +6. **Тесты e2e** (все реальные, потом удалены delete): текст → пост (status 01M2E6AC...), медиа → пост с картинкой (media_attachments=1), delete → HTTP 200. Канал @vesti@dedinit.ru чист. +7. **Доки**: STATUS.md (раздел fediverse + «Сделано»), .env.example (GT_SOCIAL_*), tasks.md (все [x]). + +### Команды +```bash +docker exec gotosocial /gotosocial/gotosocial admin account create --username vesti --email vesti@dedinit.ru --password '' +curl -s -X POST https://social.dedinit.ru/api/v1/apps -H 'Content-Type: application/json' \ + -d '{"client_name":"vesti-publisher","redirect_uris":"urn:ietf:wg:oauth:2.0:oob","scopes":"write write:media"}' +# токен — вставка в sqlite (python3, см. выше), затем в .env +curl -s http://127.0.0.1:8410/healthz # gotosocial: account=vesti, token_set=true, account_ok=true +curl -s -X POST http://127.0.0.1:8410/api/v1/publish -H 'Content-Type: application/json' \ + -d '{"card":{"text":"Тест","direction":"linux"},"channels":["gt:@vesti@dedinit.ru"]}' +``` + +### Следующая сессия +1. Проработать **candidates-mail-ui** (2 панели) — открытые вопросы см. предыдущая сессия. +2. Закрыть rich-repost-card (архив) + архивировать gotosocial-publisher после подтверждения. +3. Пометить аккаунт vesti как **bot** (в GtS 0.22.1 bot-флаг: PATCH /api/v1/accounts/update_credentials → bot:true, либо actor_type Service) — не критично, желательно. +4. Фан-аут направлений → каналы (в т.ч. fediverse-каналы по направлениям). diff --git a/openspec/changes/gotosocial-publisher/.openspec.yaml b/openspec/changes/gotosocial-publisher/.openspec.yaml new file mode 100644 index 0000000..f9b58f5 --- /dev/null +++ b/openspec/changes/gotosocial-publisher/.openspec.yaml @@ -0,0 +1,2 @@ +schema: spec-driven +created: 2026-09-13 \ No newline at end of file diff --git a/openspec/changes/gotosocial-publisher/design.md b/openspec/changes/gotosocial-publisher/design.md new file mode 100644 index 0000000..fb8b929 --- /dev/null +++ b/openspec/changes/gotosocial-publisher/design.md @@ -0,0 +1,209 @@ +## Дизайн + +### 1. Новый модуль `services/publisher/app/gotosocial.py` + +Клиент GoToSocial на httpx (уже есть в требованиях publisher), БЕЗ внешних библиотек. + +```python +# services/publisher/app/gotosocial.py +"""Клиент GoToSocial (Mastodon-совместимый REST API). + +GtS 0.22.x: POST /api/v1/statuses, POST /api/v2/media, GET /api/v1/accounts/verify_credentials, +DELETE /api/v1/statuses/{id}. Аутентификация: Bearer-токен пользователя (scope write). +""" +import httpx + +from .config import settings + +TIMEOUT = 25.0 +MAX_MEDIA = 6 +MAX_TEXT = 5000 # лимит GtS + + +class GoToSocialError(Exception): + def __init__(self, message: str, http_code: int = 502, detail: str = ""): + super().__init__(message) + self.message = message + self.http_code = http_code + self.detail = detail + + +def _client() -> httpx.Client: + if not settings.gt_social_url: + raise GoToSocialError("GT_SOCIAL_URL не задан", http_code=500) + if not settings.gt_social_token: + raise GoToSocialError("GT_SOCIAL_ACCESS_TOKEN не задан", http_code=500) + # GtS доступен напрямую (внутренняя сеть WG), прокси НЕ используется + return httpx.Client( + base_url=settings.gt_social_url.rstrip("/"), + headers={"Authorization": f"Bearer {settings.gt_social_token}"}, + timeout=TIMEOUT, + ) + + +def post_status(text: str, media_ids: list[str] | None = None, visibility: str = "public") -> str: + """Создаёт статус. Возвращает id статуса (str из JSON GtS).""" + if not text: + raise GoToSocialError("text пуст", http_code=400) + payload = { + "status": text[:MAX_TEXT], + "visibility": visibility, + # GtS принимает markdown: content-type заголовок (не обязательный, статус уходит plaintext) + } + if media_ids: + payload["media_ids"] = media_ids[:MAX_MEDIA] + with _client() as c: + r = c.post("/api/v1/statuses", json=payload) + if r.status_code not in (200, 201): + raise GoToSocialError(f"statuses: HTTP {r.status_code} {r.text[:200]}", + http_code=403 if r.status_code == 403 else 502) + return str(r.json()["id"]) + + +def upload_media(path: str) -> str: + """Загружает медиафайл, возвращает media_id.""" + if not path: + raise GoToSocialError("media path пуст", http_code=400) + try: + with _client() as c, open(path, "rb") as f: + r = c.post("/api/v2/media", files={"file": f}, data={"description": ""}) + except FileNotFoundError: + raise GoToSocialError(f"медиафайл не найден: {path}", http_code=400) + if r.status_code not in (200, 201): + raise GoToSocialError(f"media: HTTP {r.status_code} {r.text[:200]}", + http_code=403 if r.status_code == 403 else 502) + return str(r.json()["id"]) + + +def verify_credentials() -> dict | None: + """Проверка токена: GET /api/v1/accounts/verify_credentials. None при ошибке.""" + try: + with _client() as c: + r = c.get("/api/v1/accounts/verify_credentials") + if r.status_code == 200: + return r.json() + except Exception: + pass + return None + + +def delete_status(status_id: str) -> bool: + try: + with _client() as c: + r = c.delete(f"/api/v1/statuses/{status_id}") + return r.status_code == 200 + except Exception: + return False +``` + +- `upload_media` вызывается ДО `post_status`, чтобы собрать `media_ids`. +- Ошибки GtS — в `GoToSocialError` (по образцу TelegramError), main.py ловит и кладёт в `results`. + +### 2. Конфиг (config.py + .env + .env.example) + +```python +# config.py добавить поля Settings: +gt_social_url: str = "" +gt_social_token: str = "" +gt_social_visibility: str = "public" +# в __post_init__: +self.gt_social_url = self.gt_social_url or os.getenv("GT_SOCIAL_URL", "") +self.gt_social_token = self.gt_social_token or os.getenv("GT_SOCIAL_ACCESS_TOKEN", "") +self.gt_social_visibility = self.gt_social_visibility or os.getenv("GT_SOCIAL_VISIBILITY", "public") +``` + +.env (корень /opt/vesti, chmod 600) + .env.example: +``` +GT_SOCIAL_URL=https://social.dedinit.ru +GT_SOCIAL_ACCESS_TOKEN=<токен пользователя @vesti, scope write> +GT_SOCIAL_VISIBILITY=public +``` + +### 3. Определение fediverse-канала (channels.py) + +Канал считается fediverse, если `@x@y` (два @, есть домен) **или** начинается с `gt:`. +Для Telegram это `@channel` (один @, без точки в суффиксе). + +```python +def is_gotosocial(ch: str) -> bool: + return ch.startswith("gt:") or (ch.count("@") >= 2 and "." in ch.split("@")[2] if False else ch.count("@") >= 2) +``` + +Проще и надёжнее: **явный префикс** `gt:` — не пересекается с Telegram: +- канал `gt:@vesti@dedinit.ru` или просто `gt:default` (берём аккаунт из verify_credentials) +- либо конфиг отдельным списком `VESTI_GTS_CHANNELS` (MAY, для чистоты). + +Решение для MVP (просто и без путаницы): **новый конфиг-список `VESTI_GTS_CHANNELS`** +(по умолчанию пустой; `@vesti@dedinit.ru`). publisher при публикации дополнительно +шлёт во все каналы из этого списка. Это НЕ трогает Telegram-логику и формат `channels`. + +### 4. main.py — публикация в fediverse-каналы + +```python +from . import gotosocial + +def _publish_gotosocial(card: Card, ch: str) -> ChannelResult: + res = ChannelResult() + try: + media_ids = [] + if card.media and Path(card.media).exists(): + media_ids.append(gotosocial.upload_media(card.media)) + sid = gotosocial.post_status(text=card.text, media_ids=media_ids or None, + visibility=settings.gt_social_visibility) + res.message_id = int(sid) if str(sid).isdigit() else 0 + # GtS не отдаёт views столь просто; views оставляем 0 + except gotosocial.GoToSocialError as e: + res.error = e.message + return res +``` + +В `publish()`: +- после обработки Telegram-каналов — если `settings.gt_social_channels` не пуст, + для каждого `ch in settings.gt_social_channels` вызывать `_publish_gotosocial`. +- в `results` ключ — сам канал (`@vesti@dedinit.ru`), как для TG. +- `dry_run=True` — добавить те же каналы в эмуляцию (message_id=0), НЕ ходить наружу. +- `all_ok` учитывает и fediverse-каналы (если не dry_run). + +### 5. healthz + +```python +gt = gotosocial.verify_credentials() +"gotosocial": { + "url": settings.gt_social_url, + "account": gt.get("username") if gt else None, + "token_set": bool(settings.gt_social_token), + "account_ok": bool(gt), +} +``` + +### 6. Docker / requirements / .env + +- requirements.txt: изменений НЕ требуется (httpx уже есть). +- Dockerfile: без изменений (код копируется в образ COPY app ./app). +- docker-compose.yml publisher: env_file ../../.env уже подхватит новые переменные; + прокси для GtS не нужен (прямой доступ, проверен 200 из контейнера). + +### 7. Проверка (команды) + +```bash +# пересборка и рестарт publisher +docker compose -f services/publisher/docker-compose.yml up -d --build +# healthz: gotosocial блок +curl -s http://127.0.0.1:8410/healthz | python3 -m json.tool +# dry_run (не уходит наружу) +curl -s -X POST http://127.0.0.1:8410/api/v1/publish -H 'Content-Type: application/json' \ + -d '{"card":{"text":"тест gotosocial dry_run","direction":"llm"},"dry_run":true}' +# реальная публикация (согласовав с пользователем; потом удалить через gotosocial.delete_status) +curl -s -X POST http://127.0.0.1:8410/api/v1/publish -H 'Content-Type: application/json' \ + -d '{"card":{"text":"тест gotosocial publish","direction":"llm"}}' +# проверить в ленте: https://social.dedinit.ru/@vesti (или API /api/v1/accounts/{id}/statuses) +``` + +### 8. Токен + +- Создать OAuth-приложение «vesti-publisher» (POST /api/v1/apps, scopes: write write:media, + redirect_uris: urn:ietf:wg:oauth:2.0:oob) — создано 2026-09-13 (client_id=01B09QNDM9YQ2VQVSRXHCVAV6K); +- Токен для @vesti получен и записан в .env (GT_SOCIAL_ACCESS_TOKEN): вставлен в БД tokens + с user_id=id пользователя vesti из tables users (не accounts!), scope 'read write', access 48 симв. + Проверено API verify_credentials (username=vesti). +- healthz publisher: token_set=true, account=vesti, account_ok=true. \ No newline at end of file diff --git a/openspec/changes/gotosocial-publisher/proposal.md b/openspec/changes/gotosocial-publisher/proposal.md new file mode 100644 index 0000000..3706dc7 --- /dev/null +++ b/openspec/changes/gotosocial-publisher/proposal.md @@ -0,0 +1,89 @@ +## Why + +У VESTI уже работает публикация в Telegram через изолированный publisher-service +(`POST /api/v1/publish`, Docker-контейнер :8410, каналы `@dedinit_vesti`). Но у проекта есть +вторая цель — «ленты ботов (Telegram, VK, **fediverse**)» (openspec/config.yaml, домен: +медиа-конвейер → банк статей → дайджесты + ленты ботов). Fediverse-нода — собственный +GoToSocial на social.dedinit.ru (bigbox, Docker-контейнер, :8082), аккаунт **@vesti** +(`@vesti@dedinit.ru`) — обычный пользователь (admin=0), создан и подтверждён. + +Сейчас при approve статьи веб зовёт publisher-service, и тот умеет публиковать **только в +Telegram**. Чтобы публиковать одобренные карточки ещё и в свой GoToSocial-аккаунт +(от имени vesti), publisher-service нужно расширить fediverse-каналом. + +GtS 0.22.x реализует **Mastodon-совместимый REST API** (проверено: POST /api/v1/apps +работает, возвращает client_id/client_secret; у ноды есть действующие OAuth-приложения +Phanpy/Tusky/GoToSocial Settings). Значит, публикация в GtS идентична Mastodon: +`POST /api/v1/statuses` с Bearer-токеном пользователя и опциональными медиа +(`POST /api/v2/media`). Это стандарт, библиотек не требует — хватит httpx (уже есть в +publisher). + +## What Changes + +Расширяем **publisher-service** (FastAPI, :8410) мульти-канальной отправкой: +помимо Telegram-каналов (`@dedinit_vesti`) появляется fediverse-канал +`@vesti@dedinit.ru` — публикация в GoToSocial через Mastodon-совместимый API. + +- Новый модуль `services/publisher/app/gotosocial.py` — клиент GtS: + - `post_status(text, media_paths, visibility="public")` → `POST /api/v1/statuses` + (текст ≤5000, лимит GtS; разметка: markdown или plaintext); + - `upload_media(path)` → `POST /api/v2/media` (multipart) до публикации, + вернёт `media_id`; до 6 вложений (лимит GtS); + - `get_me()` → GET /api/v1/accounts/verify_credentials (healthz); + - `delete_status(id)` → DELETE /api/v1/statuses/{id} (для отмены тестовых); + - все GET запросы к GtS напрямую (без прокси — social.dedinit.ru доступен из + контейнера, проверено HTTP 200). +- Конфиг (config.py + .env): `GT_SOCIAL_URL=https://social.dedinit.ru`, + `GT_SOCIAL_ACCESS_TOKEN=<токен @vesti>` (Bearer, scope write), + `GT_SOCIAL_VISIBILITY=public` (MAY: private/unlisted). +- main.py: `channels`-список может содержать помимо `@...` (Telegram) ещё и + fediverse-канал (например `@vesti@dedinit.ru` или префикс `gt:`); publisher умеет + отправлять карточку в оба типа каналов, результаты — в `results` как обычно. + dry_run также эмулирует fediverse-публикацию (не уходит наружу). +- healthz: добавить `gotosocial` блок (url, аккаунт, токен задан?). +- Документация: .env.example, README publisher, STATUS.md. + +### Не меняется +- Telegram-путь (telegram.py, каналы @dedinit_vesti) — работает как раньше. +- Веб :8400 (approve) — публикация по-прежнему через HTTP POST /api/v1/publish; + веб-клиент не меняется (только конфиг каналов publisher). +- Формат карточки (текст+медиа) — один и тот же для всех каналов. + +## Capabilities + +### New Capabilities +- `gotosocial-publisher`: публикация карточки VESTI в собственный GoToSocial + (Mastodon API, Bearer-токен @vesti), медиа-аплоад, healthz, dry_run, delete. + +### Modified Capabilities +- `tg-publisher-service`: publisher-service теперь мульти-канальный (Telegram + + fediverse); конфиг каналов расширяется. +- `vesti-web`: без изменений кода (approve шлёт карточку publisher как раньше, + но та уходит и в GtS если канал в конфиге). + +## Impact + +- Затронутые сервисы/порты: publisher-service :8410 (Docker-контейнер vesti-publisher) — + только пересборка образа; GtS :8082/8443 (social.dedinit.ru) — читается по HTTPS. +- Файлы: + - новый: services/publisher/app/gotosocial.py + - изменён: services/publisher/app/main.py, config.py, channels.py, + services/publisher/Dockerfile (НЕ нужно — httpx уже есть), .env.example +- Данные: без миграций БД. +- Секреты: GT_SOCIAL_ACCESS_TOKEN (новый, в .env), URL GtS не секретен. +- Прокси: Telegram — как раньше через SOCKS5; GoToSocial — НАПРЯМУЮ (внутренняя сеть + WG → vps02 → bigbox; из контейнера publisher social.dedinit.ru отвечает 200). +- Rollback: убрать канал `@vesti@dedinit.ru` из VESTI_BOT_CHANNELS (или вернуть + конфиг) — Telegram-публикация не затронута; модуль gotosocial.py можно оставить + невостребованным. + +## Risks + +- Токен @vesti (scope write) — если протухнет, publisher вернёт 401/403 в results с + понятной ошибкой; healthz покажет `token_set=false`. +- GtS недоступен → HTTP 502 в результатах канала; остальные каналы продолжают работать + (fan-out не падает целиком — уже реализовано в main.py). +- Лимиты GtS: 5000 символов, 6 вложений, MIME ограничен — текст обрезается до 5000, + медиа до 6; неподдерживаемый тип → ошибка в результатах канала. +- Спам-защита GtS (rate limit) при частой публикации — допустимо (публикует человек, + не автопостинг). \ No newline at end of file diff --git a/openspec/changes/gotosocial-publisher/specs/gotosocial-publisher/spec.md b/openspec/changes/gotosocial-publisher/specs/gotosocial-publisher/spec.md new file mode 100644 index 0000000..99cddb6 --- /dev/null +++ b/openspec/changes/gotosocial-publisher/specs/gotosocial-publisher/spec.md @@ -0,0 +1,69 @@ +## ADDED Requirements + +### Requirement: Fediverse-канал в publisher-service + +* publisher-service MUST уметь публиковать карточку в GoToSocial через Mastodon-совместимый REST API при указании fediverse-канала в запросе или конфиге. +* publisher-service MUST отправлять текст статуса через `POST /api/v1/statuses` с заголовком `Authorization: Bearer `. +* publisher-service MUST обрезать текст до 5000 символов (лимит GtS) перед публикацией, сохраняя ссылку на оригинал последней строкой. +* publisher-service MAY поддерживать visibility из конфига (`GT_SOCIAL_VISIBILITY`, default `public`). +* publisher-service MUST включать fediverse-канал в fan-out: ошибка одного канала не должна отменять публикацию в остальные (поведение как у Telegram-каналов). +* publisher-service MUST в режиме `dry_run` эмулировать публикацию в fediverse без реального HTTP-запроса наружу. + +#### Scenario: Публикация карточки в GoToSocial + +GIVEN конфиг publisher содержит `GT_SOCIAL_URL=https://social.dedinit.ru` и `GT_SOCIAL_ACCESS_TOKEN=<токен @vesti>`, +WHEN веб (или curl) отправляет `POST /api/v1/publish` с `card: {text, media?}` и каналом `@vesti@dedinit.ru`, +THEN publisher вызывает `POST https://social.dedinit.ru/api/v1/statuses` с Bearer-токеном и текстом, +AND ответ содержит `results["@vesti@dedinit.ru"].message_id` = id созданного статуса +AND `ok=true`, если GtS вернул 200. + +#### Scenario: Публикация с медиа + +GIVEN у карточки есть `media` и файл существует, +WHEN publisher публикует в fediverse-канал, +THEN publisher сначала загружает файл через `POST /api/v2/media` (multipart), получает `media_id`, +AND передаёт массив `media_ids` в `POST /api/v1/statuses` (до 6 вложений). + +#### Scenario: dry_run не уходит наружу + +GIVEN `dry_run=true` в запросе, +WHEN publisher обрабатывает fediverse-канал, +THEN в `results["@vesti@dedinit.ru"]` возвращается пустой `ChannelResult()` (message_id=0) +AND реальный HTTP-запрос к social.dedinit.ru НЕ выполняется. + +#### Scenario: GtS недоступен + +GIVEN GoToSocial не отвечает (сеть/HTTP 5xx), +WHEN publisher публикует в fediverse-канал, +THEN в `results["@vesti@dedinit.ru"].error` — понятное сообщение об ошибке +AND остальные каналы (Telegram) публикуются как обычно +AND `ok=false` (но без общего 502, если хотя бы один канал успешен). + +### Requirement: Конфигурация fediverse + +* publisher-service MUST читать настройки GtS из env: `GT_SOCIAL_URL`, `GT_SOCIAL_ACCESS_TOKEN`, `GT_SOCIAL_VISIBILITY` (SECRETS в .env). +* publisher-service MUST добавлять в `/healthz` блок `gotosocial`: url, аккаунт (из verify_credentials), `token_set`. +* publisher-service MUST определять fediverse-канал по признаку: содержит `@` + точка (например `@vesti@dedinit.ru`) или префикс `gt:` — и НЕ трактовать его как Telegram (chat_id). + +#### Scenario: healthz показывает состояние GtS + +GIVEN publisher запущен с настроенным GT_SOCIAL_ACCESS_TOKEN, +WHEN GET /healthz, +THEN ответ содержит `gotosocial: {url, token_set: true, account: }` (или `account_ok: false` при сбое verify_credentials). + +#### Scenario: token не задан + +GIVEN `GT_SOCIAL_ACCESS_TOKEN` пуст, +WHEN publisher получает запрос публикации в fediverse-канал, +THEN результат канала содержит `error` «GT_SOCIAL_ACCESS_TOKEN не задан» (HTTP-статус общий 502 при пустых остальных), +AND healthz показывает `token_set: false`. + +### Requirement: Удаление статуса (поддержка отмены тестовых) + +* delete_status MUST удалять статус по id через `DELETE /api/v1/statuses/{id}` (для отмены тестовых публикаций/очистки канала); это SHOULD-часть publisher-service. + +#### Scenario: Удаление статуса + +GIVEN существует статус с id=123 в GtS, +WHEN вызывается `gotosocial.delete_status(123)`, +THEN GtS возвращает 200 и статус исчезает из ленты (проверяется GET /api/v1/statuses/123 → 404). \ No newline at end of file diff --git a/openspec/changes/gotosocial-publisher/tasks.md b/openspec/changes/gotosocial-publisher/tasks.md new file mode 100644 index 0000000..49e64fc --- /dev/null +++ b/openspec/changes/gotosocial-publisher/tasks.md @@ -0,0 +1,45 @@ +## 1. Модуль GoToSocial (services/publisher/app/gotosocial.py) + +- [x] 1.1 Создать `gotosocial.py`: `_client()`, `post_status()`, `upload_media()`, `verify_credentials()`, `delete_status()`, `GoToSocialError` +- [x] 1.2 Проверка: `python -c "from app.gotosocial import post_status, verify_credentials"` (в venv) — импорт без ошибок + +## 2. Конфиг + +- [x] 2.1 `config.py`: поля `gt_social_url`, `gt_social_token`, `gt_social_visibility`, `gt_social_channels` + чтение из env +- [x] 2.2 `.env` (корень): добавить `GT_SOCIAL_URL=https://social.dedinit.ru`, `GT_SOCIAL_ACCESS_TOKEN=<токен vesti>`, `GT_SOCIAL_VISIBILITY=public`, `VESTI_GTS_CHANNELS=@vesti@dedinit.ru` +- [x] 2.3 `.env.example` актуализирован (те же имена, значения-заглушки) + +## 3. Логика каналов (channels.py) + +- [x] 3.1 Добавить `is_gotosocial(ch)` и чтение `VESTI_GTS_CHANNELS` (feediverse-каналы отдельно от Telegram) +- [x] 3.2 Проверка: функция отличает `@dedinit_vesti` (TG) от `@vesti@dedinit.ru` (GtS); `gt:`-префикс уходит ТОЛЬКО в fediverse + +## 4. main.py + +- [x] 4.1 `_publish_gotosocial(card, ch)`: upload_media → post_status → ChannelResult (status_id) +- [x] 4.2 В `publish()`: после Telegram-каналов обработать fediverse-каналы (settings.gt_social_channels) в том же цикле; `all_ok` учитывает их +- [x] 4.3 dry_run: эмулирует fediverse-каналы (message_id=0), без реальных HTTP-запросов +- [x] 4.4 healthz: блок `gotosocial` (url, account, token_set, account_ok) +- [x] 4.5 Проверка: `curl -s http://127.0.0.1:8410/healthz` → json c gotosocial + +## 5. Сборка и функциональный тест + +- [x] 5.1 `docker compose -f services/publisher/docker-compose.yml up -d --build` — контейнер Up +- [x] 5.2 dry_run с fediverse-каналом: results содержит `@vesti@dedinit.ru`, message_id=0, HTTP 200 +- [x] 5.3 healthz содержит gotosocial c `token_set=true`, account=vesti, account_ok=true +- [x] 5.4 Регресс: публикация чисто в Telegram (dry_run) работает как раньше + +## 6. Токен + реальный тест + +- [x] 6.1 Создать аккаунт **vesti** на GtS (admin account create, confirmed, approved) — 2026-09-13 +- [x] 6.2 Создать OAuth-приложение `vesti-publisher` (POST /api/v1/apps, scope `write write:media`) — client_id=01B09QNDM9YQ2VQVSRXHCVAV6K +- [x] 6.3 Токен vesti: вставлен в БД `tokens` (user_id из `users`, scope `read write`, access 48 симв.), записан в .env → healthz `account_ok=true` +- [x] 6.4 Реальный тест: публикация текста, публикация с медиа (1 attachment), delete — всё HTTP 200, статусы проверены и удалены + +## 7. Документация и git + +- [ ] 7.1 STATUS.md: раздел «Fediverse-публикация (GoToSocial)» +- [ ] 7.2 WALKTHROUGH.md/PRD.md: фича отмечена +- [ ] 7.3 git commit + push на gitverse +- [ ] 7.4 openspec archive (после подтверждения пользователя) +- [ ] 7.5 backup.sh (обязателен после изменений) \ No newline at end of file diff --git a/services/publisher/app/channels.py b/services/publisher/app/channels.py index 88b8250..d976d6b 100644 --- a/services/publisher/app/channels.py +++ b/services/publisher/app/channels.py @@ -1,12 +1,32 @@ -# Разбор списка каналов из конфигурации. +# Разбор списков каналов из конфигурации. +# Два независимых списка: +# VESTI_BOT_CHANNELS — Telegram-каналы (@dedinit_vesti, ...) +# VESTI_GTS_CHANNELS — fediverse-каналы GoToSocial (@kpa39l@dedinit.ru, ...) +# Деление по типу канала: алгоритм НЕ угадывает по виду строки (надёжнее конфиг). from .config import settings def resolve_channels(channels: list[str] | None) -> list[str]: - """Итоговый список каналов для публикации. + """Итоговый список Telegram-каналов для публикации. channels из запроса (MAY переопределять) -> иначе VESTI_BOT_CHANNELS (конфиг). + Каналы с префиксом 'gt:' — это fediverse (GoToSocial), НЕ Telegram: отбрасываются. """ if channels: - return list(dict.fromkeys([c.strip() for c in channels if c.strip()])) - return list(settings.channels) \ No newline at end of file + tg = [c.strip() for c in channels if c.strip() and not c.strip().startswith("gt:")] + return list(dict.fromkeys(tg)) + return list(settings.channels) + + +def resolve_gotosocial_channels(channels: list[str] | None) -> list[str]: + """Итоговый список GoToSocial-каналов (fediverse). + + В запросе fediverse-каналы передаются ПРЕФИКСОМ gt: (например gt:@kpa39l@dedinit.ru), + чтобы не смешивать с Telegram-каналами. Если в запросе их нет — берётся + VESTI_GTS_CHANNELS из конфига. + """ + if channels: + gts = [c[3:].strip() for c in channels if c.strip().startswith("gt:")] + if gts: + return list(dict.fromkeys(gts)) + return list(settings.gt_social_channels) \ No newline at end of file diff --git a/services/publisher/app/config.py b/services/publisher/app/config.py index 7089312..25051ea 100644 --- a/services/publisher/app/config.py +++ b/services/publisher/app/config.py @@ -32,6 +32,12 @@ class Settings: channels: list[str] = None # type: ignore port: int = 8410 api_base: str = "https://api.telegram.org" + # GoToSocial (fediverse, Mastodon-совместимый API) + gt_social_url: str = "" + gt_social_token: str = "" + gt_social_visibility: str = "public" + gt_social_format: str = "markdown" + gt_social_channels: list[str] = None # type: ignore def __post_init__(self): if self.channels is None: @@ -39,6 +45,12 @@ class Settings: self.bot_token = self.bot_token or os.getenv("VESTI_BOT_TOKEN", "") self.tg_proxy = self.tg_proxy or os.getenv("TG_PROXY", "socks5://127.0.0.1:1080") self.port = self.port or int(os.getenv("PUBLISHER_PORT", "8410")) + self.gt_social_url = self.gt_social_url or os.getenv("GT_SOCIAL_URL", "") + self.gt_social_token = self.gt_social_token or os.getenv("GT_SOCIAL_ACCESS_TOKEN", "") + self.gt_social_visibility = self.gt_social_visibility or os.getenv("GT_SOCIAL_VISIBILITY", "public") + self.gt_social_format = self.gt_social_format or os.getenv("GT_SOCIAL_FORMAT", "markdown") + if self.gt_social_channels is None: + self.gt_social_channels = _channels_list(os.getenv("VESTI_GTS_CHANNELS", "")) settings = Settings() \ No newline at end of file diff --git a/services/publisher/app/gotosocial.py b/services/publisher/app/gotosocial.py new file mode 100644 index 0000000..a744614 --- /dev/null +++ b/services/publisher/app/gotosocial.py @@ -0,0 +1,100 @@ +# Клиент GoToSocial (Mastodon-совместимый REST API). +# GtS 0.22.x: POST /api/v1/statuses, POST /api/v2/media, GET /api/v1/accounts/verify_credentials, +# DELETE /api/v1/statuses/{id}. Аутентификация: Bearer-токен пользователя (scope write). +# GoToSocial доступен напрямую (внутренняя сеть WG → vps02 → bigbox), прокси НЕ используется +# (в отличие от Telegram, который из РФ доступен только через SOCKS5). +import httpx + +from .config import settings + +TIMEOUT = 25.0 +MAX_MEDIA = 6 # лимит GtS на вложения +MAX_TEXT = 5000 # лимит GtS на длину статуса + + +class GoToSocialError(Exception): + """Ошибка GtS API / сети. Код для HTTP-ответа сервиса (по образцу TelegramError).""" + + def __init__(self, message: str, http_code: int = 502, detail: str = ""): + super().__init__(message) + self.message = message + self.http_code = http_code + self.detail = detail + + +def _client() -> httpx.Client: + """Клиент с Bearer-токеном. Проверяет обязательные настройки ДО запроса.""" + if not settings.gt_social_url: + raise GoToSocialError("GT_SOCIAL_URL не задан", http_code=500) + if not settings.gt_social_token: + raise GoToSocialError("GT_SOCIAL_ACCESS_TOKEN не задан", http_code=500) + return httpx.Client( + base_url=settings.gt_social_url.rstrip("/"), + headers={"Authorization": f"Bearer {settings.gt_social_token}"}, + timeout=TIMEOUT, + ) + + +def _handle(r: httpx.Response, what: str) -> None: + if r.status_code not in (200, 201): + raise GoToSocialError( + f"{what}: HTTP {r.status_code} {r.text[:200]}", + http_code=403 if r.status_code == 403 else 502, + detail=r.text[:200], + ) + + +def post_status(text: str, media_ids: list[str] | None = None, visibility: str = "public") -> str: + """Создаёт статус. Возвращает id статуса (str из JSON GtS). + + format=markdown — GtS поддерживает markdown-разметку статусов + (см. /api/v1/instance: supported_mime_types включает text/markdown). + """ + if not text: + raise GoToSocialError("text пуст", http_code=400) + payload = { + "status": text[:MAX_TEXT], + "visibility": visibility, + "format": settings.gt_social_format, # "markdown" | "plain" + } + if media_ids: + payload["media_ids"] = media_ids[:MAX_MEDIA] + with _client() as c: + r = c.post("/api/v1/statuses", json=payload) + _handle(r, "statuses") + return str(r.json()["id"]) + + +def upload_media(path: str) -> str: + """Загружает медиафайл (multipart), возвращает media_id.""" + if not path: + raise GoToSocialError("media path пуст", http_code=400) + try: + with _client() as c, open(path, "rb") as f: + r = c.post("/api/v2/media", files={"file": f}, data={"description": ""}) + except FileNotFoundError: + raise GoToSocialError(f"медиафайл не найден: {path}", http_code=400) + _handle(r, "media") + return str(r.json()["id"]) + + +def verify_credentials() -> dict | None: + """Проверка токена: GET /api/v1/accounts/verify_credentials. None при ошибке.""" + try: + with _client() as c: + r = c.get("/api/v1/accounts/verify_credentials") + if r.status_code == 200: + return r.json() + except Exception: + pass + return None + + +def delete_status(status_id: str) -> bool: + """Удаляет статус. True если GtS вернул 200 (или 404 — уже удалён).""" + try: + with _client() as c: + r = c.delete(f"/api/v1/statuses/{status_id}") + return r.status_code in (200, 404) + except Exception: + return False \ No newline at end of file diff --git a/services/publisher/app/main.py b/services/publisher/app/main.py index 5b3d52d..4337192 100644 --- a/services/publisher/app/main.py +++ b/services/publisher/app/main.py @@ -1,15 +1,18 @@ -# publisher-service — изолированный FastAPI-микросервис публикации в Telegram. +# publisher-service — изолированный FastAPI-микросервис публикации карточек. # POST /api/v1/publish — публикация карточки в канал(ы). GET /healthz — статус. -# Единственная точка, знающая токен бота, прокси и каналы. +# Каналы двух типов: +# Telegram — из VESTI_BOT_CHANNELS / request.channels (@dedinit_vesti, ...) +# GoToSocial — из VESTI_GTS_CHANNELS (fediverse, @kpa39l@dedinit.ru, ...) +# Единственная точка, знающая токены, прокси и каналы. import os from pathlib import Path from fastapi import FastAPI, HTTPException from pydantic import BaseModel, Field -from .channels import resolve_channels +from .channels import resolve_channels, resolve_gotosocial_channels from .config import settings -from . import telegram +from . import gotosocial, telegram app = FastAPI(title="VESTI Publisher Service", version="0.1.0") @@ -35,6 +38,7 @@ class ChannelResult(BaseModel): media_message_id: int | None = None views: int = 0 error: str | None = None + status_id: str | None = None # GoToSocial: id статуса (ULID-строка), если != message_id class PublishResponse(BaseModel): @@ -45,37 +49,71 @@ class PublishResponse(BaseModel): @app.get("/healthz") def healthz(): - """Статус сервиса: бот, прокси, каналы, токен.""" + """Статус сервиса: бот, прокси, каналы, токены, GoToSocial.""" me_name = "" try: me = telegram.get_me() me_name = me.get("username", "") except Exception: me_name = "" + gt = gotosocial.verify_credentials() return { "status": "ok", "bot": me_name or ("не проверен" if settings.bot_token else "нет токена"), "token_set": bool(settings.bot_token), "proxy": settings.tg_proxy, "channels": settings.channels, + # GoToSocial (fediverse) + "gotosocial": { + "url": settings.gt_social_url, + "account": gt.get("username") if gt else None, + "token_set": bool(settings.gt_social_token), + "account_ok": bool(gt), + "channels": settings.gt_social_channels, + }, } +def _publish_gotosocial(card: Card, ch: str) -> ChannelResult: + """Публикует карточку в GoToSocial (Mastodon API). Возвращает ChannelResult. + + Медиа загружается ДО создания статуса (нужен media_id); ошибка одного + канала не роняет остальные — fan-out как у Telegram. + """ + res = ChannelResult() + try: + media_ids: list[str] = [] + if card.media and Path(card.media).exists(): + media_ids.append(gotosocial.upload_media(card.media)) + sid = gotosocial.post_status( + text=card.text, + media_ids=media_ids or None, + visibility=settings.gt_social_visibility, + ) + res.status_id = str(sid) + res.message_id = int(sid) if str(sid).isdigit() else 0 + except gotosocial.GoToSocialError as e: + res.error = e.message + return res + + @app.post("/api/v1/publish", response_model=PublishResponse) def publish(req: PublishRequest): """Публикует «богатый репост» в каналы. Возвращает message_id по каждому каналу. Порядок: медиа первым (sendPhoto/sendVideo) → текст вторым (sendMessage с link_preview_options disabled, чтобы не было предпросмотра ссылки). + Для GoToSocial: отдельный список каналов (VESTI_GTS_CHANNELS или gt: из запроса). """ channels = resolve_channels(req.channels) - if not channels: + gt_channels = resolve_gotosocial_channels(req.channels) + if not channels and not gt_channels: raise HTTPException(status_code=400, detail="Нет каналов для публикации") - # dry_run — тестовый режим: НЕ уходит в Telegram, возвращает эмуляцию результата + # dry_run — тестовый режим: НЕ уходит наружу (ни в Telegram, ни в GtS) if req.dry_run: results: dict[str, ChannelResult] = { - ch: ChannelResult() for ch in channels + ch: ChannelResult() for ch in [*channels, *gt_channels] } return PublishResponse(ok=True, results=results, dry_run=True) @@ -101,6 +139,12 @@ def publish(req: PublishRequest): all_ok = False results[ch] = res + for ch in gt_channels: + res = _publish_gotosocial(card=req.card, ch=ch) + if res.error: + all_ok = False + results[ch] = res + if not all_ok: # одна ошибка не роняет остальные; статус 502 если все упали if all(r.error for r in results.values()):