package api import ( "context" "crypto/rand" "encoding/hex" "encoding/json" "fmt" "log/slog" "net/http" "net/url" "strings" "sync" "time" ) type WaitCallSession struct { ID string `json:"session_id"` Phone string `json:"phone"` // e.g. "79896657520" TargetPhone string `json:"target_phone"` // e.g. "79019605980" DisplayPhone string `json:"display_phone"` // e.g. "+7 (901) 960-59-80" Verified bool `json:"verified"` CreatedAt time.Time `json:"created_at"` ExpiresAt time.Time `json:"expires_at"` } type waitCallManager struct { mu sync.RWMutex sessions map[string]*WaitCallSession phoneSessions map[string][]*WaitCallSession lastAnswerPoll time.Time } var wcm = &waitCallManager{ sessions: make(map[string]*WaitCallSession), phoneSessions: make(map[string][]*WaitCallSession), } func (m *waitCallManager) set(s *WaitCallSession) { m.mu.Lock() defer m.mu.Unlock() m.sessions[s.ID] = s m.phoneSessions[s.Phone] = append(m.phoneSessions[s.Phone], s) } func (m *waitCallManager) get(id string) *WaitCallSession { m.mu.RLock() defer m.mu.RUnlock() s, ok := m.sessions[id] if !ok || time.Now().After(s.ExpiresAt) { return nil } return s } func (m *waitCallManager) isVerified(id, phone string) bool { m.mu.RLock() defer m.mu.RUnlock() s, ok := m.sessions[id] if !ok || time.Now().After(s.ExpiresAt) { return false } norm := normalizeRuPhone(phone) return s.Verified && (s.Phone == norm || s.Phone == phone) } func (m *waitCallManager) markVerified(phone string) int { m.mu.Lock() defer m.mu.Unlock() norm := normalizeRuPhone(phone) list, ok := m.phoneSessions[norm] if !ok { return 0 } count := 0 now := time.Now() for _, s := range list { if now.Before(s.ExpiresAt) { s.Verified = true count++ } } return count } func (m *waitCallManager) checkSMSCFallback(login, password, targetPhone string) { if login == "" || password == "" { return } m.mu.Lock() if time.Since(m.lastAnswerPoll) < 3*time.Second { m.mu.Unlock() return } m.lastAnswerPoll = time.Now() m.mu.Unlock() endpoint := fmt.Sprintf( "https://smsc.ru/sys/get.php?get_answers=1&hour=1&login=%s&psw=%s&fmt=3", url.QueryEscape(login), url.QueryEscape(password), ) ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) defer cancel() req, err := http.NewRequestWithContext(ctx, "GET", endpoint, nil) if err != nil { return } resp, err := http.DefaultClient.Do(req) if err != nil { return } defer resp.Body.Close() var answers []struct { ID any `json:"id"` Phone string `json:"phone"` Message string `json:"message"` Text string `json:"text"` } if err := json.NewDecoder(resp.Body).Decode(&answers); err != nil { return } for _, a := range answers { txt := a.Message if txt == "" { txt = a.Text } if strings.Contains(txt, "[waitcall]") || strings.Contains(txt, "waitcall") || txt == "" { norm := normalizeRuPhone(a.Phone) if norm != "" { m.markVerified(norm) } } } } // formatRuPhone formats Russian phone number to "+7 (XXX) XXX-XX-XX" func formatRuPhone(p string) string { d := onlyDigits(p) if len(d) == 11 && (d[0] == '7' || d[0] == '8') { return fmt.Sprintf("+7 (%s) %s-%s-%s", d[1:4], d[4:7], d[7:9], d[9:11]) } if len(d) == 10 { return fmt.Sprintf("+7 (%s) %s-%s-%s", d[0:3], d[3:6], d[6:8], d[8:10]) } return "+" + d } // normalizeRuPhone converts various formats to "7XXXXXXXXXX" func normalizeRuPhone(p string) string { d := onlyDigits(p) if len(d) == 10 { return "7" + d } if len(d) == 11 && (d[0] == '8' || d[0] == '7') { return "7" + d[1:] } return d } func (a *API) waitCallInit(w http.ResponseWriter, r *http.Request) { if a.tooMany(w, "wc_init:"+clientIP(r), 10, 5*time.Minute) { return } var in struct { Phone string `json:"phone"` } if err := json.NewDecoder(http.MaxBytesReader(w, r.Body, 1024)).Decode(&in); err != nil { fail(w, http.StatusBadRequest, "bad_json", "Некорректный запрос") return } phone := normalizeRuPhone(in.Phone) if len(phone) != 11 || phone[0] != '7' { fail(w, http.StatusBadRequest, "invalid_phone", "Укажите корректный российский номер телефона") return } if a.tooMany(w, "wc_phone:"+phone, 4, 5*time.Minute) { fail(w, http.StatusTooManyRequests, "too_many_requests", "Слишком много попыток для этого номера. Подождите пару минут.") return } endpoint := fmt.Sprintf( "https://smsc.ru/sys/wait_call.php?login=%s&psw=%s&phone=%s&fmt=3", url.QueryEscape(a.smscLogin), url.QueryEscape(a.smscPassword), url.QueryEscape(phone), ) ctx, cancel := context.WithTimeout(r.Context(), 10*time.Second) defer cancel() req, err := http.NewRequestWithContext(ctx, "GET", endpoint, nil) if err != nil { handleErr(w, err) return } resp, err := http.DefaultClient.Do(req) if err != nil { slog.Error("smsc wait_call request failed", "err", err) fail(w, http.StatusBadGateway, "smsc_error", "Сервис проверки номеров временно недоступен") return } defer resp.Body.Close() var smscResp struct { Phone string `json:"phone"` AllPhones []string `json:"all_phones"` Error string `json:"error"` ErrorCode int `json:"error_code"` } if err := json.NewDecoder(resp.Body).Decode(&smscResp); err != nil { slog.Error("smsc wait_call decode failed", "err", err) fail(w, http.StatusBadGateway, "smsc_error", "Ошибка ответа сервиса проверки") return } if smscResp.ErrorCode != 0 { slog.Warn("smsc wait_call error", "code", smscResp.ErrorCode, "err", smscResp.Error) msg := "Не удалось получить номер для проверки" if smscResp.ErrorCode == 9 { msg = "Слишком много запросов. Пожалуйста, подождите 1 минуту." } fail(w, http.StatusBadRequest, "smsc_error", msg) return } targetPhone := smscResp.Phone if targetPhone == "" && len(smscResp.AllPhones) > 0 { targetPhone = smscResp.AllPhones[0] } if targetPhone == "" { fail(w, http.StatusBadGateway, "smsc_error", "Не получен номер для звонка") return } b := make([]byte, 16) _, _ = rand.Read(b) sessionID := hex.EncodeToString(b) sess := &WaitCallSession{ ID: sessionID, Phone: phone, TargetPhone: targetPhone, DisplayPhone: formatRuPhone(targetPhone), Verified: false, CreatedAt: time.Now(), ExpiresAt: time.Now().Add(10 * time.Minute), } wcm.set(sess) ok(w, map[string]any{ "session_id": sess.ID, "target_phone": sess.TargetPhone, "display_phone": sess.DisplayPhone, "expires_in": 600, }, nil) } func (a *API) waitCallStatus(w http.ResponseWriter, r *http.Request) { sessionID := r.URL.Query().Get("session_id") if sessionID == "" { fail(w, http.StatusBadRequest, "missing_session", "Укажите session_id") return } sess := wcm.get(sessionID) if sess == nil { fail(w, http.StatusNotFound, "not_found", "Сессия не найдена или истекла") return } if !sess.Verified { wcm.checkSMSCFallback(a.smscLogin, a.smscPassword, sess.Phone) } ok(w, map[string]any{ "session_id": sess.ID, "phone": sess.Phone, "target_phone": sess.TargetPhone, "display_phone": sess.DisplayPhone, "verified": sess.Verified, }, nil) } func (a *API) smscCallback(w http.ResponseWriter, r *http.Request) { _ = r.ParseForm() phone := r.FormValue("phone") if phone == "" { phone = r.URL.Query().Get("phone") } waitcall := r.FormValue("waitcall") if waitcall == "" { waitcall = r.URL.Query().Get("waitcall") } mes := r.FormValue("mes") if phone == "" && r.Body != nil { var j struct { Phone string `json:"phone"` Waitcall any `json:"waitcall"` Mes string `json:"mes"` } _ = json.NewDecoder(r.Body).Decode(&j) if j.Phone != "" { phone = j.Phone } if j.Waitcall != nil { waitcall = fmt.Sprint(j.Waitcall) } if j.Mes != "" { mes = j.Mes } } slog.Info("smsc: callback received", "phone", phone, "waitcall", waitcall, "mes", mes) norm := normalizeRuPhone(phone) if norm != "" { verifiedCount := wcm.markVerified(norm) slog.Info("smsc: sessions marked verified", "phone", norm, "count", verifiedCount) } w.Header().Set("Content-Type", "text/plain; charset=utf-8") w.WriteHeader(http.StatusOK) _, _ = w.Write([]byte("OK")) }