diff --git a/pkg/models/ai_config.go b/pkg/models/ai_config.go index fc4f769..c3d94e1 100644 --- a/pkg/models/ai_config.go +++ b/pkg/models/ai_config.go @@ -174,3 +174,23 @@ func GetAiConfigForService(service string) (*AiConfig, error) { log.Printf("[AI_CONFIG] No se encontró config para servicio '%s', usando cualquier activa", service) return &items[0], nil } + +// GetWhisperConfig retorna la config de IA activa etiquetada específicamente con +// el módulo "whisper" (transcripción de audio). A diferencia de +// GetAiConfigForService, NO cae a una config global: si el admin no configuró +// una explícitamente para whisper, es mejor avisar con claridad que intentar +// transcribir contra un proveedor que no soporta ese endpoint (ej. Anthropic). +func GetWhisperConfig() (*AiConfig, error) { + var items []AiConfig + if err := app.Http.Database.DB.Where("is_active = ?", true).Find(&items).Error; err != nil { + return nil, fmt.Errorf("error leyendo ai_configs: %w", err) + } + for i := range items { + for _, m := range SplitModulos(items[i].Modulo) { + if m == "whisper" { + return &items[i], nil + } + } + } + return nil, fmt.Errorf("no hay ninguna configuración activa con el módulo 'whisper' en /app/ai-config") +} diff --git a/pkg/services/whisper_service.go b/pkg/services/whisper_service.go new file mode 100644 index 0000000..86dbd47 --- /dev/null +++ b/pkg/services/whisper_service.go @@ -0,0 +1,98 @@ +package services + +import ( + "bytes" + "encoding/json" + "fmt" + "io" + "mime/multipart" + "net/http" + "os" + "path/filepath" + "strings" + "time" + + "github.com/sujit-baniya/fiber-boilerplate/pkg/models" +) + +var whisperHTTPClient = &http.Client{Timeout: 90 * time.Second} + +// TranscribirAudio envía un archivo de audio a un endpoint de transcripción +// compatible con la API de OpenAI (POST /audio/transcriptions, multipart) y +// devuelve el texto. Funciona tanto con OpenAI (modelo whisper-1) como con Groq +// (modelo whisper-large-v3), reutilizando la config que ya se administra en +// /app/ai-config con el módulo "whisper". +func TranscribirAudio(ai *models.AiConfig, audioPath string) (string, error) { + if ai == nil { + return "", fmt.Errorf("no hay una configuración de Whisper activa: ve a /app/ai-config, crea o edita una y márcale el módulo 'Transcripción de audio (Whisper)'") + } + if ai.ApiKey == "" { + return "", fmt.Errorf("la configuración de Whisper '%s' no tiene API key", ai.Nombre) + } + + baseURL := ai.BaseURL + if baseURL == "" { + baseURL = providerDefaultURL(ai.Provider) + } + baseURL = strings.TrimRight(baseURL, "/") + + model := ai.ModelName + if model == "" { + model = "whisper-1" + } + + f, err := os.Open(audioPath) + if err != nil { + return "", fmt.Errorf("no se pudo abrir el audio descargado: %w", err) + } + defer f.Close() + + var body bytes.Buffer + writer := multipart.NewWriter(&body) + part, err := writer.CreateFormFile("file", filepath.Base(audioPath)) + if err != nil { + return "", err + } + if _, err := io.Copy(part, f); err != nil { + return "", fmt.Errorf("error leyendo el audio: %w", err) + } + _ = writer.WriteField("model", model) + _ = writer.WriteField("response_format", "json") + if err := writer.Close(); err != nil { + return "", err + } + + req, err := http.NewRequest("POST", baseURL+"/audio/transcriptions", &body) + if err != nil { + return "", err + } + req.Header.Set("Authorization", "Bearer "+ai.ApiKey) + req.Header.Set("Content-Type", writer.FormDataContentType()) + + resp, err := whisperHTTPClient.Do(req) + if err != nil { + return "", fmt.Errorf("no se pudo conectar con el servicio de transcripción: %w", err) + } + defer resp.Body.Close() + raw, _ := io.ReadAll(io.LimitReader(resp.Body, 1024*1024)) + + if resp.StatusCode < 200 || resp.StatusCode >= 300 { + detalle := strings.TrimSpace(string(raw)) + if len(detalle) > 300 { + detalle = detalle[:300] + } + return "", fmt.Errorf("el servicio de transcripción respondió %d: %s", resp.StatusCode, detalle) + } + + 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") + } + texto := strings.TrimSpace(out.Text) + if texto == "" { + return "", fmt.Errorf("no se detectó texto en el audio") + } + return texto, nil +} diff --git a/resources/views/ai_config.html b/resources/views/ai_config.html index cfd9017..b1e7e20 100644 --- a/resources/views/ai_config.html +++ b/resources/views/ai_config.html @@ -74,9 +74,10 @@ :class="{ 'bg-blue-100 text-blue-700': m.trim() === 'landing', 'bg-[#e9f0cf] text-[#5a7a1e]': m.trim() === 'query_runner', - 'bg-purple-100 text-purple-700': m.trim() === 'ia' + 'bg-purple-100 text-purple-700': m.trim() === 'ia', + 'bg-pink-100 text-pink-700': m.trim() === 'whisper' }" - x-text="m.trim() === 'landing' ? 'Landing' : m.trim() === 'query_runner' ? 'Query Runner' : m.trim() === 'ia' ? 'IA / vCard' : m.trim()"> + x-text="m.trim() === 'landing' ? 'Landing' : m.trim() === 'query_runner' ? 'Query Runner' : m.trim() === 'ia' ? 'IA / vCard' : m.trim() === 'whisper' ? 'Whisper' : m.trim()"> @@ -292,6 +293,7 @@ function aiConfigApp() { { value: 'landing', label: 'Landing Generator' }, { value: 'query_runner', label: 'Query Runner SQL' }, { value: 'ia', label: 'IA / vCard' }, + { value: 'whisper', label: 'Transcripción de audio (Whisper)' }, ], async init() { await this.load() }, diff --git a/rest/controllers/tarea_controller.go b/rest/controllers/tarea_controller.go index 47a9201..9854cd5 100644 --- a/rest/controllers/tarea_controller.go +++ b/rest/controllers/tarea_controller.go @@ -177,7 +177,9 @@ func AddComentario(c *fiber.Ctx) error { } contenido := strings.TrimSpace(c.FormValue("contenido")) - if contenido == "" { + file, fileErr := c.FormFile("archivo") + tieneArchivo := fileErr == nil && file != nil + if contenido == "" && !tieneArchivo { return c.Status(400).JSON(fiber.Map{"error": "contenido requerido"}) } @@ -185,8 +187,7 @@ func AddComentario(c *fiber.Ctx) error { archivosJSON := "[]" // archivo adjunto opcional - file, fileErr := c.FormFile("archivo") - if fileErr == nil && file != nil { + if tieneArchivo { if file.Size > 50*1024*1024 { return c.Status(400).JSON(fiber.Map{"error": "Máximo 50MB por archivo"}) } diff --git a/rest/controllers/telegram_agent_controller.go b/rest/controllers/telegram_agent_controller.go index 8006e9b..7278de3 100644 --- a/rest/controllers/telegram_agent_controller.go +++ b/rest/controllers/telegram_agent_controller.go @@ -3,6 +3,7 @@ package controllers import ( "fmt" "log" + "os" "strconv" "strings" @@ -37,6 +38,12 @@ type tgPhotoSize struct { Height int `json:"height"` } +type tgVoice struct { + FileID string `json:"file_id"` + Duration int `json:"duration"` + MimeType string `json:"mime_type"` +} + type tgMessage struct { MessageID int `json:"message_id"` From tgUser `json:"from"` @@ -45,6 +52,8 @@ type tgMessage struct { Caption string `json:"caption"` Document *tgDocument `json:"document"` Photo []tgPhotoSize `json:"photo"` + Voice *tgVoice `json:"voice"` + Audio *tgVoice `json:"audio"` } type tgUpdate struct { @@ -77,7 +86,8 @@ func TelegramAgentWebhook(c *fiber.Ctx) error { text := strings.TrimSpace(msg.Text) caption := strings.TrimSpace(msg.Caption) hasAttachment := msg.Document != nil || len(msg.Photo) > 0 - if text == "" && caption == "" && !hasAttachment { + hasAudio := msg.Voice != nil || msg.Audio != nil + if text == "" && caption == "" && !hasAttachment && !hasAudio { return c.SendStatus(200) } @@ -145,6 +155,46 @@ func TelegramAgentWebhook(c *fiber.Ctx) error { } } + // Nota de voz o audio: se descarga, se transcribe con Whisper (config del + // módulo "whisper" en /app/ai-config) y el texto resultante se procesa como + // si el usuario lo hubiera escrito. + if hasAudio { + voice := msg.Voice + if voice == nil { + voice = msg.Audio + } + fileName := "audio.ogg" + mimeType := voice.MimeType + path, dlErr := services.DescargarDocumentoTelegram(tgCfg.BotToken, voice.FileID, fileName, mimeType) + if dlErr != nil { + log.Printf("[AGENT_WEBHOOK] Error descargando audio: %v", dlErr) + sendAgentReply(tgCfg.BotToken, chatID, "No pude descargar el audio que enviaste, intenta de nuevo.") + return c.SendStatus(200) + } + defer os.Remove(path) + + whisperCfg, wErr := models.GetWhisperConfig() + if wErr != nil { + log.Printf("[AGENT_WEBHOOK] Whisper no configurado: %v", wErr) + sendAgentReply(tgCfg.BotToken, chatID, "No pude transcribir el audio: no hay ninguna configuración de Whisper activa. Ve a /app/ai-config, crea o edita una config con módulo 'Transcripción de audio (Whisper)' y credenciales válidas de OpenAI o Groq.") + return c.SendStatus(200) + } + + transcripcion, tErr := services.TranscribirAudio(whisperCfg, path) + if tErr != nil { + log.Printf("[AGENT_WEBHOOK] Error transcribiendo audio: %v", tErr) + sendAgentReply(tgCfg.BotToken, chatID, fmt.Sprintf("No pude transcribir el audio: %s", tErr.Error())) + return c.SendStatus(200) + } + log.Printf("[AGENT_WEBHOOK] Audio transcrito (chat %d): %s", chatID, transcripcion) + + if caption != "" { + text = transcripcion + " " + caption + } else { + text = transcripcion + } + } + // Procesar en goroutine para responder 200 inmediatamente a Telegram go func() { response, err := services.ProcessAgentMessage(chatID, text, ai)