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 чисто
This commit is contained in:
kpa39l
2026-09-13 20:19:52 +00:00
parent 71fd48aa06
commit bf176cc02b
12 changed files with 677 additions and 16 deletions
+52 -8
View File
@@ -1,15 +1,18 @@
# publisher-service — изолированный FastAPI-микросервис публикации в Telegram.
# publisher-service — изолированный FastAPI-микросервис публикации карточек.
# POST /api/v1/publish — публикация карточки в канал(ы). GET /healthz — статус.
# Единственная точка, знающая токен бота, прокси и каналы.
# Каналы двух типов:
# Telegram — из VESTI_BOT_CHANNELS / request.channels (@dedinit_vesti, ...)
# GoToSocial — из VESTI_GTS_CHANNELS (fediverse, @kpa39l@dedinit.ru, ...)
# Единственная точка, знающая токены, прокси и каналы.
import os
from pathlib import Path
from fastapi import FastAPI, HTTPException
from pydantic import BaseModel, Field
from .channels import resolve_channels
from .channels import resolve_channels, resolve_gotosocial_channels
from .config import settings
from . import telegram
from . import gotosocial, telegram
app = FastAPI(title="VESTI Publisher Service", version="0.1.0")
@@ -35,6 +38,7 @@ class ChannelResult(BaseModel):
media_message_id: int | None = None
views: int = 0
error: str | None = None
status_id: str | None = None # GoToSocial: id статуса (ULID-строка), если != message_id
class PublishResponse(BaseModel):
@@ -45,37 +49,71 @@ class PublishResponse(BaseModel):
@app.get("/healthz")
def healthz():
"""Статус сервиса: бот, прокси, каналы, токен."""
"""Статус сервиса: бот, прокси, каналы, токены, GoToSocial."""
me_name = ""
try:
me = telegram.get_me()
me_name = me.get("username", "")
except Exception:
me_name = ""
gt = gotosocial.verify_credentials()
return {
"status": "ok",
"bot": me_name or ("не проверен" if settings.bot_token else "нет токена"),
"token_set": bool(settings.bot_token),
"proxy": settings.tg_proxy,
"channels": settings.channels,
# GoToSocial (fediverse)
"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),
"channels": settings.gt_social_channels,
},
}
def _publish_gotosocial(card: Card, ch: str) -> ChannelResult:
"""Публикует карточку в GoToSocial (Mastodon API). Возвращает ChannelResult.
Медиа загружается ДО создания статуса (нужен media_id); ошибка одного
канала не роняет остальные — fan-out как у Telegram.
"""
res = ChannelResult()
try:
media_ids: list[str] = []
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.status_id = str(sid)
res.message_id = int(sid) if str(sid).isdigit() else 0
except gotosocial.GoToSocialError as e:
res.error = e.message
return res
@app.post("/api/v1/publish", response_model=PublishResponse)
def publish(req: PublishRequest):
"""Публикует «богатый репост» в каналы. Возвращает message_id по каждому каналу.
Порядок: медиа первым (sendPhoto/sendVideo) → текст вторым (sendMessage с
link_preview_options disabled, чтобы не было предпросмотра ссылки).
Для GoToSocial: отдельный список каналов (VESTI_GTS_CHANNELS или gt: из запроса).
"""
channels = resolve_channels(req.channels)
if not channels:
gt_channels = resolve_gotosocial_channels(req.channels)
if not channels and not gt_channels:
raise HTTPException(status_code=400, detail="Нет каналов для публикации")
# dry_run — тестовый режим: НЕ уходит в Telegram, возвращает эмуляцию результата
# dry_run — тестовый режим: НЕ уходит наружу (ни в Telegram, ни в GtS)
if req.dry_run:
results: dict[str, ChannelResult] = {
ch: ChannelResult() for ch in channels
ch: ChannelResult() for ch in [*channels, *gt_channels]
}
return PublishResponse(ok=True, results=results, dry_run=True)
@@ -101,6 +139,12 @@ def publish(req: PublishRequest):
all_ok = False
results[ch] = res
for ch in gt_channels:
res = _publish_gotosocial(card=req.card, ch=ch)
if res.error:
all_ok = False
results[ch] = res
if not all_ok:
# одна ошибка не роняет остальные; статус 502 если все упали
if all(r.error for r in results.values()):