mirror of
https://gitverse.ru/kpa39l/vesti.git
synced 2026-09-29 09:55:03 +00:00
Initial import: vesti.nixg.ru — новостной апрув-проект (web, crawler, classifier, publisher, openspec)
This commit is contained in:
@@ -0,0 +1,12 @@
|
||||
# Разбор списка каналов из конфигурации.
|
||||
from .config import settings
|
||||
|
||||
|
||||
def resolve_channels(channels: list[str] | None) -> list[str]:
|
||||
"""Итоговый список каналов для публикации.
|
||||
|
||||
channels из запроса (MAY переопределять) -> иначе VESTI_BOT_CHANNELS (конфиг).
|
||||
"""
|
||||
if channels:
|
||||
return list(dict.fromkeys([c.strip() for c in channels if c.strip()]))
|
||||
return list(settings.channels)
|
||||
@@ -0,0 +1,44 @@
|
||||
# Конфигурация publisher-service. Все секреты/настройки из env (.env).
|
||||
# Правило: секреты НЕ в коде, только в .env (не коммитить).
|
||||
import os
|
||||
from pathlib import Path
|
||||
|
||||
from dotenv import load_dotenv
|
||||
|
||||
BASE = Path(__file__).resolve().parents[3] # /opt/vesti (корень проекта)
|
||||
# override=False: env-переменные, уже заданные окружением (Docker: compose environment/env_file),
|
||||
# имеют приоритет над .env; на хосте (пустой env) значения подхватываются из .env.
|
||||
load_dotenv(BASE / ".env")
|
||||
|
||||
DEFAULT_CHANNELS = "@dedinit_vesti"
|
||||
|
||||
|
||||
from dataclasses import dataclass
|
||||
|
||||
|
||||
def _channels_list(raw: str | None) -> list[str]:
|
||||
"""Разбирает VESTI_BOT_CHANNELS: '@a,@b' -> ['@a','@b']. Пустые отбрасывает."""
|
||||
if not raw:
|
||||
return [DEFAULT_CHANNELS]
|
||||
return [c.strip() for c in raw.split(",") if c.strip()]
|
||||
|
||||
|
||||
@dataclass
|
||||
class Settings:
|
||||
"""Читает env ПРИ СОЗДАНИИ (после load_dotenv). Секреты — только из env/.env."""
|
||||
|
||||
bot_token: str = ""
|
||||
tg_proxy: str = "" # пусто → читаем из env (TG_PROXY); дефолт SOCKS5 127.0.0.1:1080
|
||||
channels: list[str] = None # type: ignore
|
||||
port: int = 8410
|
||||
api_base: str = "https://api.telegram.org"
|
||||
|
||||
def __post_init__(self):
|
||||
if self.channels is None:
|
||||
self.channels = _channels_list(os.getenv("VESTI_BOT_CHANNELS", ""))
|
||||
self.bot_token = self.bot_token or os.getenv("VESTI_BOT_TOKEN", "")
|
||||
self.tg_proxy = self.tg_proxy or os.getenv("TG_PROXY", "socks5://127.0.0.1:1080")
|
||||
self.port = self.port or int(os.getenv("PUBLISHER_PORT", "8410"))
|
||||
|
||||
|
||||
settings = Settings()
|
||||
@@ -0,0 +1,119 @@
|
||||
# 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)
|
||||
@@ -0,0 +1,161 @@
|
||||
# Telegram Bot API клиент: httpx + SOCKS5-прокси.
|
||||
# TG из РФ заблокирован -> все запросы через TG_PROXY (socks5://127.0.0.1:1080).
|
||||
import httpx
|
||||
|
||||
from .config import settings
|
||||
|
||||
API = settings.api_base
|
||||
TIMEOUT = 20.0
|
||||
|
||||
|
||||
class TelegramError(Exception):
|
||||
"""Ошибка Bot API / сети. Код для HTTP-ответа сервиса."""
|
||||
|
||||
def __init__(self, message: str, http_code: int = 502, detail: str = ""):
|
||||
super().__init__(message)
|
||||
self.message = message
|
||||
self.http_code = http_code # какой HTTP-статус вернуть вызывающему
|
||||
self.detail = detail
|
||||
|
||||
|
||||
def _client() -> httpx.Client:
|
||||
if not settings.bot_token:
|
||||
raise TelegramError("VESTI_BOT_TOKEN не задан", http_code=500, detail="missing token")
|
||||
return httpx.Client(proxy=settings.tg_proxy, timeout=TIMEOUT)
|
||||
|
||||
|
||||
def _url(method: str) -> str:
|
||||
return f"{API}/bot{settings.bot_token}/{method}"
|
||||
|
||||
|
||||
def _call(method: str, **params) -> dict:
|
||||
"""Выполняет метод Bot API. Возвращает result. Ошибки -> TelegramError."""
|
||||
try:
|
||||
with _client() as c:
|
||||
r = c.post(_url(method), json=params, timeout=TIMEOUT)
|
||||
except Exception as e:
|
||||
raise TelegramError(f"Сеть/прокси до Bot API: {e}", http_code=502) from e
|
||||
|
||||
if r.status_code != 200:
|
||||
# 401 bad token, 403 forbidden (бот не админ) и т.п.
|
||||
try:
|
||||
body = r.json()
|
||||
desc = body.get("description", "")
|
||||
except Exception:
|
||||
desc = r.text[:200]
|
||||
raise TelegramError(
|
||||
f"Bot API {method}: HTTP {r.status_code} {desc}",
|
||||
http_code=403 if r.status_code == 403 else 502,
|
||||
detail=desc,
|
||||
)
|
||||
data = r.json()
|
||||
if not data.get("ok"):
|
||||
desc = data.get("description", "")
|
||||
raise TelegramError(f"Bot API {method}: {desc}", http_code=502, detail=desc)
|
||||
return data.get("result", {})
|
||||
|
||||
|
||||
def send_message(chat_id: str, text: str, parse_mode: str | None = None) -> int:
|
||||
"""Отправляет текст. По умолчанию — plain (карточка репоста: исходный текст поста
|
||||
с сырыми символами <,>,&; ссылки Telegram распознаёт сам).
|
||||
link_preview_options={is_disabled:True} — НЕ показывать предпросмотр ссылки
|
||||
(чтобы канал не выглядел как агрегатор ссылок с превью)."""
|
||||
params = {
|
||||
"chat_id": chat_id,
|
||||
"text": text,
|
||||
"link_preview_options": {"is_disabled": True},
|
||||
}
|
||||
if parse_mode:
|
||||
params["parse_mode"] = parse_mode
|
||||
return _call("sendMessage", **params)["message_id"]
|
||||
|
||||
|
||||
def send_photo(chat_id: str, photo_path: str, caption: str = "") -> int:
|
||||
"""Отправляет фото (медиа первым). Возвращает message_id."""
|
||||
if not photo_path:
|
||||
raise TelegramError("photo_path пуст", http_code=400)
|
||||
try:
|
||||
with _client() as c, open(photo_path, "rb") as f:
|
||||
r = c.post(
|
||||
_url("sendPhoto"),
|
||||
data={"chat_id": chat_id, "caption": caption[:1024]},
|
||||
files={"photo": f},
|
||||
timeout=TIMEOUT,
|
||||
)
|
||||
except TelegramError:
|
||||
raise
|
||||
except Exception as e:
|
||||
raise TelegramError(f"sendPhoto сеть: {e}", http_code=502) from e
|
||||
|
||||
if r.status_code != 200:
|
||||
try:
|
||||
desc = r.json().get("description", "")
|
||||
except Exception:
|
||||
desc = r.text[:200]
|
||||
raise TelegramError(f"sendPhoto: HTTP {r.status_code} {desc}", http_code=403 if r.status_code == 403 else 502)
|
||||
data = r.json()
|
||||
if not data.get("ok"):
|
||||
raise TelegramError(f"sendPhoto: {data.get('description','')}", http_code=502)
|
||||
return data["result"]["message_id"]
|
||||
|
||||
|
||||
def send_video(chat_id: str, video_path: str, caption: str = "") -> int:
|
||||
"""Отправляет видео (медиа первым). Возвращает message_id."""
|
||||
if not video_path:
|
||||
raise TelegramError("video_path пуст", http_code=400)
|
||||
try:
|
||||
with _client() as c, open(video_path, "rb") as f:
|
||||
r = c.post(
|
||||
_url("sendVideo"),
|
||||
data={"chat_id": chat_id, "caption": caption[:1024]},
|
||||
files={"video": f},
|
||||
timeout=TIMEOUT,
|
||||
)
|
||||
except TelegramError:
|
||||
raise
|
||||
except Exception as e:
|
||||
raise TelegramError(f"sendVideo сеть: {e}", http_code=502) from e
|
||||
|
||||
if r.status_code != 200:
|
||||
try:
|
||||
desc = r.json().get("description", "")
|
||||
except Exception:
|
||||
desc = r.text[:200]
|
||||
raise TelegramError(f"sendVideo: HTTP {r.status_code} {desc}", http_code=403 if r.status_code == 403 else 502)
|
||||
data = r.json()
|
||||
if not data.get("ok"):
|
||||
raise TelegramError(f"sendVideo: {data.get('description','')}", http_code=502)
|
||||
return data["result"]["message_id"]
|
||||
|
||||
|
||||
def send_media(chat_id: str, media_path: str, caption: str = "") -> int:
|
||||
"""Отправляет медиа по расширению: фото или видео. Возвращает message_id."""
|
||||
if str(media_path).lower().endswith((".mp4", ".mov", ".avi", ".mkv")):
|
||||
return send_video(chat_id, media_path, caption)
|
||||
return send_photo(chat_id, media_path, caption)
|
||||
|
||||
|
||||
def get_views(chat_id: str, message_id: int) -> int:
|
||||
"""Просмотры опубликованного сообщения (getMessage → views)."""
|
||||
try:
|
||||
r = _call("getMessage", chat_id=chat_id, message_id=message_id)
|
||||
return int(r.get("views") or r.get("forwards") or 0)
|
||||
except TelegramError:
|
||||
return 0
|
||||
|
||||
|
||||
def get_me() -> dict:
|
||||
return _call("getMe")
|
||||
|
||||
|
||||
def delete_message(chat_id: str, message_id: int) -> bool:
|
||||
"""Удаляет сообщение из канала. True если успешно."""
|
||||
try:
|
||||
_call("deleteMessage", chat_id=chat_id, message_id=message_id)
|
||||
return True
|
||||
except TelegramError:
|
||||
return False
|
||||
|
||||
|
||||
def get_chat(chat_id: str) -> dict:
|
||||
return _call("getChat", chat_id=chat_id)
|
||||
Reference in New Issue
Block a user