# publisher-service — изолированный FastAPI-микросервис публикации в Telegram. # POST /api/v1/publish — публикация карточки в канал(ы). GET /healthz — статус. # Единственная точка, знающая токен бота, прокси и каналы. import os from pathlib import Path from fastapi import FastAPI, HTTPException from pydantic import BaseModel, Field from .channels import resolve_channels from .config import settings from . import 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 class PublishResponse(BaseModel): ok: bool results: dict[str, ChannelResult] dry_run: bool = False @app.get("/healthz") def healthz(): """Статус сервиса: бот, прокси, каналы, токен.""" me_name = "" try: me = telegram.get_me() me_name = me.get("username", "") except Exception: me_name = "" 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, } @app.post("/api/v1/publish", response_model=PublishResponse) def publish(req: PublishRequest): """Публикует «богатый репост» в каналы. Возвращает message_id по каждому каналу. Порядок: медиа первым (sendPhoto/sendVideo) → текст вторым (sendMessage с link_preview_options disabled, чтобы не было предпросмотра ссылки). """ channels = resolve_channels(req.channels) if not channels: raise HTTPException(status_code=400, detail="Нет каналов для публикации") # dry_run — тестовый режим: НЕ уходит в Telegram, возвращает эмуляцию результата if req.dry_run: results: dict[str, ChannelResult] = { ch: ChannelResult() for ch in 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 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)