diff --git a/main.go b/main.go index b98484a..11b4c05 100755 --- a/main.go +++ b/main.go @@ -158,6 +158,9 @@ func main() { &models.UmindEventoLog{}, // API Keys de /api/v2 (token + IP obligatoria + scopes) &models.ApiKey{}, + // Integraciones: OCR y transcripción de audio (servicios propios) + &models.OcrConfig{}, + &models.WhisperAsrConfig{}, } for _, m := range modelosBase { if err := app.Http.Database.DB.AutoMigrate(m); err != nil { diff --git a/migrations/migrate.go b/migrations/migrate.go index cb6864b..a23132d 100755 --- a/migrations/migrate.go +++ b/migrations/migrate.go @@ -129,6 +129,9 @@ func Migrate() { &models.UmindEventoLog{}, // API Keys de /api/v2 (token + IP obligatoria + scopes) &models.ApiKey{}, + // Integraciones: OCR y transcripción de audio (servicios propios) + &models.OcrConfig{}, + &models.WhisperAsrConfig{}, } for _, m := range modelosPrincipales { if err := app.Http.Database.DB.Migrator().AutoMigrate(m); err != nil { @@ -571,6 +574,8 @@ func SeedIntegraciones() { {"Hostinger", "Panel de VPS, dominios, hosting y DNS de Hostinger", "/app/hostinger"}, {"Cloudflare", "Gestión de zonas, DNS, SSL y firewall en Cloudflare", "/app/cloudflare"}, {"VCard API", "Integración con el sistema VCard externo (Laravel + Sanctum): usuarios, membresías, vcards, pagos y más", "/app/vcard-api"}, + {"OCR", "Extracción de texto de imágenes (comprobantes, capturas) vía servicio OCR propio", "/app/ocr"}, + {"Whisper ASR", "Transcripción de audio vía servicio propio de reconocimiento de voz (self-hosted)", "/app/whisper-asr"}, } var insertados []models.Submodules diff --git a/pkg/models/ocr_config.go b/pkg/models/ocr_config.go new file mode 100644 index 0000000..029ecee --- /dev/null +++ b/pkg/models/ocr_config.go @@ -0,0 +1,41 @@ +package models + +import ( + "github.com/sujit-baniya/fiber-boilerplate/app" + "gorm.io/gorm" +) + +// OcrConfig almacena la conexión al servicio propio de OCR (extracción de +// texto de imágenes, ej. comprobantes de pago). Solo un registro activo a +// la vez, mismo patrón que HostingerConfig/WebSmsConfig. +type OcrConfig struct { + gorm.Model + BaseURL string `json:"base_url" gorm:"column:base_url;type:text;not null"` // ej: https://ocr.u-s.app/extract + Token string `json:"token" gorm:"column:token;type:text;not null"` // Bearer token + Notas string `json:"notas" gorm:"column:notas;type:text"` + Activo bool `json:"activo" gorm:"column:activo;default:true"` +} + +func (OcrConfig) TableName() string { return "ocr_config" } + +func GetOcrConfig() (*OcrConfig, error) { + var item OcrConfig + if err := app.Http.Database.DB.Where("activo = ?", true).Order("id DESC").First(&item).Error; err != nil { + return nil, err + } + return &item, nil +} + +func SaveOcrConfig(s OcrConfig) error { + app.Http.Database.DB.Model(&OcrConfig{}).Where("activo = ?", true).Update("activo", false) + s.Activo = true + if s.ID > 0 { + return app.Http.Database.DB.Model(&s).Updates(map[string]interface{}{ + "base_url": s.BaseURL, + "token": s.Token, + "notas": s.Notas, + "activo": true, + }).Error + } + return app.Http.Database.DB.Create(&s).Error +} diff --git a/pkg/models/whisper_asr_config.go b/pkg/models/whisper_asr_config.go new file mode 100644 index 0000000..8e85ae4 --- /dev/null +++ b/pkg/models/whisper_asr_config.go @@ -0,0 +1,44 @@ +package models + +import ( + "github.com/sujit-baniya/fiber-boilerplate/app" + "gorm.io/gorm" +) + +// WhisperAsrConfig almacena la conexión al servicio propio de transcripción +// de audio (whisper-asr-webservice self-hosted, autenticado con Basic Auth) +// — distinto del Whisper de OpenAI que ya se configura vía AiConfig con +// modulo "whisper" para el bot de Telegram. Solo un registro activo a la vez. +type WhisperAsrConfig struct { + gorm.Model + BaseURL string `json:"base_url" gorm:"column:base_url;type:text;not null"` // ej: https://whisper.u-s.app/asr + Username string `json:"username" gorm:"column:username;size:255;not null"` + Password string `json:"password" gorm:"column:password;type:text;not null"` + Notas string `json:"notas" gorm:"column:notas;type:text"` + Activo bool `json:"activo" gorm:"column:activo;default:true"` +} + +func (WhisperAsrConfig) TableName() string { return "whisper_asr_config" } + +func GetWhisperAsrConfig() (*WhisperAsrConfig, error) { + var item WhisperAsrConfig + if err := app.Http.Database.DB.Where("activo = ?", true).Order("id DESC").First(&item).Error; err != nil { + return nil, err + } + return &item, nil +} + +func SaveWhisperAsrConfig(s WhisperAsrConfig) error { + app.Http.Database.DB.Model(&WhisperAsrConfig{}).Where("activo = ?", true).Update("activo", false) + s.Activo = true + if s.ID > 0 { + return app.Http.Database.DB.Model(&s).Updates(map[string]interface{}{ + "base_url": s.BaseURL, + "username": s.Username, + "password": s.Password, + "notas": s.Notas, + "activo": true, + }).Error + } + return app.Http.Database.DB.Create(&s).Error +} diff --git a/pkg/services/ocr_service.go b/pkg/services/ocr_service.go new file mode 100644 index 0000000..1bf7ceb --- /dev/null +++ b/pkg/services/ocr_service.go @@ -0,0 +1,67 @@ +package services + +import ( + "bytes" + "encoding/base64" + "encoding/json" + "fmt" + "io" + "net/http" + "time" + + "github.com/sujit-baniya/fiber-boilerplate/pkg/models" +) + +var ocrHTTPClient = &http.Client{Timeout: 30 * time.Second} + +// ExtraerTextoOCR manda una imagen al servicio propio de OCR y devuelve el +// texto extraído. mimeType ej: "image/png", "image/jpeg". +func ExtraerTextoOCR(imagenBytes []byte, mimeType string) (string, error) { + cfg, err := models.GetOcrConfig() + if err != nil { + return "", fmt.Errorf("el servicio de OCR no está configurado (Integraciones → OCR)") + } + + body, err := json.Marshal(map[string]string{ + "image_base64": base64.StdEncoding.EncodeToString(imagenBytes), + "mime_type": mimeType, + }) + if err != nil { + return "", err + } + + req, err := http.NewRequest(http.MethodPost, cfg.BaseURL, bytes.NewReader(body)) + if err != nil { + return "", err + } + req.Header.Set("Authorization", "Bearer "+cfg.Token) + req.Header.Set("Content-Type", "application/json") + + resp, err := ocrHTTPClient.Do(req) + if err != nil { + return "", fmt.Errorf("no se pudo contactar el servicio de OCR: %w", err) + } + defer resp.Body.Close() + raw, _ := io.ReadAll(io.LimitReader(resp.Body, 2*1024*1024)) + + if resp.StatusCode < 200 || resp.StatusCode >= 300 { + return "", fmt.Errorf("el servicio de OCR respondió %d: %s", resp.StatusCode, string(raw)) + } + + var out struct { + Success bool `json:"success"` + Text string `json:"text"` + Error string `json:"error"` + } + if err := json.Unmarshal(raw, &out); err != nil { + return "", fmt.Errorf("respuesta inesperada del servicio de OCR: %s", string(raw)) + } + if !out.Success { + msg := out.Error + if msg == "" { + msg = "el servicio de OCR no pudo procesar la imagen" + } + return "", fmt.Errorf("%s", msg) + } + return out.Text, nil +} diff --git a/pkg/services/whisper_asr_service.go b/pkg/services/whisper_asr_service.go new file mode 100644 index 0000000..0379652 --- /dev/null +++ b/pkg/services/whisper_asr_service.go @@ -0,0 +1,67 @@ +package services + +import ( + "bytes" + "encoding/json" + "fmt" + "io" + "mime/multipart" + "net/http" + "time" + + "github.com/sujit-baniya/fiber-boilerplate/pkg/models" +) + +var whisperAsrHTTPClient = &http.Client{Timeout: 120 * time.Second} // audio largo puede tardar + +// TranscribirAudioSelfHosted manda un archivo de audio al servicio propio de +// transcripción (whisper-asr-webservice, Basic Auth) y devuelve el texto. +func TranscribirAudioSelfHosted(audioBytes []byte, filename string) (string, error) { + cfg, err := models.GetWhisperAsrConfig() + if err != nil { + return "", fmt.Errorf("el servicio de transcripción no está configurado (Integraciones → Whisper ASR)") + } + if filename == "" { + filename = "audio.wav" + } + + var buf bytes.Buffer + w := multipart.NewWriter(&buf) + fw, err := w.CreateFormFile("audio_file", filename) + if err != nil { + return "", err + } + if _, err := fw.Write(audioBytes); err != nil { + return "", err + } + _ = w.WriteField("response_format", "json") + if err := w.Close(); err != nil { + return "", err + } + + req, err := http.NewRequest(http.MethodPost, cfg.BaseURL, &buf) + if err != nil { + return "", err + } + req.Header.Set("Content-Type", w.FormDataContentType()) + req.SetBasicAuth(cfg.Username, cfg.Password) + + resp, err := whisperAsrHTTPClient.Do(req) + if err != nil { + return "", fmt.Errorf("no se pudo contactar el servicio de transcripción: %w", err) + } + defer resp.Body.Close() + raw, _ := io.ReadAll(io.LimitReader(resp.Body, 2*1024*1024)) + + if resp.StatusCode < 200 || resp.StatusCode >= 300 { + return "", fmt.Errorf("el servicio de transcripción respondió %d: %s", resp.StatusCode, string(raw)) + } + + var out struct { + Text string `json:"text"` + } + if err := json.Unmarshal(raw, &out); err != nil { + return "", fmt.Errorf("respuesta inesperada del servicio de transcripción: %s", string(raw)) + } + return out.Text, nil +} diff --git a/resources/views/ocr_config.html b/resources/views/ocr_config.html new file mode 100644 index 0000000..d3835a2 --- /dev/null +++ b/resources/views/ocr_config.html @@ -0,0 +1,107 @@ +
Extracción de texto de imágenes vía servicio propio
+Transcripción de audio vía servicio propio (self-hosted)
+