Привет, Хабр! Меня зовут Екатерина Саяпина, я Product Owner личного кабинета платформы МТС Exolve. Сегодня расскажу, как создать простую, но эффективную форму обратного звонка с SMS-уведомлениями. Дам пример для сценария, когда клиент оставляет заявку через форму, а менеджер связывается с ним через Callback API. После успешного разговора система автоматически отправляет SMS через SMS API с подтверждением договоренностей и следующими шагами.
SMS-уведомления здесь играют роль надежного канала для закрепления результатов разговора и напоминания о договоренностях. Они не требуют интернета или установки приложений и работают везде, даже при слабом сигнале связи.
Причем пример будет без громоздких фреймворков — только Go и чистый HTML с щепоткой JavaScript.
Зачем это нужно в 2024
Неожиданно, но формы обратного звонка все еще актуальны. И дело не в технологиях, а в людях. Клиенту проще оставить номер на сайте, чем звонить самому или искать контакты в мессенджерах.
Более того, SMS-уведомления остаются самым надежным способом оповещения. Не нужен интернет. Не требуется установка приложений. Работает везде, даже там, где связь еле дышит.
Что в итоге получим
Сделаем два компонента:
минималистичный лендинг с формой заказа звонка;
сервер на Go для обработки запросов и отправки SMS через API Exolve.
Звучит просто, но дьявол, как всегда, кроется в деталях.
Начинаем с фронтенда
Создадим простой, но современный лендинг. Никаких тяжелых фреймворков — только HTML5, CSS и чистый JavaScript.
<!DOCTYPE html>
<html lang="ru">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Заказать звонок</title>
<style>
.callback-form {
display: none;
position: fixed;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
background: white;
padding: 20px;
border-radius: 8px;
box-shadow: 0 0 10px rgba(0,0,0,0.1);
}
.callback-form.active {
display: block;
}
.overlay {
display: none;
position: fixed;
top: 0;
left: 0;
width: 100%;
height: 100%;
background: rgba(0,0,0,0.5);
}
.overlay.active {
display: block;
}
</style>
</head>
<body>
<button onclick="showForm()">Заказать звонок</button>
<div class="overlay" id="overlay"></div>
<div class="callback-form" id="callbackForm">
<h2>Заказать обратный звонок</h2>
<form id="phoneForm">
<input type="text" id="name" placeholder="Ваше имя" required>
<input type="tel" id="phone" placeholder="+7 (___) ___-__-__" required>
<label>
<input type="checkbox" required>
Согласен с политикой конфиденциальности
</label>
<button type="submit">Отправить</button>
</form>
</div>
<script>
function showForm() {
document.getElementById('overlay').classList.add('active');
document.getElementById('callbackForm').classList.add('active');
}
document.getElementById('phoneForm').addEventListener('submit', async (e) => {
e.preventDefault();
const formData = {
name: document.getElementById('name').value,
phone: document.getElementById('phone').value
};
try {
const response = await fetch('/api/callback', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify(formData)
});
if (response.ok) {
alert('Спасибо! Мы перезвоним вам в ближайшее время.');
} else {
alert('Произошла ошибка. Попробуйте позже.');
}
} catch (error) {
console.error('Error:', error);
alert('Произошла ошибка. Попробуйте позже.');
}
});
</script>
</body>
Стандартный HTML с JavaScript может многое. Position: fixed с transform — древний как мир способ центрирования модального окна, работающий везде, от кнопочных Nokia до последних айфонов. В этом примере я не использую Bootstrap или Material UI, потому что можно сделать просто и надежно. Асинхронная отправка через fetch избавляет от перезагрузки страницы.
Пишем сервер на Go
Теперь займемся серверной частью. Нам понадобится:
обработка входящих запросов;
валидация данных;
интеграция с API Exolve;
защита от спама.
Начнем с основной структуры проекта:
package main
import (
"encoding/json"
"log"
"net/http"
"os"
"time"
)
type CallbackRequest struct {
Name string `json:"name"`
Phone string `json:"phone"`
}
type ExolveConfig struct {
ApiKey string
From string
To string
}
var (
config ExolveConfig
client *http.Client
)
func init() {
// Загружаем конфигурацию
config = ExolveConfig{
ApiKey: os.Getenv("EXOLVE_API_KEY"),
From: os.Getenv("SMS_FROM"),
To: os.Getenv("SMS_TO"),
}
// Инициализируем HTTP-клиент
client = &http.Client{
Timeout: time.Second * 10,
}
}
type ExolveResponse struct {
CallID string `json:"call_id"`
}
// Создаем структуру для отправки SMS через Exolve API
func sendSMS(phone string, name string) error {
smsBody := struct {
Number string `json:"number"` // Отправитель
Destination string `json:"destination"` // Получатель
Text string `json:"text"` // Текст сообщения
}{
Number: config.From,
Destination: phone,
Text: fmt.Sprintf("Здравствуйте, %s! Мы получили ваш запрос на обратный звонок и свяжемся с вами в ближайшее время.", name),
}
jsonData, err := json.Marshal(smsBody)
if err != nil {
return fmt.Errorf("ошибка при формировании SMS: %v", err)
}
req, err := http.NewRequest("POST", "https://api.exolve.ru/messaging/v1/SendSMS", bytes.NewBuffer(jsonData))
if err != nil {
return fmt.Errorf("ошибка при создании запроса: %v", err)
}
req.Header.Set("Content-Type", "application/json")
req.Header.Set("Authorization", fmt.Sprintf("Bearer %s", config.ApiKey))
resp, err := client.Do(req)
if err != nil {
return fmt.Errorf("ошибка при отправке SMS: %v", err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return fmt.Errorf("неожиданный статус ответа: %d", resp.StatusCode)
}
return nil
}
// Обработчик для API обратного звонка
func handleCallback(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
http.Error(w, "Метод не поддерживается", http.StatusMethodNotAllowed)
return
}
var request CallbackRequest
if err := json.NewDecoder(r.Body).Decode(&request); err != nil {
http.Error(w, "Ошибка при разборе запроса", http.StatusBadRequest)
return
}
// Валидация номера телефона
if !validatePhone(request.Phone) {
http.Error(w, "Некорректный номер телефона", http.StatusBadRequest)
return
}
// Проверка на спам через Redis или другое хранилище
if isSpamRequest(request.Phone) {
http.Error(w, "Слишком много запросов", http.StatusTooManyRequests)
return
}
// Отправляем SMS
if err := sendSMS(request.Phone, request.Name); err != nil {
log.Printf("Ошибка при отправке SMS: %v", err)
http.Error(w, "Внутренняя ошибка сервера", http.StatusInternalServerError)
return
}
w.WriteHeader(http.StatusOK)
json.NewEncoder(w).Encode(map[string]string{
"status": "success",
"message": "Заявка принята",
})
}
func main() {
// Настраиваем CORS
corsMiddleware := cors.New(cors.Options{
AllowedOrigins: []string{"*"}, // В продакшне заменить на конкретные домены
AllowedMethods: []string{"GET", "POST", "OPTIONS"},
AllowedHeaders: []string{"Content-Type", "Authorization"},
})
// Настройка маршрутов
mux := http.NewServeMux()
mux.HandleFunc("/api/callback", handleCallback)
// Оборачиваем наш мультиплексор в CORS middleware
handler := corsMiddleware.Handler(mux)
// Запуск сервера
log.Printf("Сервер запущен на порту :8080")
if err := http.ListenAndServe(":8080", handler); err != nil {
log.Fatal(err)
}
}
Ядро системы — встроенный http-пакет Go. Без лишних фреймворков и зависимостей. Так проще масштабировать и удобнее дебажить. Плюс меньше кода — меньше багов.
CallbackRequest-структура намеренно простая: только имя и телефон. Расширить всегда успеем, а вот выкинуть лишнее потом — та еще головная боль.
ExolveConfig держит настройки API в одном месте. Загружаем из переменных окружения — классика 12 factor app. Хардкодить креды в код в 2024 — моветон, да и DevOps нас не поймет.
Безопасность и валидация
Добавим функции валидации и защиты от спама:
func validatePhone(phone string) bool {
// Очищаем номер от всего, кроме цифр
re := regexp.MustCompile(`\D`)
cleanPhone := re.ReplaceAllString(phone, "")
// Проверяем длину и начало номера
if len(cleanPhone) != 11 {
return false
}
if !strings.HasPrefix(cleanPhone, "7") {
return false
}
return true
}
// Простая проверка на спам через in-memory-хранилище
// В реальном проекте лучше использовать Redis
var (
requestLock sync.RWMutex
requestCounter = make(map[string]*rateLimiter)
)
type rateLimiter struct {
count int
firstCall time.Time
}
func isSpamRequest(phone string) bool {
requestLock.Lock()
defer requestLock.Unlock()
now := time.Now()
limiter, exists := requestCounter[phone]
if !exists {
requestCounter[phone] = &rateLimiter{
count: 1,
firstCall: now,
}
return false
}
// Сбрасываем счетчик каждый час
if now.Sub(limiter.firstCall) > time.Hour {
limiter.count = 1
limiter.firstCall = now
return false
}
limiter.count++
// Ограничиваем до 3 запросов в час
return limiter.count > 3
}
ValidatePhone проверяет номер по длине и первой цифре. Никаких хитрых регулярок — они только усложняют поддержку. К тому же валидация номера на бэкенде — это последний рубеж обороны, основную работу должен делать фронт.
Защита от спама через in-memory-хранилище не идеал, но для начала сойдет. Три запроса в час от одного номера — адекватный лимит. Redis сразу не используем — начинаем с простого, усложняем по необходимости.
Улучшаем наш сервис
Но сначала посмотрим, как сделать обработку звонков через Voice API от Exolve. Это позволит нам не только отправлять SMS, но и автоматически совершать звонки.
type VoiceConfig struct {
ServiceID string // ID нашего голосового сервиса
Source string // Номер, с которого будем звонить
}
func makeCallback(phoneNumber string) error {
callbackBody := struct {
Source string `json:"source"`
Destination string `json:"destination"`
ServiceID string `json:"service_id"`
}{
Source: config.Voice.Source,
Destination: phoneNumber,
ServiceID: config.Voice.ServiceID,
}
jsonData, err := json.Marshal(callbackBody)
if err != nil {
return fmt.Errorf("ошибка при формировании запроса: %v", err)
}
req, err := http.NewRequest("POST", "https://api.exolve.ru/call/v1/MakeCallback", bytes.NewBuffer(jsonData))
if err != nil {
return fmt.Errorf("ошибка при создании запроса: %v", err)
}
req.Header.Set("Content-Type", "application/json")
req.Header.Set("Authorization", fmt.Sprintf("Bearer %s", config.ApiKey))
resp, err := client.Do(req)
if err != nil {
return fmt.Errorf("ошибка при выполнении запроса: %v", err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return fmt.Errorf("неожиданный статус ответа: %d", resp.StatusCode)
}
return nil
}
MakeCallback — наша связь с Voice API от Exolve. Структура запроса максимально прозрачная: откуда звоним, куда звоним, какой сервис используем. Никакой самодеятельности — только то, что реально нужно.
Логирование и мониторинг
Важная часть любого сервиса — отслеживание его работы. Добавим структурированное логирование:
type LogEntry struct {
Time time.Time `json:"time"`
Level string `json:"level"`
Phone string `json:"phone"`
Name string `json:"name"`
Status string `json:"status"`
Error string `json:"error,omitempty"`
UserAgent string `json:"user_agent"`
IP string `json:"ip"`
}
func logRequest(r *http.Request, phone, name, status string, err error) {
entry := LogEntry{
Time: time.Now(),
Level: "info",
Phone: phone,
Name: name,
Status: status,
UserAgent: r.UserAgent(),
IP: r.RemoteAddr,
}
if err != nil {
entry.Level = "error"
entry.Error = err.Error()
}
jsonEntry, _ := json.Marshal(entry)
log.Println(string(jsonEntry))
}
LogEntry-структура — наш швейцарский нож для отладки. Время, уровень, телефон, имя, статус — все, что поможет понять, что пошло не так. UserAgent и IP — бонусом для особо пытливых DevOps.
Добавляем метрики
Prometheus стал стандартом де-факто для мониторинга. Добавим базовые метрики:
var (
requestsTotal = promauto.NewCounterVec(
prometheus.CounterOpts{
Name: "callback_requests_total",
Help: "Общее количество запросов на обратный звонок",
},
[]string{"status"},
)
requestDuration = promauto.NewHistogramVec(
prometheus.HistogramOpts{
Name: "callback_request_duration_seconds",
Help: "Время обработки запроса",
Buckets: []float64{.005, .01, .025, .05, .1, .25, .5, 1, 2.5, 5, 10},
},
[]string{"status"},
)
)
// Оборачиваем наш обработчик для сбора метрик
func metricsMiddleware(next http.HandlerFunc) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
start := time.Now()
// Создаем свой ResponseWriter для отслеживания статуса
srw := &statusResponseWriter{ResponseWriter: w}
next.ServeHTTP(srw, r)
duration := time.Since(start).Seconds()
status := fmt.Sprintf("%d", srw.status)
requestsTotal.WithLabelValues(status).Inc()
requestDuration.WithLabelValues(status).Observe(duration)
}
}
type statusResponseWriter struct {
http.ResponseWriter
status int
}
func (w *statusResponseWriter) WriteHeader(status int) {
w.status = status
w.ResponseWriter.WriteHeader(status)
}
RequestsTotal и requestDuration — два основных элемента нашего мониторинга. Первый считает запросы, второй измеряет время. Лишние метрики лучше не вводить: этих двух уже достаточно, чтобы следить за здоровьем сервиса.
Кэширование и оптимизация
Добавим Redis для более надежного контроля спама и кэширования:
type Cache struct {
client *redis.Client
}
func NewCache(addr string) (*Cache, error) {
client := redis.NewClient(&redis.Options{
Addr: addr,
})
// Проверяем подключение
if err := client.Ping().Err(); err != nil {
return nil, fmt.Errorf("ошибка подключения к Redis: %v", err)
}
return &Cache{client: client}, nil
}
func (c *Cache) CheckSpam(phone string) (bool, error) {
key := fmt.Sprintf("spam:%s", phone)
// Получаем количество запросов
count, err := c.client.Get(key).Int()
if err == redis.Nil {
// Ключа нет, создаем новый
err = c.client.Set(key, 1, time.Hour).Err()
return false, err
}
if err != nil {
return false, err
}
// Увеличиваем счетчик
count++
err = c.client.Set(key, count, time.Hour).Err()
if err != nil {
return false, err
}
return count > 3, nil
}
Cache-структура оборачивает клиент Redis. Проверка спама теперь надежнее: счетчики живут час и не боятся перезапуска сервера. А для больших нагрузок Redis — самое то: быстрый, надежный, проверенный временем.
Деплой и конфигурация
Для контейнеризации используем Docker:
FROM golang:1.21-alpine AS builder
WORKDIR /app
COPY go.mod go.sum ./
RUN go mod download
COPY . .
RUN CGO_ENABLED=0 GOOS=linux go build -o main .
FROM alpine:latest
RUN apk --no-cache add ca-certificates
WORKDIR /root/
COPY --from=builder /app/main .
COPY config.yaml .
EXPOSE 8080
CMD ["./main"]
Dockerfile разворачиваем в две стадии: сначала собираем, потом упаковываем. Минимум слоев — минимум проблем. Используем как базовый образ легкий и быстрый Alpine Linux.
И docker-compose для локальной разработки:
version: '3'
services:
app:
build: .
ports:
- "8080:8080"
environment:
- REDIS_URL=redis:6379
- EXOLVE_API_KEY=${EXOLVE_API_KEY}
depends_on:
- redis
redis:
image: redis:alpine
ports:
- "6379:6379"
prometheus:
image: prom/prometheus
ports:
- "9090:9090"
volumes:
- ./prometheus.yml:/etc/prometheus/prometheus.yml
grafana:
image: grafana/grafana
ports:
- "3000:3000"
environment:
- GF_SECURITY_ADMIN_PASSWORD=secret
depends_on:
- prometheus
Docker-compose хорошо подходит для нашей задачи. Он объединяет Redis, Prometheus, Grafana и позволяет избежать проблем с настройкой окружения.
Что у нас получилось в итоге
Мы создали простой, но надежный сервис обратного звонка с защитой от спама, мониторингом и логированием. Благодаря интеграции с Exolve API наше решение может не только отправлять SMS, но и автоматически совершать звонки. Однако все-таки стоит помнить, что это лишь учебный прототип и демонстрация того, как можно использовать Exolve в вашей работе. На его основе вам предстоит уже решить, как вы будете делать решение, которое подойдет конкретно для ваших задач и потребностей.
Если у вас возникли вопросы по интеграции с Exolve API или масштабированию сервиса, пишите в комментариях — обязательно отвечу.
tuxi
Ну в случае отправки смс, бэкенд как раз это основной рубеж обороны. Надо делать наиболее полную защиту, вплоть до изменяемых имен формы в сессии и тд. Иначе можно стать лакомым кусочком для смс-бомберов.
tuxi
И еше совет. Если вы идентифицировали запрос как спамбота, отдайте ему код 200, иначе спамер будет сразу копать глубже, а так у вас будет время, пока он поймет (если ваще поймет) что его дурят.