From 849e5c472a04ce5279ba1b1b7c13239c281430b5 Mon Sep 17 00:00:00 2001 From: Kavalar Date: Fri, 11 Sep 2026 13:35:42 +0300 Subject: [PATCH] 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 --- ai_core/tenchat_publisher.py | 176 ++++++++++++++++++++ cabinet_service/app.py | 82 ++++++++- cabinet_service/templates/profile_edit.html | 6 + cabinet_service/templates/publish.html | 18 +- cabinet_service/templates/tenchat.html | 65 ++++++++ database.py | 96 +++++++++++ 6 files changed, 441 insertions(+), 2 deletions(-) create mode 100644 ai_core/tenchat_publisher.py create mode 100644 cabinet_service/templates/tenchat.html 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 +
+

← Назад в кабинет

{% endblock %} diff --git a/cabinet_service/templates/publish.html b/cabinet_service/templates/publish.html index d60122e..a899b38 100644 --- a/cabinet_service/templates/publish.html +++ b/cabinet_service/templates/publish.html @@ -28,7 +28,7 @@ {% endfor %} - + {% else %}
@@ -36,6 +36,22 @@
{% endif %} +{% if tenchat_connected %} +
+

TenChat

+

Опубликовать этот пост в ваш профиль TenChat (@{{ tenchat_username }}).

+
+ +
+
+{% else %} +
+

TenChat

+

Подключите TenChat, чтобы публиковать посты в профиль.

+ 🔑 Настроить TenChat +
+{% endif %} +
← Назад к посту ⚙ Управление каналами diff --git a/cabinet_service/templates/tenchat.html b/cabinet_service/templates/tenchat.html new file mode 100644 index 0000000..1da59a5 --- /dev/null +++ b/cabinet_service/templates/tenchat.html @@ -0,0 +1,65 @@ +{% extends "base.html" %} +{% block title %}TenChat — Личный кабинет{% endblock %} +{% block content %} +

Интеграция с TenChat

+

Подключите свой аккаунт TenChat, чтобы публиковать посты автоматически. Refresh token хранится только у вас и используется для публикации от вашего имени.

+ +{% if creds %} +
+

+ Подключено: @{{ creds.tenchat_username }} +  |  подключено {{ creds.tenchat_connected_at }} + {% if creds.tenchat_last_refresh %} +  |  обновлено {{ creds.tenchat_last_refresh }} + {% endif %} +

+
+{% else %} +
+

⚠️ TenChat не подключён. Введите refresh token ниже, чтобы публиковать посты.

+
+{% endif %} + +
+

🔑 Refresh token TenChat

+ + + + + + +
+ +{% if creds %} +
+ +
+{% endif %} + +
+

📖 Как получить refresh token

+
    +
  1. Откройте tenchat.ru и войдите в свой аккаунт.
  2. +
  3. Нажмите F12 (или Ctrl+Shift+I), чтобы открыть инструменты разработчика.
  4. +
  5. Перейдите на вкладку Application (Приложение).
  6. +
  7. В левом меню выберите Cookies → https://tenchat.ru.
  8. +
  9. Найдите куку TCRF и скопируйте её значение.
  10. +
  11. Вставьте значение в поле выше и нажмите «Сохранить ключи».
  12. +
+

+ ⚠️ Refresh token живёт ~14 дней и автоматически обновляется при каждой публикации. + Если публикация перестала работать — обновите ключ заново. +

+
+ +

← Назад в профиль · ← На главную

+{% endblock %} \ No newline at end of file diff --git a/database.py b/database.py index 6e65545..0b2e356 100644 --- a/database.py +++ b/database.py @@ -37,6 +37,25 @@ def create_db(): if 'password_hash' not in columns: cursor.execute('ALTER TABLE users ADD COLUMN password_hash TEXT') print("✅ Колонка 'password_hash' добавлена в таблицу users") + # === TenChat интеграция === + if 'tenchat_username' not in columns: + cursor.execute('ALTER TABLE users ADD COLUMN tenchat_username TEXT') + print("✅ Колонка 'tenchat_username' добавлена в таблицу users") + if 'tenchat_tcaf' not in columns: + cursor.execute('ALTER TABLE users ADD COLUMN tenchat_tcaf TEXT') + print("✅ Колонка 'tenchat_tcaf' добавлена в таблицу users") + if 'tenchat_tcrf' not in columns: + cursor.execute('ALTER TABLE users ADD COLUMN tenchat_tcrf TEXT') + print("✅ Колонка 'tenchat_tcrf' добавлена в таблицу users") + if 'tenchat_session' not in columns: + cursor.execute('ALTER TABLE users ADD COLUMN tenchat_session TEXT') + print("✅ Колонка 'tenchat_session' добавлена в таблицу users") + if 'tenchat_connected_at' not in columns: + cursor.execute('ALTER TABLE users ADD COLUMN tenchat_connected_at TIMESTAMP') + print("✅ Колонка 'tenchat_connected_at' добавлена в таблицу users") + if 'tenchat_last_refresh' not in columns: + cursor.execute('ALTER TABLE users ADD COLUMN tenchat_last_refresh TIMESTAMP') + print("✅ Колонка 'tenchat_last_refresh' добавлена в таблицу users") cursor.execute(''' CREATE TABLE IF NOT EXISTS posts ( @@ -963,5 +982,82 @@ def get_task(task_id): return dict(row) if row else None +# === TenChat интеграция === + +def save_tenchat_credentials(user_id, username, tcrf): + """Сохраняет ключи сессии TenChat для пользователя. + + Хранит только TCRF (refresh token) — он используется для получения + access_token через refresh. TCRF одноразовый, обновляется после каждого refresh. + """ + conn = sqlite3.connect('users.db') + cursor = conn.cursor() + cursor.execute( + '''UPDATE users SET + tenchat_username = ?, + tenchat_tcrf = ?, + tenchat_connected_at = COALESCE(tenchat_connected_at, CURRENT_TIMESTAMP), + tenchat_last_refresh = CURRENT_TIMESTAMP + WHERE id = ?''', + (username, tcrf, user_id) + ) + conn.commit() + conn.close() + + +def get_tenchat_credentials(user_id): + """Возвращает ключи сессии TenChat пользователя (или None). + + Возвращает dict с tenchat_username и tenchat_tcrf. + """ + conn = _row_conn() + cursor = conn.cursor() + cursor.execute( + '''SELECT tenchat_username, tenchat_tcrf, + tenchat_connected_at, tenchat_last_refresh + FROM users WHERE id = ?''', + (user_id,) + ) + row = cursor.fetchone() + conn.close() + if not row: + return None + creds = dict(row) + if not creds.get('tenchat_tcrf'): + return None + return creds + + +def update_tenchat_tokens(user_id, new_tcrf): + """Обновляет TCRF после refresh (TCRF одноразовый!).""" + conn = sqlite3.connect('users.db') + cursor = conn.cursor() + cursor.execute( + 'UPDATE users SET tenchat_tcrf = ?, tenchat_last_refresh = CURRENT_TIMESTAMP WHERE id = ?', + (new_tcrf, user_id) + ) + conn.commit() + conn.close() + + +def clear_tenchat_credentials(user_id): + """Удаляет ключи сессии TenChat пользователя.""" + conn = sqlite3.connect('users.db') + cursor = conn.cursor() + cursor.execute( + '''UPDATE users SET + tenchat_username = NULL, + tenchat_tcaf = NULL, + tenchat_tcrf = NULL, + tenchat_session = NULL, + tenchat_connected_at = NULL, + tenchat_last_refresh = NULL + WHERE id = ?''', + (user_id,) + ) + conn.commit() + conn.close() + + # Создаем базу при первом импорте create_db() \ No newline at end of file