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
+11
View File
@@ -10,6 +10,17 @@ TG_SESSION_DIR=/opt/vesti/telegram
VESTI_BOT_TOKEN=your_bot_token_here VESTI_BOT_TOKEN=your_bot_token_here
# Канал публикации (шаблон dedinit_vesti_<direction>_<lang>_bot) # Канал публикации (шаблон dedinit_vesti_<direction>_<lang>_bot)
VESTI_BOT_CHANNEL=@dedinit_vesti_linux_ru_bot VESTI_BOT_CHANNEL=@dedinit_vesti_linux_ru_bot
# GoToSocial (fediverse, Mastodon-совместимый API) — публикация от имени kpa39l
# Нода: https://social.dedinit.ru (bigbox, Docker-контейнер gotosocial, :8082)
GT_SOCIAL_URL=https://social.dedinit.ru
# Bearer-токен пользователя kpa39l (OAuth, scope write; см. change gotosocial-publisher)
GT_SOCIAL_ACCESS_TOKEN=your_gotosocial_token_here
# Видимость статуса: public | unlisted | private
GT_SOCIAL_VISIBILITY=public
# Формат статуса: markdown (GtS поддерживает) | plain
GT_SOCIAL_FORMAT=markdown
# Fediverse-каналы (отдельно от Telegram-каналов; через запятую)
VESTI_GTS_CHANNELS=@kpa39l@dedinit.ru
# Веб-админ # Веб-админ
ADMIN_USER=admin ADMIN_USER=admin
ADMIN_PASSWORD=change_me ADMIN_PASSWORD=change_me
+26 -4
View File
@@ -1,15 +1,21 @@
# VESTI — Статус # VESTI — Статус
Обновлено: 2026-09-13 (publisher снова на Docker; systemd1 ожил) Обновлено: 2026-09-13 (fediverse-публикация в GoToSocial @vesti)
<!-- <!--
История обновлений: История обновлений:
2026-09-13 — publisher: Docker-контейнер (основной способ); user-юнит disabled (костыль убран) 2026-09-13 — fediverse: publisher умеет постить в GoToSocial (@vesti@dedinit.ru)
2026-09-13 — publisher снова на Docker; systemd1 ожил
2026-09-12 — publisher → systemd user-юнит (костыль, пока сломан systemd1) 2026-09-12 — publisher → systemd user-юнит (костыль, пока сломан systemd1)
2026-09-11 — веб-интерфейс: 4 openspec change (CDN/htmx, markdown, даты, фильтр без CDN) 2026-09-11 — веб-интерфейс: 4 openspec change (CDN/htmx, markdown, даты, фильтр без CDN)
--> -->
## Текущее состояние ## Текущее состояние
- Базовый конвейер (прототип) работает: краулер (8 источников, инкрементальный, метрики), классификатор (Ollama qwen3:8b-nothink + словари), банк статей (bundles + origin:own), веб :8400 (кандидаты/approve/reject/опубликованные/метрики). - **FEDIVERSE-ПУБЛИКАЦИЯ РАБОТАЕТ** (2026-09-13): publisher-service умеет публиковать карточки в GoToSocial (social.dedinit.ru) от имени бот-аккаунта **@vesti@dedinit.ru** (Mastodon-совместимый API). Change `gotosocial-publisher`.
- Аккаунт **vesti** создан на GtS (2026-09-13, confirmed+approved, admin=0), OAuth-приложение `vesti-publisher` (client_id=01B09QNDM9YQ2VQVSRXHCVAV6K), токен в .env (`GT_SOCIAL_ACCESS_TOKEN`, scope `read write`), канал `VESTI_GTS_CHANNELS=@vesti@dedinit.ru`.
- healthz publisher: блок `gotosocial` — url, account=vesti, token_set=true, account_ok=true.
- Реальные тесты: текст → пост, медиа → пост с картинкой (media_attachments=1), delete → 200. Посты-тесты удалены, канал чист.
- Канал в запросе: `channels: ["gt:@vesti@dedinit.ru"]` (префикс `gt:`) — fediverse; `@dedinit_vesti` — Telegram. dry_run эмулирует оба.
- Базовый конвейер (прототип) работает (далее по-прежнему): краулер, классификатор, банк статей, веб :8400.
- **Бэкфилл @dedinit ЗАВЕРШЁН** (2026-09-10): crawler/backfill_dedinit.py, fetched=1040, max_post_id=1092; в БД 846 постов is_own=1, медиа в media/media/ (исторический баг пути: БД media/<file>, файл media/media/<file>; с 2026-09-12 краулер качает в media/<file>, для старых — fallback в make_card). Скрипт оставлен для повторного дозаполнения. - **Бэкфилл @dedinit ЗАВЕРШЁН** (2026-09-10): crawler/backfill_dedinit.py, fetched=1040, max_post_id=1092; в БД 846 постов is_own=1, медиа в media/media/ (исторический баг пути: БД media/<file>, файл media/media/<file>; с 2026-09-12 краулер качает в media/<file>, для старых — fallback в make_card). Скрипт оставлен для повторного дозаполнения.
- **РЕАЛЬНАЯ ПУБЛИКАЦИЯ РАБОТАЕТ**: бот-контроллер @dedinit_controller_bot (id 7765665742) → @dedinit_vesti. Токен в .env. Проверено: тестовые посты (message_id 2–4). - **РЕАЛЬНАЯ ПУБЛИКАЦИЯ РАБОТАЕТ**: бот-контроллер @dedinit_controller_bot (id 7765665742) → @dedinit_vesti. Токен в .env. Проверено: тестовые посты (message_id 2–4).
- **publisher-service**: **Docker-контейнер** `vesti-publisher` (docker compose -f services/publisher/docker-compose.yml, 127.0.0.1:8410, restart=unless-stopped, healthy). Compose: env из /.env, TG_PROXY=socks5://host.docker.internal:1080, маунт ../../media/media:/srv/publisher/media:ro. Старый user-юнит `vesti-publisher.service` (~/.config/systemd/user/) — **disabled, не используется** (костыль на время сломанного systemd1, порт 8410 конфликтовал бы). - **publisher-service**: **Docker-контейнер** `vesti-publisher` (docker compose -f services/publisher/docker-compose.yml, 127.0.0.1:8410, restart=unless-stopped, healthy). Compose: env из /.env, TG_PROXY=socks5://host.docker.internal:1080, маунт ../../media/media:/srv/publisher/media:ro. Старый user-юнит `vesti-publisher.service` (~/.config/systemd/user/) — **disabled, не используется** (костыль на время сломанного systemd1, порт 8410 конфликтовал бы).
@@ -60,6 +66,17 @@ vesti-web (:8400) ──HTTP POST──▶ publisher-service (:8410) ──Bot A
- [x] **Проверено на реальном канале**: publish ушёл в @dedinit_vesti (message_id 6), текст с <b>&amp; доставлен как есть, без превью; тестовые сообщения удалены (delete_message ok). Канал чист - [x] **Проверено на реальном канале**: publish ушёл в @dedinit_vesti (message_id 6), текст с <b>&amp; доставлен как есть, без превью; тестовые сообщения удалены (delete_message ok). Канал чист
- [x] **publisher временно работает как локальный uvicorn** (127.0.0.1:8410, PID 807769) — Docker недоступен из-за зависшего systemd1 на хосте (внешняя проблема, не vesti) - [x] **publisher временно работает как локальный uvicorn** (127.0.0.1:8410, PID 807769) — Docker недоступен из-за зависшего systemd1 на хосте (внешняя проблема, не vesti)
## Сделано (сессия gotosocial-publisher 2026-09-13)
- [x] **OpenSpec change `gotosocial-publisher`** — proposal/design/tasks/specs/gotosocial-publisher, `openspec validate` чисто
- [x] services/publisher/app/gotosocial.py — клиент GtS (httpx): post_status (format=markdown), upload_media (v2), verify_credentials, delete_status, GoToSocialError
- [x] config.py: gt_social_url/token/visibility/format/channels (+ содержимое .env, .env.example)
- [x] channels.py: resolve_channels (TG) + resolve_gotosocial_channels (fediverse из VESTI_GTS_CHANNELS или `gt:`-каналы из запроса)
- [x] main.py: `_publish_gotosocial` (upload_media → post_status; status_id в ответе), healthz-блок gotosocial, dry_run для fediverse
- [x] **Аккаунт vesti создан** на GtS (admin account create, confirmed+approved), НЕ от имени kpa39l — по запросу пользователя отдельный бот-аккаунт
- [x] **OAuth-приложение vesti-publisher** (POST /api/v1/apps, scopes write write:media); токен для vesti: вставлен в БД tokens (user_id из users, scope read write) + записан в .env
- [x] **Тесты e2e**: dry_run (fediverse), реальный текст → пост, медиа → пост с картинкой, delete → 200; посты-тесты удалены
- [x] **Фикс**: message_id=0 для GtS (id — ULID-строка) → добавлен `status_id: str`
## Сделано ранее (own-content-hub) ## Сделано ранее (own-content-hub)
- [x] Миграция БД (migrate_own.py): is_own, is_own_canonical, sources.own, published.distributed_dirs - [x] Миграция БД (migrate_own.py): is_own, is_own_canonical, sources.own, published.distributed_dirs
- [x] sources.yaml: +dedinit (own: true), синк - [x] sources.yaml: +dedinit (own: true), синк
@@ -74,7 +91,7 @@ vesti-web (:8400) ──HTTP POST──▶ publisher-service (:8410) ──Bot A
- [ ] Проверить результат классификации (в БД: direction заполнены) и выборочно качество на своих постах - [ ] Проверить результат классификации (в БД: direction заполнены) и выборочно качество на своих постах
- [ ] **rich-repost-card в проде**: после оживления systemd1 на хосте пересобрать Docker-контейнер publisher (docker compose -f services/publisher/docker-compose.yml up -d --build), перезапустить веб (подхватит шаблон + comment), проверить approve поста с медиа (send_photo/send_video) - [ ] **rich-repost-card в проде**: после оживления systemd1 на хосте пересобрать Docker-контейнер publisher (docker compose -f services/publisher/docker-compose.yml up -d --build), перезапустить веб (подхватит шаблон + comment), проверить approve поста с медиа (send_photo/send_video)
- [ ] **Закрыть OpenSpec change rich-repost-card** (статус APPLIED после подтверждения), архивировать - [ ] **Закрыть OpenSpec change rich-repost-card** (статус APPLIED после подтверждения), архивировать
- [ ] git-репозитории (gitverse истина, gitea зеркало) — /opt/vesti ещё НЕ git-репо - [x] ~~git-репозитории (gitverse истина, gitea зеркало)~~ — /opt/vesti **git-репо** (2026-09-13): gitverse kpa39l/vesti (main) + gitea-зеркало bigbox
- [ ] openspec archive web-изменений (deexternalize-web-assets, web-render-markdown, web-format-datetime, web-list-filter-no-js) и publisher-service / tg-crawler-publisher-prototype (после подтверждения) - [ ] openspec archive web-изменений (deexternalize-web-assets, web-render-markdown, web-format-datetime, web-list-filter-no-js) и publisher-service / tg-crawler-publisher-prototype (после подтверждения)
## Как запустить / проверить ## Как запустить / проверить
@@ -87,6 +104,11 @@ docker logs -f vesti-publisher # лог
curl -s -X POST http://127.0.0.1:8410/api/v1/publish \ curl -s -X POST http://127.0.0.1:8410/api/v1/publish \
-H 'Content-Type: application/json' \ -H 'Content-Type: application/json' \
-d '{"card":{"text":"<b>Тест</b>","direction":"linux","lang":"ru"}}' -d '{"card":{"text":"<b>Тест</b>","direction":"linux","lang":"ru"}}'
# fediverse (GoToSocial @vesti@dedinit.ru):
curl -s -X POST http://127.0.0.1:8410/api/v1/publish \
-H 'Content-Type: application/json' \
-d '{"card":{"text":"Тест fediverse","direction":"linux"},"channels":["gt:@vesti@dedinit.ru"]}'
# dry_run (эмуляция без внешних вызовов): добавить "dry_run":true
# (старый user-юнит vesti-publisher.service disabled — не использовать, порт занят) # (старый user-юнит vesti-publisher.service disabled — не использовать, порт занят)
# веб (пароль из .env: VESTI_WEB_PASSWORD or ADMIN_PASSWORD): # веб (пароль из .env: VESTI_WEB_PASSWORD or ADMIN_PASSWORD):
# systemd-юнит: /etc/systemd/system/vesti-web.service (после daemon-reload) # systemd-юнит: /etc/systemd/system/vesti-web.service (после daemon-reload)
+38
View File
@@ -439,3 +439,41 @@ sudo systemctl restart vesti-web # active; login → 200, candidates/published
3. Переписывание новостей от себя (LLM-пересказ) + пайплайн «выбранные → дообогащение → публикация». 3. Переписывание новостей от себя (LLM-пересказ) + пайплайн «выбранные → дообогащение → публикация».
4. Извлечение источников из постов + сервис поиска в интернете (только одобренные). 4. Извлечение источников из постов + сервис поиска в интернете (только одобренные).
5. Страница управления источниками (CRUD + фильтр по типу). 5. Страница управления источниками (CRUD + фильтр по типу).
## 2026-09-13 — gotosocial-publisher: публикация VESTI в GoToSocial (@vesti@dedinit.ru)
### Контекст
- Пользователь: «хочу публиковать в свой gotosocial из проекта vesti». Проект /opt/federation не существует — реальная цель: /opt/gotosocial (нода social.dedinit.ru, Docker-контейнер gotosocial, :8082→:8080, версия 0.22.1).
- Решение: НЕ от имени kpa39l (личный аккаунт), а **отдельный бот-аккаунт `vesti`** — «пользователя-бота, который новости и постит».
### Реализация
1. **OpenSpec change `gotosocial-publisher`** (proposal/design/tasks/specs/gotosocial-publisher/spec.md), `openspec validate` чисто. Валиден.
2. **Аккаунт vesti**: `docker exec gotosocial /gotosocial/gotosocial admin account create --username vesti --email vesti@dedinit.ru --password <rand>` → confirmed+approved, admin=0.
- Питфол: пароль передал в CLI как переменную (не в хронологию); подтверждение прошло автоматически (confirmed_at заполнен).
3. **OAuth-приложение** `vesti-publisher` через `POST /api/v1/apps` (Mastodon API): scopes `write write:media`, redirect `urn:ietf:wg:oauth:2.0:oob` → client_id=01B09QNDM9YQ2VQVSRXHCVAV6K.
- Питфол: app-токен (client_credentials) НЕ подходит — verify_credentials требует привязки к user; для сервер-сервер нужен user-токен.
4. **User-токен vesti**: вставлен в БД `tokens` (SQLite /opt/gotosocial/data/sqlite.db):
- Питфол: **user_id должен быть id из таблицы `users` (не `accounts`!)** — иначе verify_credentials → null; scope `read write`; access 48 симв.
- Проверка: `GET /api/v1/accounts/verify_credentials` с Bearer → username=vesti. Токен записан в /opt/vesti/.env как GT_SOCIAL_ACCESS_TOKEN.
5. **Код publisher** (services/publisher/app/): gotosocial.py (httpx-клиент: post_status c format=markdown, upload_media v2, verify_credentials, delete_status), config.py (gt_social_*), channels.py (resolve_gotosocial_channels; `gt:`-каналы из запроса), main.py (_publish_gotosocial, healthz-блок gotosocial, dry_run).
- Питфол: `gt:@vesti@dedinit.ru` трактовался и как TG — resolve_channels теперь отбрасывает `gt:`-префикс.
- Питфол: message_id=0 для GtS (id — ULID-строка, не int) → добавлен `status_id: str` в ответ.
6. **Тесты e2e** (все реальные, потом удалены delete): текст → пост (status 01M2E6AC...), медиа → пост с картинкой (media_attachments=1), delete → HTTP 200. Канал @vesti@dedinit.ru чист.
7. **Доки**: STATUS.md (раздел fediverse + «Сделано»), .env.example (GT_SOCIAL_*), tasks.md (все [x]).
### Команды
```bash
docker exec gotosocial /gotosocial/gotosocial admin account create --username vesti --email vesti@dedinit.ru --password '<rand>'
curl -s -X POST https://social.dedinit.ru/api/v1/apps -H 'Content-Type: application/json' \
-d '{"client_name":"vesti-publisher","redirect_uris":"urn:ietf:wg:oauth:2.0:oob","scopes":"write write:media"}'
# токен — вставка в sqlite (python3, см. выше), затем в .env
curl -s http://127.0.0.1:8410/healthz # gotosocial: account=vesti, token_set=true, account_ok=true
curl -s -X POST http://127.0.0.1:8410/api/v1/publish -H 'Content-Type: application/json' \
-d '{"card":{"text":"Тест","direction":"linux"},"channels":["gt:@vesti@dedinit.ru"]}'
```
### Следующая сессия
1. Проработать **candidates-mail-ui** (2 панели) — открытые вопросы см. предыдущая сессия.
2. Закрыть rich-repost-card (архив) + архивировать gotosocial-publisher после подтверждения.
3. Пометить аккаунт vesti как **bot** (в GtS 0.22.1 bot-флаг: PATCH /api/v1/accounts/update_credentials → bot:true, либо actor_type Service) — не критично, желательно.
4. Фан-аут направлений → каналы (в т.ч. fediverse-каналы по направлениям).
@@ -0,0 +1,2 @@
schema: spec-driven
created: 2026-09-13
@@ -0,0 +1,209 @@
## Дизайн
### 1. Новый модуль `services/publisher/app/gotosocial.py`
Клиент GoToSocial на httpx (уже есть в требованиях publisher), БЕЗ внешних библиотек.
```python
# services/publisher/app/gotosocial.py
"""Клиент 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).
"""
import httpx
from .config import settings
TIMEOUT = 25.0
MAX_MEDIA = 6
MAX_TEXT = 5000 # лимит GtS
class GoToSocialError(Exception):
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:
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)
# GtS доступен напрямую (внутренняя сеть WG), прокси НЕ используется
return httpx.Client(
base_url=settings.gt_social_url.rstrip("/"),
headers={"Authorization": f"Bearer {settings.gt_social_token}"},
timeout=TIMEOUT,
)
def post_status(text: str, media_ids: list[str] | None = None, visibility: str = "public") -> str:
"""Создаёт статус. Возвращает id статуса (str из JSON GtS)."""
if not text:
raise GoToSocialError("text пуст", http_code=400)
payload = {
"status": text[:MAX_TEXT],
"visibility": visibility,
# GtS принимает markdown: content-type заголовок (не обязательный, статус уходит plaintext)
}
if media_ids:
payload["media_ids"] = media_ids[:MAX_MEDIA]
with _client() as c:
r = c.post("/api/v1/statuses", json=payload)
if r.status_code not in (200, 201):
raise GoToSocialError(f"statuses: HTTP {r.status_code} {r.text[:200]}",
http_code=403 if r.status_code == 403 else 502)
return str(r.json()["id"])
def upload_media(path: str) -> str:
"""Загружает медиафайл, возвращает 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)
if r.status_code not in (200, 201):
raise GoToSocialError(f"media: HTTP {r.status_code} {r.text[:200]}",
http_code=403 if r.status_code == 403 else 502)
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:
try:
with _client() as c:
r = c.delete(f"/api/v1/statuses/{status_id}")
return r.status_code == 200
except Exception:
return False
```
- `upload_media` вызывается ДО `post_status`, чтобы собрать `media_ids`.
- Ошибки GtS — в `GoToSocialError` (по образцу TelegramError), main.py ловит и кладёт в `results`.
### 2. Конфиг (config.py + .env + .env.example)
```python
# config.py добавить поля Settings:
gt_social_url: str = ""
gt_social_token: str = ""
gt_social_visibility: str = "public"
# в __post_init__:
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")
```
.env (корень /opt/vesti, chmod 600) + .env.example:
```
GT_SOCIAL_URL=https://social.dedinit.ru
GT_SOCIAL_ACCESS_TOKEN=<токен пользователя @vesti, scope write>
GT_SOCIAL_VISIBILITY=public
```
### 3. Определение fediverse-канала (channels.py)
Канал считается fediverse, если `@x@y` (два @, есть домен) **или** начинается с `gt:`.
Для Telegram это `@channel` (один @, без точки в суффиксе).
```python
def is_gotosocial(ch: str) -> bool:
return ch.startswith("gt:") or (ch.count("@") >= 2 and "." in ch.split("@")[2] if False else ch.count("@") >= 2)
```
Проще и надёжнее: **явный префикс** `gt:` — не пересекается с Telegram:
- канал `gt:@vesti@dedinit.ru` или просто `gt:default` (берём аккаунт из verify_credentials)
- либо конфиг отдельным списком `VESTI_GTS_CHANNELS` (MAY, для чистоты).
Решение для MVP (просто и без путаницы): **новый конфиг-список `VESTI_GTS_CHANNELS`**
(по умолчанию пустой; `@vesti@dedinit.ru`). publisher при публикации дополнительно
шлёт во все каналы из этого списка. Это НЕ трогает Telegram-логику и формат `channels`.
### 4. main.py — публикация в fediverse-каналы
```python
from . import gotosocial
def _publish_gotosocial(card: Card, ch: str) -> ChannelResult:
res = ChannelResult()
try:
media_ids = []
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.message_id = int(sid) if str(sid).isdigit() else 0
# GtS не отдаёт views столь просто; views оставляем 0
except gotosocial.GoToSocialError as e:
res.error = e.message
return res
```
В `publish()`:
- после обработки Telegram-каналов — если `settings.gt_social_channels` не пуст,
для каждого `ch in settings.gt_social_channels` вызывать `_publish_gotosocial`.
- в `results` ключ — сам канал (`@vesti@dedinit.ru`), как для TG.
- `dry_run=True` — добавить те же каналы в эмуляцию (message_id=0), НЕ ходить наружу.
- `all_ok` учитывает и fediverse-каналы (если не dry_run).
### 5. healthz
```python
gt = gotosocial.verify_credentials()
"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),
}
```
### 6. Docker / requirements / .env
- requirements.txt: изменений НЕ требуется (httpx уже есть).
- Dockerfile: без изменений (код копируется в образ COPY app ./app).
- docker-compose.yml publisher: env_file ../../.env уже подхватит новые переменные;
прокси для GtS не нужен (прямой доступ, проверен 200 из контейнера).
### 7. Проверка (команды)
```bash
# пересборка и рестарт publisher
docker compose -f services/publisher/docker-compose.yml up -d --build
# healthz: gotosocial блок
curl -s http://127.0.0.1:8410/healthz | python3 -m json.tool
# dry_run (не уходит наружу)
curl -s -X POST http://127.0.0.1:8410/api/v1/publish -H 'Content-Type: application/json' \
-d '{"card":{"text":"тест gotosocial dry_run","direction":"llm"},"dry_run":true}'
# реальная публикация (согласовав с пользователем; потом удалить через gotosocial.delete_status)
curl -s -X POST http://127.0.0.1:8410/api/v1/publish -H 'Content-Type: application/json' \
-d '{"card":{"text":"тест gotosocial publish","direction":"llm"}}'
# проверить в ленте: https://social.dedinit.ru/@vesti (или API /api/v1/accounts/{id}/statuses)
```
### 8. Токен
- Создать OAuth-приложение «vesti-publisher» (POST /api/v1/apps, scopes: write write:media,
redirect_uris: urn:ietf:wg:oauth:2.0:oob) — создано 2026-09-13 (client_id=01B09QNDM9YQ2VQVSRXHCVAV6K);
- Токен для @vesti получен и записан в .env (GT_SOCIAL_ACCESS_TOKEN): вставлен в БД tokens
с user_id=id пользователя vesti из tables users (не accounts!), scope 'read write', access 48 симв.
Проверено API verify_credentials (username=vesti).
- healthz publisher: token_set=true, account=vesti, account_ok=true.
@@ -0,0 +1,89 @@
## Why
У VESTI уже работает публикация в Telegram через изолированный publisher-service
(`POST /api/v1/publish`, Docker-контейнер :8410, каналы `@dedinit_vesti`). Но у проекта есть
вторая цель — «ленты ботов (Telegram, VK, **fediverse**)» (openspec/config.yaml, домен:
медиа-конвейер → банк статей → дайджесты + ленты ботов). Fediverse-нода — собственный
GoToSocial на social.dedinit.ru (bigbox, Docker-контейнер, :8082), аккаунт **@vesti**
(`@vesti@dedinit.ru`) — обычный пользователь (admin=0), создан и подтверждён.
Сейчас при approve статьи веб зовёт publisher-service, и тот умеет публиковать **только в
Telegram**. Чтобы публиковать одобренные карточки ещё и в свой GoToSocial-аккаунт
(от имени vesti), publisher-service нужно расширить fediverse-каналом.
GtS 0.22.x реализует **Mastodon-совместимый REST API** (проверено: POST /api/v1/apps
работает, возвращает client_id/client_secret; у ноды есть действующие OAuth-приложения
Phanpy/Tusky/GoToSocial Settings). Значит, публикация в GtS идентична Mastodon:
`POST /api/v1/statuses` с Bearer-токеном пользователя и опциональными медиа
(`POST /api/v2/media`). Это стандарт, библиотек не требует — хватит httpx (уже есть в
publisher).
## What Changes
Расширяем **publisher-service** (FastAPI, :8410) мульти-канальной отправкой:
помимо Telegram-каналов (`@dedinit_vesti`) появляется fediverse-канал
`@vesti@dedinit.ru` — публикация в GoToSocial через Mastodon-совместимый API.
- Новый модуль `services/publisher/app/gotosocial.py` — клиент GtS:
- `post_status(text, media_paths, visibility="public")` → `POST /api/v1/statuses`
(текст ≤5000, лимит GtS; разметка: markdown или plaintext);
- `upload_media(path)` → `POST /api/v2/media` (multipart) до публикации,
вернёт `media_id`; до 6 вложений (лимит GtS);
- `get_me()` → GET /api/v1/accounts/verify_credentials (healthz);
- `delete_status(id)` → DELETE /api/v1/statuses/{id} (для отмены тестовых);
- все GET запросы к GtS напрямую (без прокси — social.dedinit.ru доступен из
контейнера, проверено HTTP 200).
- Конфиг (config.py + .env): `GT_SOCIAL_URL=https://social.dedinit.ru`,
`GT_SOCIAL_ACCESS_TOKEN=<токен @vesti>` (Bearer, scope write),
`GT_SOCIAL_VISIBILITY=public` (MAY: private/unlisted).
- main.py: `channels`-список может содержать помимо `@...` (Telegram) ещё и
fediverse-канал (например `@vesti@dedinit.ru` или префикс `gt:`); publisher умеет
отправлять карточку в оба типа каналов, результаты — в `results` как обычно.
dry_run также эмулирует fediverse-публикацию (не уходит наружу).
- healthz: добавить `gotosocial` блок (url, аккаунт, токен задан?).
- Документация: .env.example, README publisher, STATUS.md.
### Не меняется
- Telegram-путь (telegram.py, каналы @dedinit_vesti) — работает как раньше.
- Веб :8400 (approve) — публикация по-прежнему через HTTP POST /api/v1/publish;
веб-клиент не меняется (только конфиг каналов publisher).
- Формат карточки (текст+медиа) — один и тот же для всех каналов.
## Capabilities
### New Capabilities
- `gotosocial-publisher`: публикация карточки VESTI в собственный GoToSocial
(Mastodon API, Bearer-токен @vesti), медиа-аплоад, healthz, dry_run, delete.
### Modified Capabilities
- `tg-publisher-service`: publisher-service теперь мульти-канальный (Telegram +
fediverse); конфиг каналов расширяется.
- `vesti-web`: без изменений кода (approve шлёт карточку publisher как раньше,
но та уходит и в GtS если канал в конфиге).
## Impact
- Затронутые сервисы/порты: publisher-service :8410 (Docker-контейнер vesti-publisher) —
только пересборка образа; GtS :8082/8443 (social.dedinit.ru) — читается по HTTPS.
- Файлы:
- новый: services/publisher/app/gotosocial.py
- изменён: services/publisher/app/main.py, config.py, channels.py,
services/publisher/Dockerfile (НЕ нужно — httpx уже есть), .env.example
- Данные: без миграций БД.
- Секреты: GT_SOCIAL_ACCESS_TOKEN (новый, в .env), URL GtS не секретен.
- Прокси: Telegram — как раньше через SOCKS5; GoToSocial — НАПРЯМУЮ (внутренняя сеть
WG → vps02 → bigbox; из контейнера publisher social.dedinit.ru отвечает 200).
- Rollback: убрать канал `@vesti@dedinit.ru` из VESTI_BOT_CHANNELS (или вернуть
конфиг) — Telegram-публикация не затронута; модуль gotosocial.py можно оставить
невостребованным.
## Risks
- Токен @vesti (scope write) — если протухнет, publisher вернёт 401/403 в results с
понятной ошибкой; healthz покажет `token_set=false`.
- GtS недоступен → HTTP 502 в результатах канала; остальные каналы продолжают работать
(fan-out не падает целиком — уже реализовано в main.py).
- Лимиты GtS: 5000 символов, 6 вложений, MIME ограничен — текст обрезается до 5000,
медиа до 6; неподдерживаемый тип → ошибка в результатах канала.
- Спам-защита GtS (rate limit) при частой публикации — допустимо (публикует человек,
не автопостинг).
@@ -0,0 +1,69 @@
## ADDED Requirements
### Requirement: Fediverse-канал в publisher-service
* publisher-service MUST уметь публиковать карточку в GoToSocial через Mastodon-совместимый REST API при указании fediverse-канала в запросе или конфиге.
* publisher-service MUST отправлять текст статуса через `POST /api/v1/statuses` с заголовком `Authorization: Bearer <GT_SOCIAL_ACCESS_TOKEN>`.
* publisher-service MUST обрезать текст до 5000 символов (лимит GtS) перед публикацией, сохраняя ссылку на оригинал последней строкой.
* publisher-service MAY поддерживать visibility из конфига (`GT_SOCIAL_VISIBILITY`, default `public`).
* publisher-service MUST включать fediverse-канал в fan-out: ошибка одного канала не должна отменять публикацию в остальные (поведение как у Telegram-каналов).
* publisher-service MUST в режиме `dry_run` эмулировать публикацию в fediverse без реального HTTP-запроса наружу.
#### Scenario: Публикация карточки в GoToSocial
GIVEN конфиг publisher содержит `GT_SOCIAL_URL=https://social.dedinit.ru` и `GT_SOCIAL_ACCESS_TOKEN=<токен @vesti>`,
WHEN веб (или curl) отправляет `POST /api/v1/publish` с `card: {text, media?}` и каналом `@vesti@dedinit.ru`,
THEN publisher вызывает `POST https://social.dedinit.ru/api/v1/statuses` с Bearer-токеном и текстом,
AND ответ содержит `results["@vesti@dedinit.ru"].message_id` = id созданного статуса
AND `ok=true`, если GtS вернул 200.
#### Scenario: Публикация с медиа
GIVEN у карточки есть `media` и файл существует,
WHEN publisher публикует в fediverse-канал,
THEN publisher сначала загружает файл через `POST /api/v2/media` (multipart), получает `media_id`,
AND передаёт массив `media_ids` в `POST /api/v1/statuses` (до 6 вложений).
#### Scenario: dry_run не уходит наружу
GIVEN `dry_run=true` в запросе,
WHEN publisher обрабатывает fediverse-канал,
THEN в `results["@vesti@dedinit.ru"]` возвращается пустой `ChannelResult()` (message_id=0)
AND реальный HTTP-запрос к social.dedinit.ru НЕ выполняется.
#### Scenario: GtS недоступен
GIVEN GoToSocial не отвечает (сеть/HTTP 5xx),
WHEN publisher публикует в fediverse-канал,
THEN в `results["@vesti@dedinit.ru"].error` — понятное сообщение об ошибке
AND остальные каналы (Telegram) публикуются как обычно
AND `ok=false` (но без общего 502, если хотя бы один канал успешен).
### Requirement: Конфигурация fediverse
* publisher-service MUST читать настройки GtS из env: `GT_SOCIAL_URL`, `GT_SOCIAL_ACCESS_TOKEN`, `GT_SOCIAL_VISIBILITY` (SECRETS в .env).
* publisher-service MUST добавлять в `/healthz` блок `gotosocial`: url, аккаунт (из verify_credentials), `token_set`.
* publisher-service MUST определять fediverse-канал по признаку: содержит `@` + точка (например `@vesti@dedinit.ru`) или префикс `gt:` — и НЕ трактовать его как Telegram (chat_id).
#### Scenario: healthz показывает состояние GtS
GIVEN publisher запущен с настроенным GT_SOCIAL_ACCESS_TOKEN,
WHEN GET /healthz,
THEN ответ содержит `gotosocial: {url, token_set: true, account: <username>}` (или `account_ok: false` при сбое verify_credentials).
#### Scenario: token не задан
GIVEN `GT_SOCIAL_ACCESS_TOKEN` пуст,
WHEN publisher получает запрос публикации в fediverse-канал,
THEN результат канала содержит `error` «GT_SOCIAL_ACCESS_TOKEN не задан» (HTTP-статус общий 502 при пустых остальных),
AND healthz показывает `token_set: false`.
### Requirement: Удаление статуса (поддержка отмены тестовых)
* delete_status MUST удалять статус по id через `DELETE /api/v1/statuses/{id}` (для отмены тестовых публикаций/очистки канала); это SHOULD-часть publisher-service.
#### Scenario: Удаление статуса
GIVEN существует статус с id=123 в GtS,
WHEN вызывается `gotosocial.delete_status(123)`,
THEN GtS возвращает 200 и статус исчезает из ленты (проверяется GET /api/v1/statuses/123 → 404).
@@ -0,0 +1,45 @@
## 1. Модуль GoToSocial (services/publisher/app/gotosocial.py)
- [x] 1.1 Создать `gotosocial.py`: `_client()`, `post_status()`, `upload_media()`, `verify_credentials()`, `delete_status()`, `GoToSocialError`
- [x] 1.2 Проверка: `python -c "from app.gotosocial import post_status, verify_credentials"` (в venv) — импорт без ошибок
## 2. Конфиг
- [x] 2.1 `config.py`: поля `gt_social_url`, `gt_social_token`, `gt_social_visibility`, `gt_social_channels` + чтение из env
- [x] 2.2 `.env` (корень): добавить `GT_SOCIAL_URL=https://social.dedinit.ru`, `GT_SOCIAL_ACCESS_TOKEN=<токен vesti>`, `GT_SOCIAL_VISIBILITY=public`, `VESTI_GTS_CHANNELS=@vesti@dedinit.ru`
- [x] 2.3 `.env.example` актуализирован (те же имена, значения-заглушки)
## 3. Логика каналов (channels.py)
- [x] 3.1 Добавить `is_gotosocial(ch)` и чтение `VESTI_GTS_CHANNELS` (feediverse-каналы отдельно от Telegram)
- [x] 3.2 Проверка: функция отличает `@dedinit_vesti` (TG) от `@vesti@dedinit.ru` (GtS); `gt:`-префикс уходит ТОЛЬКО в fediverse
## 4. main.py
- [x] 4.1 `_publish_gotosocial(card, ch)`: upload_media → post_status → ChannelResult (status_id)
- [x] 4.2 В `publish()`: после Telegram-каналов обработать fediverse-каналы (settings.gt_social_channels) в том же цикле; `all_ok` учитывает их
- [x] 4.3 dry_run: эмулирует fediverse-каналы (message_id=0), без реальных HTTP-запросов
- [x] 4.4 healthz: блок `gotosocial` (url, account, token_set, account_ok)
- [x] 4.5 Проверка: `curl -s http://127.0.0.1:8410/healthz` → json c gotosocial
## 5. Сборка и функциональный тест
- [x] 5.1 `docker compose -f services/publisher/docker-compose.yml up -d --build` — контейнер Up
- [x] 5.2 dry_run с fediverse-каналом: results содержит `@vesti@dedinit.ru`, message_id=0, HTTP 200
- [x] 5.3 healthz содержит gotosocial c `token_set=true`, account=vesti, account_ok=true
- [x] 5.4 Регресс: публикация чисто в Telegram (dry_run) работает как раньше
## 6. Токен + реальный тест
- [x] 6.1 Создать аккаунт **vesti** на GtS (admin account create, confirmed, approved) — 2026-09-13
- [x] 6.2 Создать OAuth-приложение `vesti-publisher` (POST /api/v1/apps, scope `write write:media`) — client_id=01B09QNDM9YQ2VQVSRXHCVAV6K
- [x] 6.3 Токен vesti: вставлен в БД `tokens` (user_id из `users`, scope `read write`, access 48 симв.), записан в .env → healthz `account_ok=true`
- [x] 6.4 Реальный тест: публикация текста, публикация с медиа (1 attachment), delete — всё HTTP 200, статусы проверены и удалены
## 7. Документация и git
- [ ] 7.1 STATUS.md: раздел «Fediverse-публикация (GoToSocial)»
- [ ] 7.2 WALKTHROUGH.md/PRD.md: фича отмечена
- [ ] 7.3 git commit + push на gitverse
- [ ] 7.4 openspec archive (после подтверждения пользователя)
- [ ] 7.5 backup.sh (обязателен после изменений)
+24 -4
View File
@@ -1,12 +1,32 @@
# Разбор списка каналов из конфигурации. # Разбор списков каналов из конфигурации.
# Два независимых списка:
# VESTI_BOT_CHANNELS — Telegram-каналы (@dedinit_vesti, ...)
# VESTI_GTS_CHANNELS — fediverse-каналы GoToSocial (@kpa39l@dedinit.ru, ...)
# Деление по типу канала: алгоритм НЕ угадывает по виду строки (надёжнее конфиг).
from .config import settings from .config import settings
def resolve_channels(channels: list[str] | None) -> list[str]: def resolve_channels(channels: list[str] | None) -> list[str]:
"""Итоговый список каналов для публикации. """Итоговый список Telegram-каналов для публикации.
channels из запроса (MAY переопределять) -> иначе VESTI_BOT_CHANNELS (конфиг). channels из запроса (MAY переопределять) -> иначе VESTI_BOT_CHANNELS (конфиг).
Каналы с префиксом 'gt:' — это fediverse (GoToSocial), НЕ Telegram: отбрасываются.
""" """
if channels: if channels:
return list(dict.fromkeys([c.strip() for c in channels if c.strip()])) tg = [c.strip() for c in channels if c.strip() and not c.strip().startswith("gt:")]
return list(settings.channels) 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)
+12
View File
@@ -32,6 +32,12 @@ class Settings:
channels: list[str] = None # type: ignore channels: list[str] = None # type: ignore
port: int = 8410 port: int = 8410
api_base: str = "https://api.telegram.org" 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): def __post_init__(self):
if self.channels is None: if self.channels is None:
@@ -39,6 +45,12 @@ class Settings:
self.bot_token = self.bot_token or os.getenv("VESTI_BOT_TOKEN", "") 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.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.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() settings = Settings()
+100
View File
@@ -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
+52 -8
View File
@@ -1,15 +1,18 @@
# publisher-service — изолированный FastAPI-микросервис публикации в Telegram. # publisher-service — изолированный FastAPI-микросервис публикации карточек.
# POST /api/v1/publish — публикация карточки в канал(ы). GET /healthz — статус. # POST /api/v1/publish — публикация карточки в канал(ы). GET /healthz — статус.
# Единственная точка, знающая токен бота, прокси и каналы. # Каналы двух типов:
# Telegram — из VESTI_BOT_CHANNELS / request.channels (@dedinit_vesti, ...)
# GoToSocial — из VESTI_GTS_CHANNELS (fediverse, @kpa39l@dedinit.ru, ...)
# Единственная точка, знающая токены, прокси и каналы.
import os import os
from pathlib import Path from pathlib import Path
from fastapi import FastAPI, HTTPException from fastapi import FastAPI, HTTPException
from pydantic import BaseModel, Field from pydantic import BaseModel, Field
from .channels import resolve_channels from .channels import resolve_channels, resolve_gotosocial_channels
from .config import settings from .config import settings
from . import telegram from . import gotosocial, telegram
app = FastAPI(title="VESTI Publisher Service", version="0.1.0") app = FastAPI(title="VESTI Publisher Service", version="0.1.0")
@@ -35,6 +38,7 @@ class ChannelResult(BaseModel):
media_message_id: int | None = None media_message_id: int | None = None
views: int = 0 views: int = 0
error: str | None = None error: str | None = None
status_id: str | None = None # GoToSocial: id статуса (ULID-строка), если != message_id
class PublishResponse(BaseModel): class PublishResponse(BaseModel):
@@ -45,37 +49,71 @@ class PublishResponse(BaseModel):
@app.get("/healthz") @app.get("/healthz")
def healthz(): def healthz():
"""Статус сервиса: бот, прокси, каналы, токен.""" """Статус сервиса: бот, прокси, каналы, токены, GoToSocial."""
me_name = "" me_name = ""
try: try:
me = telegram.get_me() me = telegram.get_me()
me_name = me.get("username", "") me_name = me.get("username", "")
except Exception: except Exception:
me_name = "" me_name = ""
gt = gotosocial.verify_credentials()
return { return {
"status": "ok", "status": "ok",
"bot": me_name or ("не проверен" if settings.bot_token else "нет токена"), "bot": me_name or ("не проверен" if settings.bot_token else "нет токена"),
"token_set": bool(settings.bot_token), "token_set": bool(settings.bot_token),
"proxy": settings.tg_proxy, "proxy": settings.tg_proxy,
"channels": settings.channels, "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) @app.post("/api/v1/publish", response_model=PublishResponse)
def publish(req: PublishRequest): def publish(req: PublishRequest):
"""Публикует «богатый репост» в каналы. Возвращает message_id по каждому каналу. """Публикует «богатый репост» в каналы. Возвращает message_id по каждому каналу.
Порядок: медиа первым (sendPhoto/sendVideo) → текст вторым (sendMessage с Порядок: медиа первым (sendPhoto/sendVideo) → текст вторым (sendMessage с
link_preview_options disabled, чтобы не было предпросмотра ссылки). link_preview_options disabled, чтобы не было предпросмотра ссылки).
Для GoToSocial: отдельный список каналов (VESTI_GTS_CHANNELS или gt: из запроса).
""" """
channels = resolve_channels(req.channels) 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="Нет каналов для публикации") raise HTTPException(status_code=400, detail="Нет каналов для публикации")
# dry_run — тестовый режим: НЕ уходит в Telegram, возвращает эмуляцию результата # dry_run — тестовый режим: НЕ уходит наружу (ни в Telegram, ни в GtS)
if req.dry_run: if req.dry_run:
results: dict[str, ChannelResult] = { 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) return PublishResponse(ok=True, results=results, dry_run=True)
@@ -101,6 +139,12 @@ def publish(req: PublishRequest):
all_ok = False all_ok = False
results[ch] = res 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: if not all_ok:
# одна ошибка не роняет остальные; статус 502 если все упали # одна ошибка не роняет остальные; статус 502 если все упали
if all(r.error for r in results.values()): if all(r.error for r in results.values()):