Привет. Сегодня мы создадим полноценный инструмент повышения конверсии в звонок для email- и CRM-маркетологов. Речь пойдет о системе заказа обратного звонка прямо из письма.

Пользователь получает письмо с кликабельным календарем для выбора даты. Нажав на подходящий день, мы самостоятельно определяем время звонка и автоматически ставим задачу менеджеру в amoCRM.

Архитектура решения

Система состоит из следующих компонентов:

  1. Персонализированный email-шаблон с интерактивным календарем, где каждая дата — это уникальная ссылка для заказа звонка.

  2. Бэкенд-сервис (API-endpoint), который принимает запросы из письма, обрабатывает их и является ядром всей логики. Пишем его на Python с Flask.

  3. Идентификация клиента из письма через GET-параметры в ссылке на дате.

  4. Сервис «Умная проверка номера» МТС Exolve, который по телефону абонента проверяет его доступность и возвращает оптимальное время для звонка.

  5. Взаимодействие с amoCRM — конечная точка, где мы ищем контакт по почтовому адресу клиента и ставим задачу менеджеру на звонок в рекомендованное время.

Теперь разберем по шагам.

Шаг 1. Точка входа: интерактивный календарь в письме

Все начинается с письма. Нам нужно, чтобы ссылка не просто вела на сайт, а несла в себе информацию о клиенте: его email и выборе даты звонка. Идеальный способ — передать данные через GET-параметры. Чтобы пользователь мог выбрать дату, мы сверстаем простой календарь HTML-таблицей. Это самый надежный способ для корректного отображения в 99% почтовых клиентов.

Переменная {{EMAIL}} в ссылке — это специальный маркер, который большинство сервисов email-рассылок (ESP) автоматически заменят на реальный адрес подписчика.

from datetime import datetime, timedelta
def generate_callback_calendar_html(days_ahead=7):
   """Генерирует HTML-код календаря для вставки в email."""
   base_url = "https://api.yourdomain.com/v1/callback-request" # Замените на ваш URL
   html = '<table border="1" cellpadding="10" style="border-collapse: collapse; text-align: center;"><tr>'
   today = datetime.today()
   for i in range(days_ahead):
       current_date = today + timedelta(days=i)
       date_str = current_date.strftime('%Y-%m-%d')
       day_str = current_date.strftime('%d.%m')
       link = f"{base_url}?email={{EMAIL}}&date={date_str}"
       html += f'<td><a href="{link}" style="text-decoration: none; color: #007bff;">{day_str}</a></td>'
   html += '</tr></table>'
   return html

Полученный HTML-код вы вставляете в свой email-шаблон. Пользователь увидит в письме аккуратную таблицу с датами, каждая из которых является уникальной ссылкой:

Шаг 2. Бэкенд: интеграция с amoCRM

Когда пользователь нажимает на дату, его запрос летит на наш сервер. Специальный класс-клиент управляет всей логикой работы с API amoCRM, включая процесс OAuth 2.0 аутентификации. Критически важный момент для production-систем - обработка токенов должна быть потокобезопасной. Чтобы избежать "гонки состояний", когда два одновременных запроса пытаются обновить истекший токен, мы используем механизм блокировки файла (.lock), который гарантирует, что только один процесс в один момент времени работает с файлом токенов.

import requests
import json
import time
import os
from dotenv import load_dotenv

load_dotenv()

# --- Конфигурация ---
TOKEN_FILE = 'tokens.json'
LOCK_FILE = 'tokens.json.lock'  # Файл для механизма блокировки
CLIENT_ID = os.getenv("AMOCRM_CLIENT_ID")
CLIENT_SECRET = os.getenv("AMOCRM_CLIENT_SECRET")
REDIRECT_URI = os.getenv("AMOCRM_REDIRECT_URI")
BASE_URL = os.getenv("AMOCRM_BASE_URL")
AUTH_CODE = os.getenv("AMOCRM_AUTH_CODE")


# --- Механизм блокировки файла ---
def _acquire_lock():
    while os.path.exists(LOCK_FILE):
        time.sleep(0.1)
    with open(LOCK_FILE, 'w') as f:
        pass


def _release_lock():
    if os.path.exists(LOCK_FILE):
        os.remove(LOCK_FILE)


# --- Функции для работы с токенами ---
def _save_tokens(tokens):
    tokens['created_at'] = int(time.time())
    with open(TOKEN_FILE, "w") as f:
        json.dump(tokens, f)
    return tokens


def _get_initial_tokens():
    url = f"{BASE_URL}/oauth2/access_token"
    payload = {"client_id": CLIENT_ID, "client_secret": CLIENT_SECRET, "grant_type": "authorization_code",
               "code": AUTH_CODE, "redirect_uri": REDIRECT_URI}
    try:
        response = requests.post(url, json=payload)
        response.raise_for_status()
        return _save_tokens(response.json())
    except requests.exceptions.RequestException as e:
        print(f"Ошибка при получении первоначальных токенов: {e.response.text}")
        return None


def _refresh_tokens(existing_tokens):
    url = f"{BASE_URL}/oauth2/access_token"
    payload = {"client_id": CLIENT_ID, "client_secret": CLIENT_SECRET, "grant_type": "refresh_token",
               "refresh_token": existing_tokens['refresh_token'], "redirect_uri": REDIRECT_URI}
    try:
        response = requests.post(url, json=payload)
        response.raise_for_status()
        return _save_tokens(response.json())
    except requests.exceptions.RequestException as e:
        print(f"Ошибка при обновлении токенов: {e.response.text}")
        return None


def get_actual_tokens():
    _acquire_lock()
    try:
        if not os.path.exists(TOKEN_FILE):
            return _get_initial_tokens()

        with open(TOKEN_FILE, "r") as f:
            tokens = json.load(f)

        if int(time.time()) - tokens.get('created_at', 0) > 82800:  # 23 часа
            with open(TOKEN_FILE, "r") as f:
                refreshed_tokens = json.load(f)
            if int(time.time()) - refreshed_tokens.get('created_at', 0) > 82800:
                return _refresh_tokens(refreshed_tokens)
            return refreshed_tokens

        return tokens
    finally:
        _release_lock()


class AmoCRMClient:
    def __init__(self):
        self.base_url = BASE_URL
        self.tokens = get_actual_tokens()
        if not self.tokens:
            raise Exception("Не удалось получить или обновить токены.")
        self.access_token = self.tokens['access_token']

    def _make_request(self, method, endpoint, **kwargs):
        self.tokens = get_actual_tokens()
        if not self.tokens:
            raise Exception("Аутентификация провалена прямо перед запросом.")
        self.access_token = self.tokens['access_token']

        url = f"{self.base_url}{endpoint}"
        headers = {"Authorization": f"Bearer {self.access_token}"}
        response = requests.request(method, url, headers=headers, **kwargs)
        return response

    def find_contact_by_email(self, email):
        endpoint = "/api/v4/contacts"
        params = {"query": email}
        response = self._make_request('GET', endpoint, params=params)
        if response.status_code == 200:
            contacts = response.json()
            if contacts and '_embedded' in contacts and contacts['_embedded']['contacts']:
                return contacts['_embedded']['contacts']
        return None

    def create_task_for_entity(self, entity_id, entity_type, task_text, due_timestamp):
        endpoint = "/api/v4/tasks"
        payload = [{"task_type_id": 1, "text": task_text, "complete_till": due_timestamp, "entity_id": entity_id,
                    "entity_type": entity_type}]
        response = self._make_request('POST', endpoint, json=payload)
        return response.status_code == 200

Шаг 3. Определяем лучшее время для звонка с Number Lookup API

Получив номер телефона клиента из CRM, мы отправляем его в МТС Exolve для анализа через метод GetBestCallTime.

В отличие от других подобных сервисов, МТС Exolve работает синхронно: мы отправляем запрос с номером и сразу получаем в ответ. В коде предусмотрели сценарий, если сервис вернет ошибку или не даст рекомендацию, то система назначит звонок на стандартное время.

import requests
import os
from dotenv import load_dotenv
load_dotenv()
EXOLVE_API_KEY = os.getenv("EXOLVE_API_KEY")
def get_optimal_call_time(phone_number: str, on_date: str):
   if not EXOLVE_API_KEY:
       print("[WARNING] Запрос пропущен из-за отсутствия API ключа Exolve.")
       return "12:00"
   cleaned_phone = "".join(filter(str.isdigit, phone_number))
   print(f"[*] Запрашиваю лучшее время для звонка через Exolve API для номера {cleaned_phone}...")
   api_url = "https://api.exolve.ru/hlr/v1/GetBestCallTime"
   headers = {"Authorization": f"Bearer {EXOLVE_API_KEY}"}
   payload = {"number": cleaned_phone}
   try:
       response = requests.post(api_url, json=payload, headers=headers, timeout=10)
       if response.status_code == 200:
           data = response.json()
           result_period = data.get("result")
           if result_period:
               # Из "17:00:00,21:00:00" берем "17:00"
               recommended_time = result_period.split(',')[0][:5]
               print(f"[+] Рекомендованное время от Exolve: {recommended_time}")
               return recommended_time
       else:
           print(f"[!] Ошибка от API Exolve: {response.status_code}, {response.text}")
   except requests.exceptions.RequestException as e:
       print(f"[CRITICAL ERROR] Ошибка при обращении к API Exolve: {e}")
   print("[WARNING] Не удалось получить рекомендацию. Возвращено стандартное время.")
   return "12:00"

Шаг 4. Финальный этап: собираем все вместе

Теперь объединим все модули в главном файле app.py. Flask-приложение будет выполнять следующую последовательность действий:

  1. Принять запрос и извлечь email и date.

  2. Найти контакт в amoCRM по email и извлечь номер телефона.

  3. Отправить номер в наш сервис и получить рекомендованное время.

  4. Создать в amoCRM задачу с текстом и точным временем выполнения.

  5. Ответить пользователю, что его запрос принят.

from flask import Flask, request, jsonify
from datetime import datetime
from amo_client import AmoCRMClient
from hlr_service import get_optimal_call_time
app = Flask(__name__)
@app.route('/v1/callback-request', methods=['GET'])
def handle_callback_request():
   email = request.args.get('email')
   request_date_str = request.args.get('date')
   if not email or not request_date_str:
       return jsonify({"error": "Email and date parameters are required"}), 400
   try:
       crm = AmoCRMClient()
       contact = crm.find_contact_by_email(email)[0] # Берем первый найденный контакт
   except Exception as e:
       return jsonify({"error": f"CRM service error: {e}"}), 503
   if not contact:
       return jsonify({"error": "Client with this email not found in CRM"}), 404
   contact_id = contact.get('id')
   contact_name = contact.get('name', '')
   phone = None
   if contact.get('custom_fields_values'):
       for field in contact.get('custom_fields_values', []):
           if field.get('field_code') == 'PHONE':
               phone = field['values'][0]['value']
               break
   # Важная логика: если телефона нет, HLR невозможен
   if phone:
       optimal_time_str = get_optimal_call_time(phone, request_date_str)
       task_datetime = datetime.strptime(f"{request_date_str} {optimal_time_str}", '%Y-%m-%d %H:%M')
       task_text = f"Умный callback: перезвонить клиенту {contact_name}"
   else:
       optimal_time_str = None # Времени нет
       task_datetime = datetime.strptime(f"{request_date_str} 23:59", '%Y-%m-%d %H:%M')




       task_text = f"Обратный звонок (нет номера): связаться с клиентом {contact_name}"
   due_timestamp = int(task_datetime.timestamp())
   task_created = crm.create_task_for_entity(contact_id, 'contacts', task_text, due_timestamp)
   if not task_created:
       return jsonify({"error": "Failed to create CRM task"}), 500
   # Формируем ответ пользователю в зависимости от того, было ли найдено время
   response_time_str = f"ориентировочно в {optimal_time_str}" if optimal_time_str else ""
   return (f"<h1>Спасибо, {contact_name or 'уважаемый клиент'}!</h1>"
           f"<p>Мы получили ваш запрос. Менеджер свяжется с вами {request_date_str} {response_time_str}.</p>")
if __name__ == '__main__':
   app.run(debug=True, port=5001)

Шаг 5. Постановка задачи в amoCRM

После нажатия клиентом на ссылку в письме, бэкенд обрабатывает запрос, определяет лучшее время для звонка и создает в amoCRM задачу, привязанную к карточке этого клиента.

Финальный результат — задача с текстом «Умный callback...» и конкретным рекомендованным временем. Вот как выглядит результат работы скрипта — автоматически созданная задача в карточке клиента:

Мы разработали полнофункциональную систему обратного звонка. Она решает поставленную задачу: делает заказ звонка для клиента бесшовным, а саму заявку — сразу ставит в работу менеджеру.

Как улучшить это решение

  • Добавить логирование, обработку ошибок API и механизм повторных попыток на случай, если CRM недоступна.

  • Отправлять клиенту автоматическое email-подтверждение с датой запланированного звонка и возможностью изменить время.

  • При переходе по ссылке из письма выводить записанное время и предлагать выбор другого времени.

  • Учитывать загруженность менеджеров при постановке задач в CRM.

  • Определять и учитывать часовой пояс по IP при переходе по ссылке.

Эта система — отличный пример того, как комбинация простых, но надежных технологий может дать мощный эффект, напрямую влияющий на бизнес-показатели. Вот код на гитхаб.

Комментарии (0)