mirror of
https://gitverse.ru/kpa39l/vesti.git
synced 2026-09-29 09:55:03 +00:00
fediverse: публикация в GoToSocial (@vesti@dedinit.ru) из publisher-service
- gotosocial.py: клиент GtS (httpx): post_status (markdown), upload_media v2, verify_credentials, delete_status - config.py: gt_social_url/token/visibility/format/channels - channels.py: fediverse-каналы (VESTI_GTS_CHANNELS, префикс gt:), gt: уходит только в fediverse - main.py: _publish_gotosocial + healthz-блок gotosocial + dry_run; status_id (ULID) в ответе - .env.example: GT_SOCIAL_* и VESTI_GTS_CHANNELS - STATUS.md/WALKTHROUGH.md: fediverse-раздел, хронология - openspec: change gotosocial-publisher (proposal/design/tasks/specs), validate чисто
This commit is contained in:
@@ -10,6 +10,17 @@ TG_SESSION_DIR=/opt/vesti/telegram
|
|||||||
VESTI_BOT_TOKEN=your_bot_token_here
|
VESTI_BOT_TOKEN=your_bot_token_here
|
||||||
# Канал публикации (шаблон dedinit_vesti_<direction>_<lang>_bot)
|
# Канал публикации (шаблон dedinit_vesti_<direction>_<lang>_bot)
|
||||||
VESTI_BOT_CHANNEL=@dedinit_vesti_linux_ru_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_USER=admin
|
||||||
ADMIN_PASSWORD=change_me
|
ADMIN_PASSWORD=change_me
|
||||||
|
|||||||
@@ -1,15 +1,21 @@
|
|||||||
# VESTI — Статус
|
# VESTI — Статус
|
||||||
|
|
||||||
Обновлено: 2026-09-13 (publisher снова на Docker; systemd1 ожил)
|
Обновлено: 2026-09-13 (fediverse-публикация в GoToSocial @vesti)
|
||||||
<!--
|
<!--
|
||||||
История обновлений:
|
История обновлений:
|
||||||
2026-09-13 — publisher: Docker-контейнер (основной способ); user-юнит disabled (костыль убран)
|
2026-09-13 — fediverse: publisher умеет постить в GoToSocial (@vesti@dedinit.ru)
|
||||||
|
2026-09-13 — publisher снова на Docker; systemd1 ожил
|
||||||
2026-09-12 — publisher → systemd user-юнит (костыль, пока сломан systemd1)
|
2026-09-12 — publisher → systemd user-юнит (костыль, пока сломан systemd1)
|
||||||
2026-09-11 — веб-интерфейс: 4 openspec change (CDN/htmx, markdown, даты, фильтр без CDN)
|
2026-09-11 — веб-интерфейс: 4 openspec change (CDN/htmx, markdown, даты, фильтр без CDN)
|
||||||
-->
|
-->
|
||||||
|
|
||||||
## Текущее состояние
|
## Текущее состояние
|
||||||
- Базовый конвейер (прототип) работает: краулер (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/<file>, файл media/media/<file>; с 2026-09-12 краулер качает в media/<file>, для старых — fallback в make_card). Скрипт оставлен для повторного дозаполнения.
|
- **Бэкфилл @dedinit ЗАВЕРШЁН** (2026-09-10): crawler/backfill_dedinit.py, fetched=1040, max_post_id=1092; в БД 846 постов is_own=1, медиа в media/media/ (исторический баг пути: БД media/<file>, файл media/media/<file>; с 2026-09-12 краулер качает в media/<file>, для старых — fallback в make_card). Скрипт оставлен для повторного дозаполнения.
|
||||||
- **РЕАЛЬНАЯ ПУБЛИКАЦИЯ РАБОТАЕТ**: бот-контроллер @dedinit_controller_bot (id 7765665742) → @dedinit_vesti. Токен в .env. Проверено: тестовые посты (message_id 2–4).
|
- **РЕАЛЬНАЯ ПУБЛИКАЦИЯ РАБОТАЕТ**: бот-контроллер @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 конфликтовал бы).
|
- **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), текст с <b>& доставлен как есть, без превью; тестовые сообщения удалены (delete_message ok). Канал чист
|
- [x] **Проверено на реальном канале**: publish ушёл в @dedinit_vesti (message_id 6), текст с <b>& доставлен как есть, без превью; тестовые сообщения удалены (delete_message ok). Канал чист
|
||||||
- [x] **publisher временно работает как локальный uvicorn** (127.0.0.1:8410, PID 807769) — Docker недоступен из-за зависшего systemd1 на хосте (внешняя проблема, не vesti)
|
- [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)
|
## Сделано ранее (own-content-hub)
|
||||||
- [x] Миграция БД (migrate_own.py): is_own, is_own_canonical, sources.own, published.distributed_dirs
|
- [x] Миграция БД (migrate_own.py): is_own, is_own_canonical, sources.own, published.distributed_dirs
|
||||||
- [x] sources.yaml: +dedinit (own: true), синк
|
- [x] sources.yaml: +dedinit (own: true), синк
|
||||||
@@ -74,7 +91,7 @@ vesti-web (:8400) ──HTTP POST──▶ publisher-service (:8410) ──Bot A
|
|||||||
- [ ] Проверить результат классификации (в БД: direction заполнены) и выборочно качество на своих постах
|
- [ ] Проверить результат классификации (в БД: direction заполнены) и выборочно качество на своих постах
|
||||||
- [ ] **rich-repost-card в проде**: после оживления systemd1 на хосте пересобрать Docker-контейнер publisher (docker compose -f services/publisher/docker-compose.yml up -d --build), перезапустить веб (подхватит шаблон + comment), проверить approve поста с медиа (send_photo/send_video)
|
- [ ] **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 после подтверждения), архивировать
|
- [ ] **Закрыть 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 (после подтверждения)
|
- [ ] 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 \
|
curl -s -X POST http://127.0.0.1:8410/api/v1/publish \
|
||||||
-H 'Content-Type: application/json' \
|
-H 'Content-Type: application/json' \
|
||||||
-d '{"card":{"text":"<b>Тест</b>","direction":"linux","lang":"ru"}}'
|
-d '{"card":{"text":"<b>Тест</b>","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 — не использовать, порт занят)
|
# (старый user-юнит vesti-publisher.service disabled — не использовать, порт занят)
|
||||||
# веб (пароль из .env: VESTI_WEB_PASSWORD or ADMIN_PASSWORD):
|
# веб (пароль из .env: VESTI_WEB_PASSWORD or ADMIN_PASSWORD):
|
||||||
# systemd-юнит: /etc/systemd/system/vesti-web.service (после daemon-reload)
|
# systemd-юнит: /etc/systemd/system/vesti-web.service (после daemon-reload)
|
||||||
|
|||||||
@@ -439,3 +439,41 @@ sudo systemctl restart vesti-web # active; login → 200, candidates/published
|
|||||||
3. Переписывание новостей от себя (LLM-пересказ) + пайплайн «выбранные → дообогащение → публикация».
|
3. Переписывание новостей от себя (LLM-пересказ) + пайплайн «выбранные → дообогащение → публикация».
|
||||||
4. Извлечение источников из постов + сервис поиска в интернете (только одобренные).
|
4. Извлечение источников из постов + сервис поиска в интернете (только одобренные).
|
||||||
5. Страница управления источниками (CRUD + фильтр по типу).
|
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 <rand>` → 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 '<rand>'
|
||||||
|
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-каналы по направлениям).
|
||||||
|
|||||||
@@ -0,0 +1,2 @@
|
|||||||
|
schema: spec-driven
|
||||||
|
created: 2026-09-13
|
||||||
@@ -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.
|
||||||
@@ -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) при частой публикации — допустимо (публикует человек,
|
||||||
|
не автопостинг).
|
||||||
@@ -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 <GT_SOCIAL_ACCESS_TOKEN>`.
|
||||||
|
* 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: <username>}` (или `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).
|
||||||
@@ -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 (обязателен после изменений)
|
||||||
@@ -1,12 +1,32 @@
|
|||||||
# Разбор списка каналов из конфигурации.
|
# Разбор списков каналов из конфигурации.
|
||||||
|
# Два независимых списка:
|
||||||
|
# VESTI_BOT_CHANNELS — Telegram-каналы (@dedinit_vesti, ...)
|
||||||
|
# VESTI_GTS_CHANNELS — fediverse-каналы GoToSocial (@kpa39l@dedinit.ru, ...)
|
||||||
|
# Деление по типу канала: алгоритм НЕ угадывает по виду строки (надёжнее конфиг).
|
||||||
from .config import settings
|
from .config import settings
|
||||||
|
|
||||||
|
|
||||||
def resolve_channels(channels: list[str] | None) -> list[str]:
|
def resolve_channels(channels: list[str] | None) -> list[str]:
|
||||||
"""Итоговый список каналов для публикации.
|
"""Итоговый список Telegram-каналов для публикации.
|
||||||
|
|
||||||
channels из запроса (MAY переопределять) -> иначе VESTI_BOT_CHANNELS (конфиг).
|
channels из запроса (MAY переопределять) -> иначе VESTI_BOT_CHANNELS (конфиг).
|
||||||
|
Каналы с префиксом 'gt:' — это fediverse (GoToSocial), НЕ Telegram: отбрасываются.
|
||||||
"""
|
"""
|
||||||
if channels:
|
if channels:
|
||||||
return list(dict.fromkeys([c.strip() for c in channels if c.strip()]))
|
tg = [c.strip() for c in channels if c.strip() and not c.strip().startswith("gt:")]
|
||||||
return list(settings.channels)
|
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)
|
||||||
@@ -32,6 +32,12 @@ class Settings:
|
|||||||
channels: list[str] = None # type: ignore
|
channels: list[str] = None # type: ignore
|
||||||
port: int = 8410
|
port: int = 8410
|
||||||
api_base: str = "https://api.telegram.org"
|
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):
|
def __post_init__(self):
|
||||||
if self.channels is None:
|
if self.channels is None:
|
||||||
@@ -39,6 +45,12 @@ class Settings:
|
|||||||
self.bot_token = self.bot_token or os.getenv("VESTI_BOT_TOKEN", "")
|
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.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.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()
|
settings = Settings()
|
||||||
@@ -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
|
||||||
@@ -1,15 +1,18 @@
|
|||||||
# publisher-service — изолированный FastAPI-микросервис публикации в Telegram.
|
# publisher-service — изолированный FastAPI-микросервис публикации карточек.
|
||||||
# POST /api/v1/publish — публикация карточки в канал(ы). GET /healthz — статус.
|
# POST /api/v1/publish — публикация карточки в канал(ы). GET /healthz — статус.
|
||||||
# Единственная точка, знающая токен бота, прокси и каналы.
|
# Каналы двух типов:
|
||||||
|
# Telegram — из VESTI_BOT_CHANNELS / request.channels (@dedinit_vesti, ...)
|
||||||
|
# GoToSocial — из VESTI_GTS_CHANNELS (fediverse, @kpa39l@dedinit.ru, ...)
|
||||||
|
# Единственная точка, знающая токены, прокси и каналы.
|
||||||
import os
|
import os
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
from fastapi import FastAPI, HTTPException
|
from fastapi import FastAPI, HTTPException
|
||||||
from pydantic import BaseModel, Field
|
from pydantic import BaseModel, Field
|
||||||
|
|
||||||
from .channels import resolve_channels
|
from .channels import resolve_channels, resolve_gotosocial_channels
|
||||||
from .config import settings
|
from .config import settings
|
||||||
from . import telegram
|
from . import gotosocial, telegram
|
||||||
|
|
||||||
app = FastAPI(title="VESTI Publisher Service", version="0.1.0")
|
app = FastAPI(title="VESTI Publisher Service", version="0.1.0")
|
||||||
|
|
||||||
@@ -35,6 +38,7 @@ class ChannelResult(BaseModel):
|
|||||||
media_message_id: int | None = None
|
media_message_id: int | None = None
|
||||||
views: int = 0
|
views: int = 0
|
||||||
error: str | None = None
|
error: str | None = None
|
||||||
|
status_id: str | None = None # GoToSocial: id статуса (ULID-строка), если != message_id
|
||||||
|
|
||||||
|
|
||||||
class PublishResponse(BaseModel):
|
class PublishResponse(BaseModel):
|
||||||
@@ -45,37 +49,71 @@ class PublishResponse(BaseModel):
|
|||||||
|
|
||||||
@app.get("/healthz")
|
@app.get("/healthz")
|
||||||
def healthz():
|
def healthz():
|
||||||
"""Статус сервиса: бот, прокси, каналы, токен."""
|
"""Статус сервиса: бот, прокси, каналы, токены, GoToSocial."""
|
||||||
me_name = ""
|
me_name = ""
|
||||||
try:
|
try:
|
||||||
me = telegram.get_me()
|
me = telegram.get_me()
|
||||||
me_name = me.get("username", "")
|
me_name = me.get("username", "")
|
||||||
except Exception:
|
except Exception:
|
||||||
me_name = ""
|
me_name = ""
|
||||||
|
gt = gotosocial.verify_credentials()
|
||||||
return {
|
return {
|
||||||
"status": "ok",
|
"status": "ok",
|
||||||
"bot": me_name or ("не проверен" if settings.bot_token else "нет токена"),
|
"bot": me_name or ("не проверен" if settings.bot_token else "нет токена"),
|
||||||
"token_set": bool(settings.bot_token),
|
"token_set": bool(settings.bot_token),
|
||||||
"proxy": settings.tg_proxy,
|
"proxy": settings.tg_proxy,
|
||||||
"channels": settings.channels,
|
"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)
|
@app.post("/api/v1/publish", response_model=PublishResponse)
|
||||||
def publish(req: PublishRequest):
|
def publish(req: PublishRequest):
|
||||||
"""Публикует «богатый репост» в каналы. Возвращает message_id по каждому каналу.
|
"""Публикует «богатый репост» в каналы. Возвращает message_id по каждому каналу.
|
||||||
|
|
||||||
Порядок: медиа первым (sendPhoto/sendVideo) → текст вторым (sendMessage с
|
Порядок: медиа первым (sendPhoto/sendVideo) → текст вторым (sendMessage с
|
||||||
link_preview_options disabled, чтобы не было предпросмотра ссылки).
|
link_preview_options disabled, чтобы не было предпросмотра ссылки).
|
||||||
|
Для GoToSocial: отдельный список каналов (VESTI_GTS_CHANNELS или gt: из запроса).
|
||||||
"""
|
"""
|
||||||
channels = resolve_channels(req.channels)
|
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="Нет каналов для публикации")
|
raise HTTPException(status_code=400, detail="Нет каналов для публикации")
|
||||||
|
|
||||||
# dry_run — тестовый режим: НЕ уходит в Telegram, возвращает эмуляцию результата
|
# dry_run — тестовый режим: НЕ уходит наружу (ни в Telegram, ни в GtS)
|
||||||
if req.dry_run:
|
if req.dry_run:
|
||||||
results: dict[str, ChannelResult] = {
|
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)
|
return PublishResponse(ok=True, results=results, dry_run=True)
|
||||||
|
|
||||||
@@ -101,6 +139,12 @@ def publish(req: PublishRequest):
|
|||||||
all_ok = False
|
all_ok = False
|
||||||
results[ch] = res
|
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:
|
if not all_ok:
|
||||||
# одна ошибка не роняет остальные; статус 502 если все упали
|
# одна ошибка не роняет остальные; статус 502 если все упали
|
||||||
if all(r.error for r in results.values()):
|
if all(r.error for r in results.values()):
|
||||||
|
|||||||
Reference in New Issue
Block a user