# Клиент 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