Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
28 changes: 28 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 |
Expand All @@ -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://<host>:8080/vk-ads?rb_clickid=<value>`. 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=<token>`.
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=<rb_clickid>`.

## Local Bot API server

To handle large files you can run a local copy of Telegram's Bot API server.
Expand Down Expand Up @@ -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
Expand Down
70 changes: 70 additions & 0 deletions ads_server.py
Original file line number Diff line number Diff line change
@@ -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"""
<!doctype html>
<html lang=\"ru\">
<head>
<meta charset=\"utf-8\" />
<title>Переход к боту</title>
<meta name=\"viewport\" content=\"width=device-width, initial-scale=1\" />
</head>
<body>
<script type=\"text/javascript\">
var _tmr = window._tmr || (window._tmr = []);
_tmr.push({{id: {counter_json}, type: "pageView", start: (new Date()).getTime()}});
(function (d, w, id) {{
if (d.getElementById(id)) return;
var ts = d.createElement("script"); ts.type = "text/javascript"; ts.async = true; ts.id = id;
ts.src = "https://top-fwz1.mail.ru/js/code.js";
var f = function () {{var s = d.getElementsByTagName("script")[0]; s.parentNode.insertBefore(ts, s);}};
if (w.opera == "[object Opera]") {{ d.addEventListener("DOMContentLoaded", f, false); }} else {{ f(); }}
}})(document, window, "tmr-code");
setTimeout(() => {{ window.location.href = {redirect_json}; }}, {REDIRECT_DELAY_MS});
</script>
<noscript><div><img src=\"https://top-fwz1.mail.ru/counter?id={VK_COUNTER_ID};js=na\" style=\"position:absolute;left:-9999px;\" alt=\"Top.Mail.Ru\" /></div></noscript>
</body>
</html>
"""

return HTMLResponse(content=html, status_code=200)


if __name__ == "__main__":
import uvicorn

uvicorn.run(app, host="0.0.0.0", port=LISTEN_PORT)
21 changes: 21 additions & 0 deletions database/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -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())
34 changes: 32 additions & 2 deletions database/queries.py
Original file line number Diff line number Diff line change
@@ -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:
Expand Down Expand Up @@ -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()
9 changes: 5 additions & 4 deletions handlers/text.py
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand All @@ -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)
Expand Down
2 changes: 2 additions & 0 deletions requirements.txt
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,8 @@ boto3
pytz
requests
httpx
fastapi
uvicorn

# Monitoring / error reporting
sentry_sdk
63 changes: 63 additions & 0 deletions utils/vk_ads.py
Original file line number Diff line number Diff line change
@@ -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