Files
vesti/openspec/changes/gotosocial-publisher/design.md
T
kpa39l bf176cc02b 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 чисто
2026-09-13 20:19:52 +00:00

209 lines
9.1 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
## Дизайн
### 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.