mirror of
https://gitverse.ru/kpa39l/vesti.git
synced 2026-09-29 09:55:03 +00:00
bf176cc02b
- 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 чисто
163 lines
6.8 KiB
Python
163 lines
6.8 KiB
Python
# 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, resolve_gotosocial_channels
|
||
from .config import settings
|
||
from . import gotosocial, telegram
|
||
|
||
app = FastAPI(title="VESTI Publisher Service", version="0.1.0")
|
||
|
||
# путь к .env для test/curl (вне контейнера) — читаем через config (load_dotenv уже там)
|
||
BASE = Path(__file__).resolve().parent.parent.parent
|
||
|
||
|
||
class Card(BaseModel):
|
||
text: str = Field(..., min_length=1, max_length=4096)
|
||
media: str | None = None # путь к локальному файлу (если есть)
|
||
direction: str = "linux"
|
||
lang: str = "ru"
|
||
|
||
|
||
class PublishRequest(BaseModel):
|
||
card: Card
|
||
channels: list[str] | None = None # MAY переопределять конфиг
|
||
dry_run: bool = False # тестовый режим: не уходит в Telegram
|
||
|
||
|
||
class ChannelResult(BaseModel):
|
||
message_id: int = 0
|
||
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):
|
||
ok: bool
|
||
results: dict[str, ChannelResult]
|
||
dry_run: bool = False
|
||
|
||
|
||
@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)
|
||
gt_channels = resolve_gotosocial_channels(req.channels)
|
||
if not channels and not gt_channels:
|
||
raise HTTPException(status_code=400, detail="Нет каналов для публикации")
|
||
|
||
# dry_run — тестовый режим: НЕ уходит наружу (ни в Telegram, ни в GtS)
|
||
if req.dry_run:
|
||
results: dict[str, ChannelResult] = {
|
||
ch: ChannelResult() for ch in [*channels, *gt_channels]
|
||
}
|
||
return PublishResponse(ok=True, results=results, dry_run=True)
|
||
|
||
results: dict[str, ChannelResult] = {}
|
||
all_ok = True
|
||
for ch in channels:
|
||
res = ChannelResult()
|
||
try:
|
||
if req.card.media and Path(req.card.media).exists():
|
||
# медиа первым (caption короткий: первые 1000 символов, т.к. лимит 1024)
|
||
media_msg = telegram.send_media(ch, req.card.media, caption=req.card.text[:1000])
|
||
res.media_message_id = media_msg
|
||
# ПОЛНЫЙ текст отдельным сообщением (без предпросмотра ссылки)
|
||
msg = telegram.send_message(ch, req.card.text)
|
||
res.message_id = msg
|
||
else:
|
||
# без медиа: текст вторым; предпросмотр ссылки отключён (богатый репост)
|
||
msg = telegram.send_message(ch, req.card.text)
|
||
res.message_id = msg
|
||
res.views = telegram.get_views(ch, res.message_id)
|
||
except telegram.TelegramError as e:
|
||
res.error = e.message
|
||
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()):
|
||
raise HTTPException(status_code=502, detail={ch: r.error for ch, r in results.items()})
|
||
return PublishResponse(ok=all_ok, results=results)
|
||
|
||
|
||
@app.get("/api/v1/views/{channel}/{message_id}")
|
||
def views(channel: str, message_id: int):
|
||
"""Views по каналу/сообщению (метрики)."""
|
||
return {"channel": channel, "message_id": message_id, "views": telegram.get_views(channel, message_id)}
|
||
|
||
|
||
if __name__ == "__main__":
|
||
import uvicorn
|
||
uvicorn.run("services.publisher.app.main:app", host="127.0.0.1", port=settings.port) |