openspec: архивирован change gotosocial-publisher (2026-09-13), delta влита в specs/gotosocial-publisher

This commit is contained in:
kpa39l
2026-09-13 20:27:41 +00:00
parent bf176cc02b
commit 6b955aa143
6 changed files with 79 additions and 5 deletions
@@ -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
- [x] 7.1 STATUS.md: раздел «Fediverse-публикация (GoToSocial)»
- [x] 7.2 WALKTHROUGH.md/PRD.md: фича отмечена
- [x] 7.3 git commit + push на gitverse
- [x] 7.4 openspec archive (после подтверждения пользователя)
- [x] 7.5 backup.sh (обязателен после изменений)