Agrega transcripción de audio (Whisper) en Telegram y corrige adjuntos en tareas
- Nueva config "whisper" reutilizando /app/ai-config (credenciales/endpoint
validables) y servicio de transcripción compatible con OpenAI/Groq.
- El webhook de Telegram ahora detecta notas de voz/audio, las transcribe
y procesa el texto resultante como si el usuario lo hubiera escrito.
- Fix: subir un archivo adjunto en un comentario de tarea sin escribir
texto era rechazado por el backend ("contenido requerido") aunque el
frontend sí lo permitía.
This commit is contained in:
@@ -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)
|
log.Printf("[AI_CONFIG] No se encontró config para servicio '%s', usando cualquier activa", service)
|
||||||
return &items[0], nil
|
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")
|
||||||
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -74,9 +74,10 @@
|
|||||||
:class="{
|
:class="{
|
||||||
'bg-blue-100 text-blue-700': m.trim() === 'landing',
|
'bg-blue-100 text-blue-700': m.trim() === 'landing',
|
||||||
'bg-[#e9f0cf] text-[#5a7a1e]': m.trim() === 'query_runner',
|
'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()">
|
||||||
</span>
|
</span>
|
||||||
</template>
|
</template>
|
||||||
</div>
|
</div>
|
||||||
@@ -292,6 +293,7 @@ function aiConfigApp() {
|
|||||||
{ value: 'landing', label: 'Landing Generator' },
|
{ value: 'landing', label: 'Landing Generator' },
|
||||||
{ value: 'query_runner', label: 'Query Runner SQL' },
|
{ value: 'query_runner', label: 'Query Runner SQL' },
|
||||||
{ value: 'ia', label: 'IA / vCard' },
|
{ value: 'ia', label: 'IA / vCard' },
|
||||||
|
{ value: 'whisper', label: 'Transcripción de audio (Whisper)' },
|
||||||
],
|
],
|
||||||
|
|
||||||
async init() { await this.load() },
|
async init() { await this.load() },
|
||||||
|
|||||||
@@ -177,7 +177,9 @@ func AddComentario(c *fiber.Ctx) error {
|
|||||||
}
|
}
|
||||||
|
|
||||||
contenido := strings.TrimSpace(c.FormValue("contenido"))
|
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"})
|
return c.Status(400).JSON(fiber.Map{"error": "contenido requerido"})
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -185,8 +187,7 @@ func AddComentario(c *fiber.Ctx) error {
|
|||||||
archivosJSON := "[]"
|
archivosJSON := "[]"
|
||||||
|
|
||||||
// archivo adjunto opcional
|
// archivo adjunto opcional
|
||||||
file, fileErr := c.FormFile("archivo")
|
if tieneArchivo {
|
||||||
if fileErr == nil && file != nil {
|
|
||||||
if file.Size > 50*1024*1024 {
|
if file.Size > 50*1024*1024 {
|
||||||
return c.Status(400).JSON(fiber.Map{"error": "Máximo 50MB por archivo"})
|
return c.Status(400).JSON(fiber.Map{"error": "Máximo 50MB por archivo"})
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ package controllers
|
|||||||
import (
|
import (
|
||||||
"fmt"
|
"fmt"
|
||||||
"log"
|
"log"
|
||||||
|
"os"
|
||||||
"strconv"
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
@@ -37,6 +38,12 @@ type tgPhotoSize struct {
|
|||||||
Height int `json:"height"`
|
Height int `json:"height"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type tgVoice struct {
|
||||||
|
FileID string `json:"file_id"`
|
||||||
|
Duration int `json:"duration"`
|
||||||
|
MimeType string `json:"mime_type"`
|
||||||
|
}
|
||||||
|
|
||||||
type tgMessage struct {
|
type tgMessage struct {
|
||||||
MessageID int `json:"message_id"`
|
MessageID int `json:"message_id"`
|
||||||
From tgUser `json:"from"`
|
From tgUser `json:"from"`
|
||||||
@@ -45,6 +52,8 @@ type tgMessage struct {
|
|||||||
Caption string `json:"caption"`
|
Caption string `json:"caption"`
|
||||||
Document *tgDocument `json:"document"`
|
Document *tgDocument `json:"document"`
|
||||||
Photo []tgPhotoSize `json:"photo"`
|
Photo []tgPhotoSize `json:"photo"`
|
||||||
|
Voice *tgVoice `json:"voice"`
|
||||||
|
Audio *tgVoice `json:"audio"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type tgUpdate struct {
|
type tgUpdate struct {
|
||||||
@@ -77,7 +86,8 @@ func TelegramAgentWebhook(c *fiber.Ctx) error {
|
|||||||
text := strings.TrimSpace(msg.Text)
|
text := strings.TrimSpace(msg.Text)
|
||||||
caption := strings.TrimSpace(msg.Caption)
|
caption := strings.TrimSpace(msg.Caption)
|
||||||
hasAttachment := msg.Document != nil || len(msg.Photo) > 0
|
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)
|
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
|
// Procesar en goroutine para responder 200 inmediatamente a Telegram
|
||||||
go func() {
|
go func() {
|
||||||
response, err := services.ProcessAgentMessage(chatID, text, ai)
|
response, err := services.ProcessAgentMessage(chatID, text, ai)
|
||||||
|
|||||||
Reference in New Issue
Block a user