Files
vesti/openspec/changes/archive/2026-09-13-gotosocial-publisher/design.md
T

9.1 KiB
Raw Blame History

Дизайн

1. Новый модуль services/publisher/app/gotosocial.py

Клиент GoToSocial на httpx (уже есть в требованиях publisher), БЕЗ внешних библиотек.

# 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)

# 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 (один @, без точки в суффиксе).

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-каналы

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

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. Проверка (команды)

# пересборка и рестарт 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.