Files
press/ai_core/tenchat_publisher.py
apuc 849e5c472a feat: TenChat auto-posting via refresh token (TCRF) auth
- Rewrite tenchat_publisher to TCRF-only flow: refresh -> Bearer access_token -> publish
- Store only TCRF (one-time refresh token, rotates on each publish)
- Add /tenchat page (GET/POST/disconnect) with username + TCRF fields
- Add publish-to-TenChat button on post publish page
- Update DB helpers: save/get/update/clear tenchat credentials
2026-09-11 13:35:42 +03:00

177 lines
6.3 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""Публикация постов в TenChat через внутренний API.
TenChat — SPA на Nuxt, API-префикс /gostinder/api/web/auth/...
Авторизация через Bearer-токен (access_token), получаемый через refresh.
Механизм аутентификации:
1. POST /tenchat-oauth2/oauth/token?refresh_token={TCRF}&grant_type=refresh_token
→ возвращает access_token + новый refresh_token (TCRF одноразовый!)
2. API-запросы с заголовком Authorization: Bearer {access_token}
API-поток публикации (изучен через редактор tenchat.ru/editor):
1. POST /gostinder/api/web/auth/user/post/draft
— создать черновик, получаем числовой draft_id
2. PUT /gostinder/api/web/auth/user/post/draft/publish/id/{draft_id}
— опубликовать
3. GET /gostinder/api/web/auth/post/id/{draft_id}
— получить данные поста (draft_id == post_id после публикации)
"""
import json
import re
import requests
TENCHAT_BASE = "https://tenchat.ru"
REFRESH_ENDPOINT = "/tenchat-oauth2/oauth/token"
DRAFT_ENDPOINT = "/gostinder/api/web/auth/user/post/draft"
DRAFT_PUBLISH_ENDPOINT = "/gostinder/api/web/auth/user/post/draft/publish/id/{draft_id}"
POST_GET_ENDPOINT = "/gostinder/api/web/auth/post/id/{post_id}"
# Заголовки, имитирующие браузер
DEFAULT_HEADERS = {
"User-Agent": (
"Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 "
"(KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36"
),
"Accept": "application/json, text/plain, */*",
"Accept-Language": "ru-RU,ru;q=0.9,en;q=0.8",
"Content-Type": "application/json",
"Origin": TENCHAT_BASE,
"Referer": TENCHAT_BASE + "/",
}
def refresh_access_token(tcrf: str) -> dict:
"""Обновляет access_token через refresh_token (TCRF).
TCRF — одноразовый! После использования он заменяется новым.
Возвращает dict с access_token, refresh_token (новый TCRF), expires_in.
При ошибке — dict с error.
"""
url = f"{TENCHAT_BASE}{REFRESH_ENDPOINT}?refresh_token={tcrf}&grant_type=refresh_token"
try:
resp = requests.post(url, headers=DEFAULT_HEADERS, timeout=30)
if resp.status_code == 200:
data = resp.json()
return {
"access_token": data.get("access_token"),
"refresh_token": data.get("refresh_token"),
"expires_in": data.get("expires_in"),
}
return {"error": f"HTTP {resp.status_code}: {resp.text[:200]}"}
except Exception as e:
return {"error": str(e)}
def _request(method, url, access_token, json_body=None, timeout=30):
"""Выполняет запрос к TenChat API с Bearer-токеном.
Возвращает (ok, data_or_error).
"""
headers = {**DEFAULT_HEADERS, "Authorization": f"Bearer {access_token}"}
try:
resp = requests.request(
method, url, json=json_body, headers=headers, timeout=timeout
)
try:
data = resp.json()
except ValueError:
data = resp.text
if resp.status_code in (200, 201):
return True, data
return False, {"status": resp.status_code, "body": data}
except Exception as e:
return False, {"error": str(e)}
def _build_draft_body(text: str, tags=None):
"""Собирает тело запроса для создания черновика."""
return {
"adminTagTechNames": [],
"attachments": [],
"metadata": "",
"pictures": [],
"picturesDto": [],
"tagCodes": tags or [],
"text": text,
"title": "",
"videos": [],
"videosDto": [],
}
def create_draft(access_token: str, text: str, tags=None):
"""Создаёт черновик поста в TenChat."""
body = _build_draft_body(text, tags)
url = TENCHAT_BASE + DRAFT_ENDPOINT
ok, data = _request("POST", url, access_token, json_body=body)
if not ok:
return False, data
draft_id = None
if isinstance(data, dict):
draft_id = data.get("id") or data.get("draftId")
elif isinstance(data, (int, float)):
draft_id = int(data)
elif isinstance(data, str):
m = re.search(r"(\d+)", data)
if m:
draft_id = m.group(1)
if not draft_id:
return False, {"error": "Не удалось получить draft_id", "body": data}
return True, draft_id
def publish_draft(access_token: str, draft_id):
"""Публикует черновик поста в TenChat."""
url = TENCHAT_BASE + DRAFT_PUBLISH_ENDPOINT.format(draft_id=draft_id)
return _request("PUT", url, access_token)
def get_post(access_token: str, post_id):
"""Получает данные поста по ID."""
url = TENCHAT_BASE + POST_GET_ENDPOINT.format(post_id=post_id)
return _request("GET", url, access_token)
def publish_post(tcrf: str, text: str, tags=None):
"""Полный цикл публикации поста в TenChat.
Принимает TCRF (refresh token).
Возвращает (ok, result_or_error, new_tcrf).
new_tcrf — новый refresh_token для сохранения в БД.
"""
# 1. Получаем access_token через refresh
refresh_result = refresh_access_token(tcrf)
if "error" in refresh_result:
return False, refresh_result, None
access_token = refresh_result["access_token"]
new_tcrf = refresh_result["refresh_token"]
# 2. Создаём черновик
ok, draft_id = create_draft(access_token, text, tags)
if not ok:
return False, draft_id, new_tcrf
# 3. Публикуем
ok, result = publish_draft(access_token, draft_id)
if not ok:
return False, result, new_tcrf
# 4. Получаем post_id
post_id = draft_id
ok, post_data = get_post(access_token, draft_id)
if ok and isinstance(post_data, dict):
post_id = post_data.get("id") or post_id
return True, {
"post_id": post_id,
"url": f"{TENCHAT_BASE}/post/{post_id}" if post_id else None,
"draft_id": draft_id,
}, new_tcrf