## Дизайн ### 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.