diff --git a/README.md b/README.md index d4ac893..09aedcf 100644 --- a/README.md +++ b/README.md @@ -107,6 +107,15 @@ ClearTranscriptBot | `MEAS_TOKEN` | Measurement Protocol token (generated in Metrica counter settings) | | `BOT_URL` | Public URL of your bot (e.g. `https://t.me/ClearTranscriptBot`) | +### VK Ads landing page + +| Variable | Description | +|-----------------------|----------------------------------------------------------------------------------| +| `VK_COUNTER_ID` | VK Ads counter ID (used in the Mail.ru tracking script, defaults to `3723500`) | +| `BOT_URL` | Base URL of your bot (used for redirect, defaults to `http://t.me/ClearTranscriptBot`) | +| `VK_SERVER_PORT` | Port for the VK Ads landing page server (defaults to `8080`) | +| `VK_REDIRECT_DELAY_MS`| Delay before redirecting to Telegram after firing `pageView` (defaults to `500`) | + ### Tinkoff acquiring | Variable | Description | @@ -115,6 +124,15 @@ ClearTranscriptBot | `TERMINAL_PASSWORD` | Terminal password from Tinkoff | | `TERMINAL_ENV` | Environment: `test` for sandbox or `prod` | +## VK Ads flow + +1. Start the landing page server: `uvicorn ads_server:app --host 0.0.0.0 --port 8080` (defaults to port `8080`). +2. Send traffic to `http://:8080/vk-ads?rb_clickid=`. The handler saves `rb_clickid`, + fires a `pageView` to Mail.ru with a compact token as `pid`, and then redirects the user to + `http://t.me/ClearTranscriptBot?start=`. +3. When the user clicks **Start** in Telegram, the bot resolves the token back to `rb_clickid` and + triggers `https://top-fwz1.mail.ru/tracker?id=3723500;e=RG%3A0/startBot;rb_clickid=`. + ## Local Bot API server To handle large files you can run a local copy of Telegram's Bot API server. @@ -186,6 +204,16 @@ CREATE TABLE IF NOT EXISTS payments ( CREATE INDEX idx_payments_telegram_id ON payments(telegram_id); + +-- VK Ads click tokens mapped to original rb_clickid values +CREATE TABLE IF NOT EXISTS vk_clicks ( + id INTEGER PRIMARY KEY AUTO_INCREMENT, + token VARCHAR(32) NOT NULL UNIQUE, + rb_clickid TEXT NOT NULL, + created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP +); + +CREATE UNIQUE INDEX idx_vk_clicks_token ON vk_clicks(token); ``` ## Installation diff --git a/ads_server.py b/ads_server.py new file mode 100644 index 0000000..2ea72d5 --- /dev/null +++ b/ads_server.py @@ -0,0 +1,70 @@ +"""Landing page server for VK Ads redirects (FastAPI).""" + +import json +import logging +import os + +from fastapi import FastAPI, HTTPException +from fastapi.responses import HTMLResponse + +from database.queries import create_vk_click + +logging.basicConfig( + level=logging.INFO, + format="%(asctime)s [%(levelname)s] %(name)s: %(message)s", +) + +VK_COUNTER_ID = os.getenv("VK_COUNTER_ID", "3723500") +BOT_URL = os.getenv("BOT_URL", "http://t.me/ClearTranscriptBot") +REDIRECT_DELAY_MS = int(os.getenv("VK_REDIRECT_DELAY_MS", "500")) +LISTEN_PORT = int(os.getenv("VK_SERVER_PORT", "8080")) + +app = FastAPI(title="VK Ads landing") + + +@app.get("/vk-ads/", response_class=HTMLResponse) +def vk_ads_landing(rb_clickid: str | None = None) -> HTMLResponse: + if not rb_clickid: + raise HTTPException(status_code=400, detail="rb_clickid is required") + + click = create_vk_click(rb_clickid) + logging.info("VK Ads click registered: token=%s", click.token) + + token_json = json.dumps(click.token) + counter_json = json.dumps(VK_COUNTER_ID) + redirect_target = f"{BOT_URL}?start={click.token}" + redirect_json = json.dumps(redirect_target) + + html = f""" + + + + + Переход к боту + + + + + + + +""" + + return HTMLResponse(content=html, status_code=200) + + +if __name__ == "__main__": + import uvicorn + + uvicorn.run(app, host="0.0.0.0", port=LISTEN_PORT) diff --git a/database/models.py b/database/models.py index 5694586..723995c 100644 --- a/database/models.py +++ b/database/models.py @@ -128,3 +128,24 @@ class Payment(Base): # Timestamp when the payment was created created_at = Column(DateTime, nullable=False, server_default=func.current_timestamp()) + + +class VkClick(Base): + """VK Ads click identifiers mapped to compact tokens.""" + + __tablename__ = "vk_clicks" + + __table_args__ = ( + Index("idx_vk_clicks_token", "token", unique=True), + ) + + id = Column(Integer, primary_key=True, autoincrement=True) + + # Compact token that can be passed to Telegram as start payload + token = Column(String(32), nullable=False, unique=True) + + # Original rb_clickid received from VK Ads + rb_clickid = Column(Text, nullable=False) + + # Timestamp when the click was registered + created_at = Column(DateTime, nullable=False, server_default=func.current_timestamp()) diff --git a/database/queries.py b/database/queries.py index d6952bf..3677233 100644 --- a/database/queries.py +++ b/database/queries.py @@ -1,10 +1,17 @@ import json +import secrets from typing import Optional, Any from decimal import Decimal -from .connection import SessionLocal -from .models import User, TranscriptionHistory, Payment +from sqlalchemy.exc import IntegrityError + +from .connection import SessionLocal, engine +from .models import Base, User, TranscriptionHistory, Payment, VkClick + + +# Ensure all tables (including newly added ones) are present. +Base.metadata.create_all(bind=engine) def add_user(telegram_id: int, telegram_login: str | None = None) -> User: @@ -158,3 +165,26 @@ def update_payment(order_id: str, **fields: Any) -> Optional[Payment]: session.commit() session.refresh(topup) return topup + + +def create_vk_click(rb_clickid: str) -> VkClick: + """Persist a VK Ads click and return the created object.""" + + with SessionLocal() as session: + for _ in range(5): + click = VkClick(token=secrets.token_hex(16), rb_clickid=rb_clickid) + session.add(click) + try: + session.commit() + session.refresh(click) + return click + except IntegrityError: + session.rollback() + raise RuntimeError("Failed to generate unique token for VK Ads click") + + +def get_vk_click(token: str) -> Optional[VkClick]: + """Fetch a VK Ads click entry by its token.""" + + with SessionLocal() as session: + return session.query(VkClick).filter(VkClick.token == token).one_or_none() diff --git a/handlers/text.py b/handlers/text.py index 10edb16..88bf0be 100644 --- a/handlers/text.py +++ b/handlers/text.py @@ -3,9 +3,10 @@ from telegram import Update from telegram.ext import ContextTypes -from database.queries import add_user, get_user_by_telegram_id +from database.queries import add_user, get_user_by_telegram_id, get_vk_click from utils.marketing import track_goal +from utils.vk_ads import track_vk_goal from utils.sentry import sentry_bind_user from utils.speechkit import available_time_by_balance @@ -25,9 +26,9 @@ async def handle_text(update: Update, context: ContextTypes.DEFAULT_TYPE) -> Non if user is None: user = add_user(telegram_id, update.message.from_user.username) - yclid = extract_start_payload(update.message.text or "") - if yclid: - context.application.create_task(track_goal(yclid, "startbot")) + start_payload = extract_start_payload(update.message.text or "") + if start_payload and get_vk_click(start_payload): + context.application.create_task(track_vk_goal(start_payload, "startBot")) balance = Decimal(user.balance or 0) duration_str = available_time_by_balance(balance) diff --git a/requirements.txt b/requirements.txt index 100ebf0..c717f6c 100644 --- a/requirements.txt +++ b/requirements.txt @@ -14,6 +14,8 @@ boto3 pytz requests httpx +fastapi +uvicorn # Monitoring / error reporting sentry_sdk diff --git a/utils/vk_ads.py b/utils/vk_ads.py new file mode 100644 index 0000000..b6f1c54 --- /dev/null +++ b/utils/vk_ads.py @@ -0,0 +1,63 @@ +"""VK Ads integration helpers.""" + +import logging +import os + +import httpx + +from database.queries import get_vk_click + + +VK_COUNTER_ID = os.getenv("VK_COUNTER_ID", "3723500") +TRACKER_URL = "https://top-fwz1.mail.ru/tracker" +HTTP_TIMEOUT = 5.0 + + +async def track_vk_goal(token: str, goal: str = "startBot") -> bool: + """ + Send conversion event for a VK Ads click token via Top.Mail.ru tracker. + + *token* — короткий токен, под которым хранится реальный rb_clickid. + """ + + # 1. Достаём запись по токену + click = get_vk_click(token) + if click is None: + logging.info("VK Ads: token not found: %s", token) + return False + + rb_clickid = click.rb_clickid + logging.info("VK Ads: resolved token=%s → rb_clickid=%s", token, rb_clickid) + + # 2. Формируем правильный формат цели (НЕ URL-энкодить!) + # e=RG:0/startBot + e_param = f"RG:0/{goal}" + + # 3. Собираем URL вручную — params нельзя использовать + url = ( + f"{TRACKER_URL}" + f"?id={VK_COUNTER_ID};" + f"e={e_param};" + f"rb_clickid={rb_clickid}" + ) + + logging.info("VK Ads: final tracker URL: %s", url) + + # 4. Выполняем запрос + try: + async with httpx.AsyncClient(timeout=HTTP_TIMEOUT) as client: + response = await client.get(url) + response.raise_for_status() + + logging.info( + "VK Ads: goal sent successfully: goal=%s, rb_clickid=%s, status=%s", + goal, rb_clickid, response.status_code + ) + return True + + except Exception as exc: # noqa: BLE001 + logging.warning( + "VK Ads: goal send FAILED: goal=%s rb_clickid=%s error=%r url=%s", + goal, rb_clickid, exc, url + ) + return False