chore: build

This commit is contained in:
2026-06-14 17:50:35 +02:00
parent 3c92a0371f
commit 3a0f725159
93 changed files with 5311 additions and 4224 deletions
@@ -0,0 +1,536 @@
package services
import (
"encoding/json"
"fmt"
"io"
"math"
"net/http"
"net/url"
"strings"
"time"
"unicode"
"golang.org/x/text/runes"
"golang.org/x/text/transform"
"golang.org/x/text/unicode/norm"
)
// ============================================
// TYPES
// ============================================
// AddressSuggestion représente une suggestion de correction
type AddressSuggestion struct {
OriginalAddress string `json:"original_address"`
CorrectedAddress string `json:"corrected_address"`
Coordinates Coordinates `json:"coordinates"`
Confidence float64 `json:"confidence"` // 0.0 à 1.0
CorrectionApplied bool `json:"correction_applied"` // true si une correction a été faite
Source string `json:"source"` // "exact", "fuzzy", "structured"
}
// NominatimSuggestion représente une réponse de l'API Nominatim
type NominatimSuggestion struct {
Latitude float64 `json:"lat,string"`
Longitude float64 `json:"lon,string"`
DisplayName string `json:"display_name"`
Importance float64 `json:"importance"`
Type string `json:"type"`
Class string `json:"class"`
Address struct {
HouseNumber string `json:"house_number"`
Road string `json:"road"`
City string `json:"city"`
Town string `json:"town"`
Village string `json:"village"`
Postcode string `json:"postcode"`
Country string `json:"country"`
CountryCode string `json:"country_code"`
} `json:"address"`
}
// AddressCorrectionService gère la correction des adresses
type AddressCorrectionService struct {
httpClient *http.Client
geoService *GeoService
}
// NewAddressCorrectionService crée une instance du service de correction
func NewAddressCorrectionService(geoService *GeoService) *AddressCorrectionService {
return &AddressCorrectionService{
httpClient: &http.Client{Timeout: 10 * time.Second},
geoService: geoService,
}
}
// ============================================
// POINT D'ENTRÉE PRINCIPAL
// ============================================
// ResolveAddress tente de géocoder une adresse avec correction automatique.
// Retourne toujours une suggestion, même approximative.
// Ordre de résolution :
// 1. Géocodage exact → succès immédiat
// 2. Nominatim fuzzy search (addressdetails + limit=5)
// 3. Décomposition structurée de l'adresse
// 4. Erreur explicite avec suggestions si dispo
func (acs *AddressCorrectionService) ResolveAddress(rawAddress string) (*AddressSuggestion, error) {
rawAddress = strings.TrimSpace(rawAddress)
if rawAddress == "" {
return nil, fmt.Errorf("adresse vide")
}
// ── Étape 1 : essai exact via GeoService (utilise le cache Redis) ──
if loc, err := acs.geoService.GeocodeAddress(rawAddress); err == nil {
return &AddressSuggestion{
OriginalAddress: rawAddress,
CorrectedAddress: rawAddress,
Coordinates: Coordinates{Latitude: loc.Latitude, Longitude: loc.Longitude},
Confidence: 1.0,
CorrectionApplied: false,
Source: "exact",
}, nil
}
// ── Étape 2 : fuzzy search Nominatim ──
if suggestion, err := acs.nominatimFuzzySearch(rawAddress); err == nil {
return suggestion, nil
}
// ── Étape 3 : décomposition structurée ──
if suggestion, err := acs.structuredSearch(rawAddress); err == nil {
return suggestion, nil
}
return nil, fmt.Errorf("adresse introuvable : '%s' — vérifiez l'orthographe ou le code postal", rawAddress)
}
// ============================================
// ÉTAPE 2 : FUZZY SEARCH NOMINATIM
// ============================================
// nominatimFuzzySearch interroge Nominatim avec plusieurs variantes de l'adresse
func (acs *AddressCorrectionService) nominatimFuzzySearch(address string) (*AddressSuggestion, error) {
variants := buildAddressVariants(address)
for _, variant := range variants {
suggestions, err := acs.queryNominatim(variant, 5)
if err != nil || len(suggestions) == 0 {
continue
}
best := suggestions[0]
confidence := computeConfidence(address, best.DisplayName, best.Importance)
// On accepte si la confiance est suffisante
if confidence >= 0.40 {
corrected := formatNominatimAddress(best)
return &AddressSuggestion{
OriginalAddress: address,
CorrectedAddress: corrected,
Coordinates: Coordinates{Latitude: best.Latitude, Longitude: best.Longitude},
Confidence: confidence,
CorrectionApplied: !strings.EqualFold(normalize(address), normalize(corrected)),
Source: "fuzzy",
}, nil
}
}
return nil, fmt.Errorf("aucune correspondance fuzzy trouvée")
}
// queryNominatim exécute une requête vers l'API Nominatim
func (acs *AddressCorrectionService) queryNominatim(query string, limit int) ([]NominatimSuggestion, error) {
query = strings.TrimSpace(query)
if query == "" {
return nil, fmt.Errorf("requête vide")
}
params := url.Values{}
params.Set("q", query)
params.Set("format", "json")
params.Set("addressdetails", "1")
params.Set("limit", fmt.Sprintf("%d", limit))
params.Set("accept-language", "fr")
fullURL := fmt.Sprintf("%s?%s", NominatimBaseURL, params.Encode())
req, err := http.NewRequest("GET", fullURL, nil)
if err != nil {
return nil, err
}
req.Header.Set("User-Agent", "DeliveryApp/1.0 (address-correction)")
// Respect du rate-limit Nominatim : 1 req/s
time.Sleep(1100 * time.Millisecond)
resp, err := acs.httpClient.Do(req)
if err != nil {
return nil, err
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return nil, fmt.Errorf("Nominatim status %d", resp.StatusCode)
}
body, err := io.ReadAll(resp.Body)
if err != nil {
return nil, err
}
var results []NominatimSuggestion
if err := json.Unmarshal(body, &results); err != nil {
return nil, err
}
return results, nil
}
// ============================================
// ÉTAPE 3 : RECHERCHE STRUCTURÉE
// ============================================
// structuredSearch décompose l'adresse et cherche les parties clés
func (acs *AddressCorrectionService) structuredSearch(address string) (*AddressSuggestion, error) {
parts := parseAddressParts(address)
// Essai 1 : numéro + rue + ville (sans code postal)
if parts.streetNumber != "" && parts.streetName != "" && parts.city != "" {
q := fmt.Sprintf("%s %s, %s", parts.streetNumber, parts.streetName, parts.city)
if s, err := acs.nominatimFuzzySearch(q); err == nil {
s.OriginalAddress = address
s.Source = "structured"
return s, nil
}
}
// Essai 2 : rue + code postal uniquement
if parts.streetName != "" && parts.postcode != "" {
q := fmt.Sprintf("%s, %s", parts.streetName, parts.postcode)
if s, err := acs.nominatimFuzzySearch(q); err == nil {
s.OriginalAddress = address
s.Source = "structured"
return s, nil
}
}
// Essai 3 : ville + code postal comme zone de repli
if parts.city != "" && parts.postcode != "" {
q := fmt.Sprintf("%s %s, France", parts.city, parts.postcode)
suggestions, err := acs.queryNominatim(q, 3)
if err == nil && len(suggestions) > 0 {
best := suggestions[0]
return &AddressSuggestion{
OriginalAddress: address,
CorrectedAddress: best.DisplayName,
Coordinates: Coordinates{Latitude: best.Latitude, Longitude: best.Longitude},
Confidence: 0.30, // faible : seulement ville/CP trouvés
CorrectionApplied: true,
Source: "structured_partial",
}, nil
}
}
return nil, fmt.Errorf("recherche structurée échouée")
}
// ============================================
// VARIANTES D'ADRESSE
// ============================================
// buildAddressVariants génère plusieurs variantes d'une adresse pour maximiser les chances
func buildAddressVariants(address string) []string {
variants := []string{address}
normalized := normalize(address)
// Variante sans accents
if normalized != address {
variants = append(variants, normalized)
}
// Variante avec "France" si absent
if !strings.Contains(strings.ToLower(address), "france") {
variants = append(variants, address+", France")
}
// Variante en corrigeant les abréviations courantes françaises
expanded := expandFrenchAbbreviations(address)
if expanded != address {
variants = append(variants, expanded)
variants = append(variants, expanded+", France")
}
// Variante en supprimant les mots de liaison potentiellement mal orthographiés
simplified := simplifyStreetName(address)
if simplified != address {
variants = append(variants, simplified)
}
// Dédoublonnage tout en conservant l'ordre
seen := map[string]bool{}
unique := make([]string, 0, len(variants))
for _, v := range variants {
if !seen[v] {
seen[v] = true
unique = append(unique, v)
}
}
return unique
}
// expandFrenchAbbreviations remplace les abréviations courantes
func expandFrenchAbbreviations(address string) string {
replacements := []struct{ from, to string }{
{"Av.", "Avenue"},
{"Ave.", "Avenue"},
{"Bd.", "Boulevard"},
{"Bld.", "Boulevard"},
{"Blvd.", "Boulevard"},
{"Rte.", "Route"},
{"Rte ", "Route "},
{"Imp.", "Impasse"},
{"Cité", "Cité"},
{"Sq.", "Square"},
{"Pl.", "Place"},
{"Rés.", "Résidence"},
}
result := address
for _, r := range replacements {
result = strings.ReplaceAll(result, r.from, r.to)
}
return result
}
// simplifyStreetName essaie de nettoyer la rue (retire les particules ambiguës)
func simplifyStreetName(address string) string {
// Ex: "20 Rue Gabriel le Pan de Ligny" → essai sans "le" → "20 Rue Gabriel Pan de Ligny"
// Heuristique légère : on ne modifie que si la chaîne est suffisamment longue
words := strings.Fields(address)
if len(words) < 5 {
return address
}
// Retire les articles intégrés dans le nom de rue (heuristique)
articles := map[string]bool{"le": true, "la": true, "les": true, "de": true, "du": true, "des": true, "d": true}
filtered := make([]string, 0, len(words))
for i, w := range words {
lower := strings.ToLower(w)
// Garder le premier mot (numéro) et les mots non-articles, ou les articles en début de nom de rue
if i < 2 || !articles[lower] {
filtered = append(filtered, w)
}
}
result := strings.Join(filtered, " ")
if result == address {
return address
}
return result
}
// ============================================
// UTILITAIRES
// ============================================
// addressParts regroupe les composants décomposés d'une adresse
type addressParts struct {
streetNumber string
streetName string
postcode string
city string
}
// parseAddressParts analyse une adresse libre pour en extraire les composants
func parseAddressParts(address string) addressParts {
var parts addressParts
// Extraction du code postal (5 chiffres consécutifs)
words := strings.Fields(address)
remaining := make([]string, 0, len(words))
for _, w := range words {
if isPostcode(w) {
parts.postcode = w
} else {
remaining = append(remaining, w)
}
}
if len(remaining) == 0 {
return parts
}
// Premier mot numérique → numéro de rue
if isNumeric(remaining[0]) {
parts.streetNumber = remaining[0]
remaining = remaining[1:]
}
// Détection de la ville : dernier groupe après le code postal
// Heuristique : si le dernier mot est une ville connue ou commence par une maj
if len(remaining) > 0 {
last := remaining[len(remaining)-1]
if len(last) > 2 && last[0] >= 'A' && last[0] <= 'Z' {
parts.city = last
remaining = remaining[:len(remaining)-1]
}
}
parts.streetName = strings.Join(remaining, " ")
return parts
}
// computeConfidence calcule un score de similarité entre l'adresse originale et la suggestion
func computeConfidence(original, suggested string, nominatimImportance float64) float64 {
origNorm := normalize(strings.ToLower(original))
suggNorm := normalize(strings.ToLower(suggested))
// Score de similarité sur les mots communs
origWords := strings.Fields(origNorm)
suggWords := strings.Fields(suggNorm)
commonCount := 0
for _, ow := range origWords {
if len(ow) < 3 {
continue // ignorer les petits mots
}
for _, sw := range suggWords {
if strings.Contains(sw, ow) || strings.Contains(ow, sw) || levenshteinRatio(ow, sw) > 0.75 {
commonCount++
break
}
}
}
var wordScore float64
if len(origWords) > 0 {
wordScore = float64(commonCount) / float64(len(origWords))
}
// Combinaison : 70% similarité textuelle + 30% importance Nominatim
importance := math.Min(nominatimImportance, 1.0)
return wordScore*0.70 + importance*0.30
}
// formatNominatimAddress formate l'adresse complète depuis une suggestion Nominatim
func formatNominatimAddress(s NominatimSuggestion) string {
addr := s.Address
var parts []string
if addr.HouseNumber != "" && addr.Road != "" {
parts = append(parts, addr.HouseNumber+" "+addr.Road)
} else if addr.Road != "" {
parts = append(parts, addr.Road)
}
city := addr.City
if city == "" {
city = addr.Town
}
if city == "" {
city = addr.Village
}
if addr.Postcode != "" {
parts = append(parts, addr.Postcode)
}
if city != "" {
parts = append(parts, city)
}
if len(parts) == 0 {
return s.DisplayName
}
return strings.Join(parts, ", ")
}
// normalize supprime les accents et normalise les espaces
func normalize(s string) string {
t := transform.Chain(norm.NFD, runes.Remove(runes.In(unicode.Mn)), norm.NFC)
result, _, _ := transform.String(t, s)
return strings.Join(strings.Fields(result), " ")
}
// isPostcode retourne true si le mot ressemble à un code postal français
func isPostcode(s string) bool {
if len(s) != 5 {
return false
}
for _, c := range s {
if c < '0' || c > '9' {
return false
}
}
return true
}
// isNumeric retourne true si la chaîne est entièrement numérique
func isNumeric(s string) bool {
for _, c := range s {
if c < '0' || c > '9' {
return false
}
}
return len(s) > 0
}
// levenshteinRatio retourne un ratio de similarité entre 0 et 1
func levenshteinRatio(a, b string) float64 {
d := levenshtein(a, b)
maxLen := math.Max(float64(len(a)), float64(len(b)))
if maxLen == 0 {
return 1.0
}
return 1.0 - float64(d)/maxLen
}
// levenshtein calcule la distance de Levenshtein entre deux chaînes
func levenshtein(a, b string) int {
ra, rb := []rune(a), []rune(b)
la, lb := len(ra), len(rb)
if la == 0 {
return lb
}
if lb == 0 {
return la
}
dp := make([][]int, la+1)
for i := range dp {
dp[i] = make([]int, lb+1)
dp[i][0] = i
}
for j := 0; j <= lb; j++ {
dp[0][j] = j
}
for i := 1; i <= la; i++ {
for j := 1; j <= lb; j++ {
cost := 1
if ra[i-1] == rb[j-1] {
cost = 0
}
dp[i][j] = min3(dp[i-1][j]+1, dp[i][j-1]+1, dp[i-1][j-1]+cost)
}
}
return dp[la][lb]
}
func min3(a, b, c int) int {
if a < b {
if a < c {
return a
}
return c
}
if b < c {
return b
}
return c
}
+70 -44
View File
@@ -6,10 +6,10 @@ import (
"fmt"
"gestion/models"
"io"
"log"
"math"
"net/http"
"net/url"
"os"
"strings"
"time"
@@ -28,10 +28,6 @@ const (
LocationTTL = 1 * time.Hour
)
// ============================================
// STRUCTURES
// ============================================
type GeoLocation struct {
Latitude float64 `json:"lat,string"`
Longitude float64 `json:"lon,string"`
@@ -51,43 +47,68 @@ type DeliveryDistance struct {
}
type GeoService struct {
redis *redis.Client
ctx context.Context
httpClient *http.Client
redis *redis.Client
ctx context.Context
httpClient *http.Client
correctionService *AddressCorrectionService
}
// ============================================
// CONSTRUCTEUR
// ============================================
func NewGeoService(redisClient *redis.Client, ctx context.Context) *GeoService {
return &GeoService{
gs := &GeoService{
redis: redisClient,
ctx: ctx,
httpClient: &http.Client{
Timeout: 10 * time.Second,
},
}
// Le correctionService est initialisé après, car il a besoin de gs lui-même
gs.correctionService = NewAddressCorrectionService(gs)
return gs
}
// ============================================
// GÉOCODAGE - API NOMINATIM
// ============================================
func (gs *GeoService) GeocodeAddress(address string) (*GeoLocation, error) {
// 1. Vérifier le cache Redis
location, err := gs.getFromCache(address)
if err == nil {
// 1. Cache Redis (adresse originale)
if location, err := gs.getFromCache(address); err == nil {
return location, nil
}
location, err = gs.fetchFromNominatim(address)
if err != nil {
return nil, err
// 2. Tentative directe via Nominatim
if location, err := gs.fetchFromNominatim(address); err == nil {
gs.saveToCache(address, location)
return location, nil
}
// 3. Sauvegarder en cache
// 3. ── NOUVEAU : correction automatique de l'adresse ──────────────────
// Déclenché uniquement si le géocodage direct a échoué.
log.Printf("🔍 [GEO] Géocodage direct échoué pour '%s', tentative de correction...", address)
suggestion, err := gs.correctionService.ResolveAddress(address)
if err != nil {
log.Printf("❌ [GEO] Correction impossible pour '%s': %v", address, err)
return nil, fmt.Errorf("adresse introuvable : '%s'", address)
}
if suggestion.CorrectionApplied {
log.Printf(
"✅ [GEO] Correction appliquée (confiance %.0f%%) : '%s' → '%s'",
suggestion.Confidence*100,
address,
suggestion.CorrectedAddress,
)
}
location := &GeoLocation{
Latitude: suggestion.Coordinates.Latitude,
Longitude: suggestion.Coordinates.Longitude,
DisplayName: suggestion.CorrectedAddress,
}
// Mettre en cache avec l'adresse originale pour les prochains appels
gs.saveToCache(address, location)
// Mettre en cache aussi avec l'adresse corrigée
if suggestion.CorrectionApplied {
gs.saveToCache(suggestion.CorrectedAddress, location)
}
return location, nil
}
@@ -244,32 +265,37 @@ func CalculateETA(distanceKm float64) int {
// CalculateETAWithTomTom calcule l'ETA via TomTom API (précis avec trafic réel)
// Retourne (etaMinutes, distanceKm, error)
func CalculateETAWithTomTom(from, to Coordinates) (int, float64, error) {
apiKey := os.Getenv("TOMTOM_API_KEY")
if apiKey == "" {
// Fallback sur calcul local si pas de clé API
if len(tomTomKeys.keys) == 0 {
distance := CalculateDistance(from, to)
return CalculateETA(distance), distance, nil
}
// API TomTom Routing: Calculate Route avec trafic
apiURL := fmt.Sprintf(
"https://api.tomtom.com/routing/1/calculateRoute/%f,%f:%f,%f/json?key=%s&traffic=true&travelMode=car",
from.Latitude, from.Longitude, to.Latitude, to.Longitude, apiKey,
)
client := &http.Client{Timeout: 8 * time.Second}
resp, err := client.Get(apiURL)
buildReq := func(key string) (*http.Request, error) {
u := &url.URL{
Scheme: "https",
Host: "api.tomtom.com",
Path: fmt.Sprintf("/routing/1/calculateRoute/%f,%f:%f,%f/json", from.Latitude, from.Longitude, to.Latitude, to.Longitude),
}
q := url.Values{}
q.Set("key", key)
q.Set("traffic", "true")
q.Set("travelMode", "car")
u.RawQuery = q.Encode()
return http.NewRequest(http.MethodGet, u.String(), nil)
}
resp, err := tomTomKeys.Do(client, buildReq)
if err != nil {
// Fallback sur calcul local en cas d'erreur réseau
distance := CalculateDistance(from, to)
eta := CalculateETA(distance)
fmt.Printf("⚠️ TomTom timeout, fallback: %.2f km -> %d min\n", distance, eta)
fmt.Printf("⚠️ TomTom indisponible, fallback: %.2f km -> %d min (%v)\n", distance, eta, err)
return eta, distance, nil
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
// Fallback sur calcul local en cas d'erreur API
distance := CalculateDistance(from, to)
eta := CalculateETA(distance)
fmt.Printf("⚠️ TomTom API error %d, fallback: %.2f km -> %d min\n", resp.StatusCode, distance, eta)
@@ -294,18 +320,14 @@ func CalculateETAWithTomTom(from, to Coordinates) (int, float64, error) {
}
summary := routeResponse.Routes[0].Summary
// Calculer ETA en minutes (arrondi supérieur)
etaMinutes := (summary.TravelTimeInSeconds + 59) / 60
distanceKm := float64(summary.LengthInMeters) / 1000.0
// Appliquer minimum
if etaMinutes < MinETA {
etaMinutes = MinETA
}
fmt.Printf("🛣️ TomTom: %.2f km -> %d min (trafic réel)\n", distanceKm, etaMinutes)
return etaMinutes, distanceKm, nil
}
@@ -503,13 +525,13 @@ func (gs *GeoService) GetAllDeliveryDistances(target Coordinates, availableUsern
// ============================================
// GetDeliveryHeatmap retourne toutes les positions des livreurs
func (gs *GeoService) GetDeliveryHeatmap() ([]map[string]interface{}, error) {
func (gs *GeoService) GetDeliveryHeatmap() ([]map[string]any, error) {
keys, err := gs.redis.Keys(gs.ctx, "delivery:location:*").Result()
if err != nil {
return nil, err
}
var heatmap []map[string]interface{}
var heatmap []map[string]any
for _, key := range keys {
data, err := gs.redis.Get(gs.ctx, key).Result()
@@ -517,7 +539,7 @@ func (gs *GeoService) GetDeliveryHeatmap() ([]map[string]interface{}, error) {
continue
}
var location map[string]interface{}
var location map[string]any
json.Unmarshal([]byte(data), &location)
username := key[len("delivery:location:"):]
@@ -528,3 +550,7 @@ func (gs *GeoService) GetDeliveryHeatmap() ([]map[string]interface{}, error) {
return heatmap, nil
}
func (gs *GeoService) CorrectionService() *AddressCorrectionService {
return gs.correctionService
}
+89
View File
@@ -0,0 +1,89 @@
package services
import (
"bytes"
"encoding/json"
"fmt"
"io"
"log"
"net/http"
"os"
"time"
)
var LBTelegram *LBTelegramService
type LBTelegramService struct {
gatewayURL string
Bot1Username string
Bot2Username string
client *http.Client
}
func NewLBTelegramService() *LBTelegramService {
url := os.Getenv("LBTELEGRAM_URL")
if url == "" {
url = "http://lbtelegram:8081"
}
svc := &LBTelegramService{
gatewayURL: url,
Bot1Username: os.Getenv("LBTELEGRAM_BOT1_USERNAME"),
Bot2Username: os.Getenv("LBTELEGRAM_BOT2_USERNAME"),
client: &http.Client{Timeout: 10 * time.Second},
}
LBTelegram = svc
return svc
}
func (s *LBTelegramService) IsConfigured() bool {
return os.Getenv("LBTELEGRAM_URL") != ""
}
// EnrollUser enrôle un utilisateur auprès de LBTelegram après liaison du compte.
// LBTelegram envoie lui-même le message de confirmation (chaîne Bot1→Bot2→Bot3).
func (s *LBTelegramService) EnrollUser(chatID int64, username, role string) error {
payload := map[string]interface{}{
"user_id": chatID,
"username": username,
"role": role,
"chat_id": chatID,
}
body, _ := json.Marshal(payload)
resp, err := s.client.Post(s.gatewayURL+"/enrollment/begin", "application/json", bytes.NewReader(body))
if err != nil {
return fmt.Errorf("enrollment: %w", err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
b, _ := io.ReadAll(resp.Body)
return fmt.Errorf("enrollment HTTP %d: %s", resp.StatusCode, string(b))
}
log.Printf("✅ [LB] Enrollment OK pour %s (%s)", username, role)
return nil
}
// SendNotification envoie un message via la gateway LBTelegram.
// Le bot est choisi automatiquement selon la stratégie configurée (failover/roundrobin/leastconn).
func (s *LBTelegramService) SendNotification(userID int64, message string) error {
payload := map[string]interface{}{
"user_id": userID,
"message": message,
}
body, _ := json.Marshal(payload)
resp, err := s.client.Post(s.gatewayURL+"/notify", "application/json", bytes.NewReader(body))
if err != nil {
return fmt.Errorf("notify: %w", err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
b, _ := io.ReadAll(resp.Body)
return fmt.Errorf("notify HTTP %d: %s", resp.StatusCode, string(b))
}
return nil
}
+47 -2
View File
@@ -10,7 +10,6 @@ import (
"time"
)
// TelegramBot est l'instance globale accessible depuis le package db
var TelegramBot *TelegramService
type TelegramService struct {
@@ -96,6 +95,52 @@ func (t *TelegramService) SendMessage(chatID int64, text string) error {
return nil
}
// SendMessageWithButtons envoie un message HTML avec des boutons inline (URL buttons).
// buttons est une liste de paires [texte, url].
func (t *TelegramService) SendMessageWithButtons(chatID int64, text string, buttons [][2]string) error {
if !t.IsConfigured() {
return fmt.Errorf("telegram non configuré")
}
row := make([]map[string]string, 0, len(buttons))
for _, b := range buttons {
row = append(row, map[string]string{"text": b[0], "url": b[1]})
}
payload := map[string]interface{}{
"chat_id": chatID,
"text": text,
"parse_mode": "HTML",
"reply_markup": map[string]interface{}{
"inline_keyboard": [][]map[string]string{row},
},
}
body, err := json.Marshal(payload)
if err != nil {
return fmt.Errorf("marshal: %w", err)
}
url := fmt.Sprintf("https://api.telegram.org/bot%s/sendMessage", t.botToken)
req, err := http.NewRequest("POST", url, bytes.NewBuffer(body))
if err != nil {
return fmt.Errorf("création requête: %w", err)
}
req.Header.Set("Content-Type", "application/json")
client := &http.Client{Timeout: 10 * time.Second}
resp, err := client.Do(req)
if err != nil {
return fmt.Errorf("envoi: %w", err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return fmt.Errorf("telegram API status %d", resp.StatusCode)
}
return nil
}
// SetWebhook enregistre l'URL webhook auprès de Telegram
func (t *TelegramService) SetWebhook(webhookURL string) error {
if !t.IsConfigured() {
@@ -103,7 +148,7 @@ func (t *TelegramService) SetWebhook(webhookURL string) error {
}
payload := map[string]interface{}{
"url": webhookURL,
"url": webhookURL,
"allowed_updates": []string{"message"},
}
if t.webhookSecret != "" {
+17 -15
View File
@@ -11,25 +11,30 @@ import (
"io"
"log"
"net/http"
"os"
"net/url"
"time"
)
func GetETAWithTraffic(from, to Coordinates) (etaMinutes int, distanceKm float64, err error) {
apiKey := os.Getenv("TOMTOM_API_KEY")
if apiKey == "" {
return 0, 0, fmt.Errorf("TOMTOM_API_KEY non configurée")
client := &http.Client{Timeout: 10 * time.Second}
buildReq := func(key string) (*http.Request, error) {
u := &url.URL{
Scheme: "https",
Host: "api.tomtom.com",
Path: fmt.Sprintf("/routing/1/calculateRoute/%f,%f:%f,%f/json", from.Latitude, from.Longitude, to.Latitude, to.Longitude),
}
q := url.Values{}
q.Set("key", key)
q.Set("traffic", "true")
q.Set("travelMode", "car")
u.RawQuery = q.Encode()
return http.NewRequest(http.MethodGet, u.String(), nil)
}
url := fmt.Sprintf(
"https://api.tomtom.com/routing/1/calculateRoute/%f,%f:%f,%f/json?key=%s&traffic=true&travelMode=car",
from.Latitude, from.Longitude, to.Latitude, to.Longitude, apiKey,
)
client := &http.Client{Timeout: 10 * time.Second}
resp, err := client.Get(url)
resp, err := tomTomKeys.Do(client, buildReq)
if err != nil {
return 0, 0, fmt.Errorf("erreur requête TomTom: %w", err)
return 0, 0, fmt.Errorf("erreur TomTom: %w", err)
}
defer resp.Body.Close()
@@ -53,12 +58,9 @@ func GetETAWithTraffic(from, to Coordinates) (etaMinutes int, distanceKm float64
}
summary := routeResponse.Routes[0].Summary
// Calculer ETA en minutes (arrondi supérieur)
etaMinutes = (summary.TravelTimeInSeconds + 59) / 60
distanceKm = float64(summary.LengthInMeters) / 1000.0
log.Printf("🛣️ TomTom Routing: %.2f km → %d min (trafic inclus)", distanceKm, etaMinutes)
return etaMinutes, distanceKm, nil
}
+96
View File
@@ -0,0 +1,96 @@
package services
import (
"fmt"
"io"
"log"
"net/http"
"os"
"sync/atomic"
)
type tomTomKeyManager struct {
keys []string
current atomic.Int32
}
var tomTomKeys = initTomTomKeyManager()
func initTomTomKeyManager() *tomTomKeyManager {
m := &tomTomKeyManager{}
seen := map[string]bool{}
candidates := []string{
os.Getenv("TOMTOM_API_KEY"),
os.Getenv("TOMTOM_API_KEY_1"),
os.Getenv("TOMTOM_API_KEY_2"),
os.Getenv("TOMTOM_API_KEY_3"),
}
for _, k := range candidates {
if k != "" && !seen[k] {
seen[k] = true
m.keys = append(m.keys, k)
}
}
log.Printf("🔑 [TOMTOM] %d clé(s) API configurée(s)", len(m.keys))
return m
}
// currentKey retourne la clé active et son index.
func (m *tomTomKeyManager) currentKey() (string, int) {
n := len(m.keys)
if n == 0 {
return "", -1
}
idx := int(m.current.Load()) % n
return m.keys[idx], idx
}
// rotate passe à la clé suivante.
func (m *tomTomKeyManager) rotate(fromIdx int) {
n := len(m.keys)
if n <= 1 {
return
}
next := int32((fromIdx + 1) % n)
m.current.CompareAndSwap(int32(fromIdx), next)
log.Printf("🔄 [TOMTOM] Rotation clé %d → clé %d (quota atteint)", fromIdx+1, next+1)
}
// Do exécute la requête en rotant automatiquement sur 403/429.
// buildReq doit construire une nouvelle *http.Request pour la clé donnée.
func (m *tomTomKeyManager) Do(client *http.Client, buildReq func(key string) (*http.Request, error)) (*http.Response, error) {
n := len(m.keys)
if n == 0 {
return nil, fmt.Errorf("aucune clé TomTom configurée (TOMTOM_API_KEY / TOMTOM_API_KEY_1..3)")
}
_, startIdx := m.currentKey()
for attempt := 0; attempt < n; attempt++ {
idx := (startIdx + attempt) % n
key := m.keys[idx]
req, err := buildReq(key)
if err != nil {
return nil, err
}
resp, err := client.Do(req)
if err != nil {
return nil, err
}
if resp.StatusCode == http.StatusForbidden || resp.StatusCode == http.StatusTooManyRequests {
io.Copy(io.Discard, resp.Body)
resp.Body.Close()
m.rotate(idx)
continue
}
return resp, nil
}
return nil, fmt.Errorf("toutes les clés TomTom ont atteint leur quota (%d clé(s) testée(s))", n)
}