mirror of
https://gitverse.ru/kpa39l/vesti.git
synced 2026-09-29 09:55:03 +00:00
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:
@@ -1,12 +1,32 @@
|
||||
# Разбор списка каналов из конфигурации.
|
||||
# Разбор списков каналов из конфигурации.
|
||||
# Два независимых списка:
|
||||
# VESTI_BOT_CHANNELS — Telegram-каналы (@dedinit_vesti, ...)
|
||||
# VESTI_GTS_CHANNELS — fediverse-каналы GoToSocial (@kpa39l@dedinit.ru, ...)
|
||||
# Деление по типу канала: алгоритм НЕ угадывает по виду строки (надёжнее конфиг).
|
||||
from .config import settings
|
||||
|
||||
|
||||
def resolve_channels(channels: list[str] | None) -> list[str]:
|
||||
"""Итоговый список каналов для публикации.
|
||||
"""Итоговый список Telegram-каналов для публикации.
|
||||
|
||||
channels из запроса (MAY переопределять) -> иначе VESTI_BOT_CHANNELS (конфиг).
|
||||
Каналы с префиксом 'gt:' — это fediverse (GoToSocial), НЕ Telegram: отбрасываются.
|
||||
"""
|
||||
if channels:
|
||||
return list(dict.fromkeys([c.strip() for c in channels if c.strip()]))
|
||||
return list(settings.channels)
|
||||
tg = [c.strip() for c in channels if c.strip() and not c.strip().startswith("gt:")]
|
||||
return list(dict.fromkeys(tg))
|
||||
return list(settings.channels)
|
||||
|
||||
|
||||
def resolve_gotosocial_channels(channels: list[str] | None) -> list[str]:
|
||||
"""Итоговый список GoToSocial-каналов (fediverse).
|
||||
|
||||
В запросе fediverse-каналы передаются ПРЕФИКСОМ gt: (например gt:@kpa39l@dedinit.ru),
|
||||
чтобы не смешивать с Telegram-каналами. Если в запросе их нет — берётся
|
||||
VESTI_GTS_CHANNELS из конфига.
|
||||
"""
|
||||
if channels:
|
||||
gts = [c[3:].strip() for c in channels if c.strip().startswith("gt:")]
|
||||
if gts:
|
||||
return list(dict.fromkeys(gts))
|
||||
return list(settings.gt_social_channels)
|
||||
@@ -32,6 +32,12 @@ class Settings:
|
||||
channels: list[str] = None # type: ignore
|
||||
port: int = 8410
|
||||
api_base: str = "https://api.telegram.org"
|
||||
# GoToSocial (fediverse, Mastodon-совместимый API)
|
||||
gt_social_url: str = ""
|
||||
gt_social_token: str = ""
|
||||
gt_social_visibility: str = "public"
|
||||
gt_social_format: str = "markdown"
|
||||
gt_social_channels: list[str] = None # type: ignore
|
||||
|
||||
def __post_init__(self):
|
||||
if self.channels is None:
|
||||
@@ -39,6 +45,12 @@ class Settings:
|
||||
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"))
|
||||
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")
|
||||
self.gt_social_format = self.gt_social_format or os.getenv("GT_SOCIAL_FORMAT", "markdown")
|
||||
if self.gt_social_channels is None:
|
||||
self.gt_social_channels = _channels_list(os.getenv("VESTI_GTS_CHANNELS", ""))
|
||||
|
||||
|
||||
settings = Settings()
|
||||
@@ -0,0 +1,100 @@
|
||||
# Клиент 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).
|
||||
# GoToSocial доступен напрямую (внутренняя сеть WG → vps02 → bigbox), прокси НЕ используется
|
||||
# (в отличие от Telegram, который из РФ доступен только через SOCKS5).
|
||||
import httpx
|
||||
|
||||
from .config import settings
|
||||
|
||||
TIMEOUT = 25.0
|
||||
MAX_MEDIA = 6 # лимит GtS на вложения
|
||||
MAX_TEXT = 5000 # лимит GtS на длину статуса
|
||||
|
||||
|
||||
class GoToSocialError(Exception):
|
||||
"""Ошибка GtS API / сети. Код для HTTP-ответа сервиса (по образцу TelegramError)."""
|
||||
|
||||
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:
|
||||
"""Клиент с Bearer-токеном. Проверяет обязательные настройки ДО запроса."""
|
||||
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)
|
||||
return httpx.Client(
|
||||
base_url=settings.gt_social_url.rstrip("/"),
|
||||
headers={"Authorization": f"Bearer {settings.gt_social_token}"},
|
||||
timeout=TIMEOUT,
|
||||
)
|
||||
|
||||
|
||||
def _handle(r: httpx.Response, what: str) -> None:
|
||||
if r.status_code not in (200, 201):
|
||||
raise GoToSocialError(
|
||||
f"{what}: HTTP {r.status_code} {r.text[:200]}",
|
||||
http_code=403 if r.status_code == 403 else 502,
|
||||
detail=r.text[:200],
|
||||
)
|
||||
|
||||
|
||||
def post_status(text: str, media_ids: list[str] | None = None, visibility: str = "public") -> str:
|
||||
"""Создаёт статус. Возвращает id статуса (str из JSON GtS).
|
||||
|
||||
format=markdown — GtS поддерживает markdown-разметку статусов
|
||||
(см. /api/v1/instance: supported_mime_types включает text/markdown).
|
||||
"""
|
||||
if not text:
|
||||
raise GoToSocialError("text пуст", http_code=400)
|
||||
payload = {
|
||||
"status": text[:MAX_TEXT],
|
||||
"visibility": visibility,
|
||||
"format": settings.gt_social_format, # "markdown" | "plain"
|
||||
}
|
||||
if media_ids:
|
||||
payload["media_ids"] = media_ids[:MAX_MEDIA]
|
||||
with _client() as c:
|
||||
r = c.post("/api/v1/statuses", json=payload)
|
||||
_handle(r, "statuses")
|
||||
return str(r.json()["id"])
|
||||
|
||||
|
||||
def upload_media(path: str) -> str:
|
||||
"""Загружает медиафайл (multipart), возвращает 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)
|
||||
_handle(r, "media")
|
||||
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:
|
||||
"""Удаляет статус. True если GtS вернул 200 (или 404 — уже удалён)."""
|
||||
try:
|
||||
with _client() as c:
|
||||
r = c.delete(f"/api/v1/statuses/{status_id}")
|
||||
return r.status_code in (200, 404)
|
||||
except Exception:
|
||||
return False
|
||||
@@ -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()):
|
||||
|
||||
Reference in New Issue
Block a user