mirror of
https://gitverse.ru/kpa39l/vesti.git
synced 2026-09-29 09:55:03 +00:00
209 lines
9.1 KiB
Markdown
209 lines
9.1 KiB
Markdown
## Дизайн
|
||
|
||
### 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. |