diff --git a/ai_core/tenchat_publisher.py b/ai_core/tenchat_publisher.py new file mode 100644 index 0000000..59a6d6d --- /dev/null +++ b/ai_core/tenchat_publisher.py @@ -0,0 +1,176 @@ +"""Публикация постов в 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 diff --git a/cabinet_service/app.py b/cabinet_service/app.py index eab38cd..316ddd8 100644 --- a/cabinet_service/app.py +++ b/cabinet_service/app.py @@ -20,6 +20,7 @@ from ai_core.telegram_publisher import ( publish_post, resolve_chat, ) +from ai_core.tenchat_publisher import publish_post as publish_post_tenchat from cabinet_service.brand_doc_generator import generate_brand_docx from config import SECRET_KEY from database import ( @@ -63,9 +64,13 @@ from database import ( save_post_for_user, save_post_image, save_provider_settings, + clear_tenchat_credentials, + get_tenchat_credentials, + save_tenchat_credentials, update_plan_status, update_post_content, update_task_status, + update_tenchat_tokens, update_user_field_by_id, ) @@ -352,6 +357,48 @@ def provider_submit( return RedirectResponse("/provider", status_code=303) +@app.get("/tenchat") +def tenchat_form(request: Request, user: dict = Depends(current_user)): + creds = get_tenchat_credentials(user["id"]) + return render(request, "tenchat.html", user=user, creds=creds) + + +@app.post("/tenchat") +def tenchat_submit( + request: Request, + tenchat_username: str = Form(...), + tenchat_tcrf: str = Form(...), + user: dict = Depends(current_user), +): + errors = [] + username = tenchat_username.strip().lstrip("@") + tcrf = tenchat_tcrf.strip() + + if not username: + errors.append("Укажите username в TenChat.") + if not tcrf: + errors.append("Укажите ключ TCRF (refresh token).") + + if errors: + return render( + request, "tenchat.html", status_code=400, + user=user, creds=get_tenchat_credentials(user["id"]), + errors=errors, + form={"tenchat_username": username, "tenchat_tcrf": tcrf}, + ) + + save_tenchat_credentials(user["id"], username, tcrf) + flash(request, "TenChat подключён: @%s. Refresh token сохранён." % username) + return RedirectResponse("/tenchat", status_code=303) + + +@app.post("/tenchat/disconnect") +def tenchat_disconnect(request: Request, user: dict = Depends(current_user)): + clear_tenchat_credentials(user["id"]) + flash(request, "TenChat отключён. Ключи сессии удалены.") + return RedirectResponse("/tenchat", status_code=303) + + @app.get("/generate") def generate_form(request: Request, user: dict = Depends(current_user), plan_id: str = Query(""), platform: str = Query(""), @@ -969,8 +1016,11 @@ def publish_form(request: Request, post_id: int, user: dict = Depends(current_us if not post: raise HTTPException(status_code=404, detail="Пост не найден") channels = get_channels_for_user(user["id"]) + tenchat = get_tenchat_credentials(user["id"]) return render(request, "publish.html", user=user, post=post, channels=channels, - platform_label=platform_label) + platform_label=platform_label, + tenchat_connected=bool(tenchat), + tenchat_username=tenchat["tenchat_username"] if tenchat else None) @app.post("/posts/{post_id}/publish") @@ -1018,6 +1068,36 @@ def publish_submit( return RedirectResponse(f"/posts/{post_id}", status_code=303) +@app.post("/posts/{post_id}/publish/tenchat") +def publish_tenchat_submit(request: Request, post_id: int, user: dict = Depends(current_user)): + post = get_post_by_id_for_user(post_id, user["id"]) + if not post: + raise HTTPException(status_code=404, detail="Пост не найден") + + creds = get_tenchat_credentials(user["id"]) + if not creds: + flash(request, "❌ TenChat не подключён. Настройте ключи сессии на странице /tenchat.") + return RedirectResponse(f"/posts/{post_id}/publish", status_code=303) + + ok, result, new_tcrf = publish_post_tenchat( + creds["tenchat_tcrf"], + post["content"], + ) + + # Обновляем TCRF (одноразовый — после каждого refresh он меняется) + if new_tcrf: + update_tenchat_tokens(user["id"], new_tcrf) + + if ok: + mark_post_published(post_id, user["id"], "TenChat") + url = result.get("url") if isinstance(result, dict) else None + flash(request, f"✅ Пост опубликован в TenChat" + (f": {url}" if url else "")) + else: + flash(request, f"❌ Ошибка публикации в TenChat: {result}") + + return RedirectResponse(f"/posts/{post_id}", status_code=303) + + # === ФОНОВАЯ ГЕНЕРАЦИЯ (AJAX) === def _run_post_generation(task_id, user_id, platform, style, wishes, survey_id, plan_id=None): diff --git a/cabinet_service/templates/profile_edit.html b/cabinet_service/templates/profile_edit.html index 271b23e..8112dd0 100644 --- a/cabinet_service/templates/profile_edit.html +++ b/cabinet_service/templates/profile_edit.html @@ -22,5 +22,11 @@ ⚙ Настроить провайдера +
Подключите аккаунт TenChat, чтобы публиковать посты автоматически.
+ 🔑 Настроить TenChat +Опубликовать этот пост в ваш профиль TenChat (@{{ tenchat_username }}).
+ +Подключите свой аккаунт TenChat, чтобы публиковать посты автоматически. Refresh token хранится только у вас и используется для публикации от вашего имени.
+ +{% if creds %} ++ Подключено: @{{ creds.tenchat_username }} + | подключено {{ creds.tenchat_connected_at }} + {% if creds.tenchat_last_refresh %} + | обновлено {{ creds.tenchat_last_refresh }} + {% endif %} +
+⚠️ TenChat не подключён. Введите refresh token ниже, чтобы публиковать посты.
++ ⚠️ Refresh token живёт ~14 дней и автоматически обновляется при каждой публикации. + Если публикация перестала работать — обновите ключ заново. +
+