diff --git a/main.go b/main.go index fdf5c45..3b02a55 100755 --- a/main.go +++ b/main.go @@ -157,6 +157,7 @@ func main() { &models.UmindConexion{}, &models.UmindEventoLog{}, &models.UmindPlan{}, + &models.UmindUso{}, // API Keys de /api/v2 (token + IP obligatoria + scopes) &models.ApiKey{}, // Integraciones: OCR y transcripción de audio (servicios propios) diff --git a/migrations/migrate.go b/migrations/migrate.go index 8915e3b..daf717a 100755 --- a/migrations/migrate.go +++ b/migrations/migrate.go @@ -128,6 +128,7 @@ func Migrate() { &models.UmindConexion{}, &models.UmindEventoLog{}, &models.UmindPlan{}, + &models.UmindUso{}, // API Keys de /api/v2 (token + IP obligatoria + scopes) &models.ApiKey{}, // Integraciones: OCR y transcripción de audio (servicios propios) diff --git a/pkg/models/umind_uso.go b/pkg/models/umind_uso.go new file mode 100644 index 0000000..d249bbe --- /dev/null +++ b/pkg/models/umind_uso.go @@ -0,0 +1,135 @@ +package models + +import ( + "log" + "time" + + "github.com/sujit-baniya/fiber-boilerplate/app" + "gorm.io/gorm" +) + +// Tipos de consumo medible. +const ( + UsoTipoIA = "ia" + UsoTipoOCR = "ocr" + UsoTipoWhisper = "whisper" +) + +// UmindUso es una línea de consumo facturable. El Costo se congela con el +// precio vigente del plan al momento de registrarlo: subir un precio nunca +// revalúa consumo ya ocurrido, que es lo que haría imposible defender una +// factura ante un reclamo. +// +// TenantID está desnormalizado a propósito (se puede derivar del agente) para +// poder sumar el consumo de un ciclo sin joins. +type UmindUso struct { + gorm.Model + TenantID uint `json:"tenant_id" gorm:"column:tenant_id;index"` + AgenteID uint `json:"agente_id" gorm:"column:agente_id;index"` + Tipo string `json:"tipo" gorm:"column:tipo;size:10;index"` + Cantidad float64 `json:"cantidad" gorm:"column:cantidad"` + Unidad string `json:"unidad" gorm:"column:unidad;size:20"` + Costo float64 `json:"costo" gorm:"column:costo"` + Moneda string `json:"moneda" gorm:"column:moneda;size:3"` + // FacturadoAt null = pendiente de cobrar en el próximo ciclo. + FacturadoAt *time.Time `json:"facturado_at" gorm:"column:facturado_at;index"` +} + +func (UmindUso) TableName() string { return "umind_uso" } + +// RegistrarUsoUmind nunca devuelve error, igual que RegistrarEventoUmind: es +// contabilidad lateral y jamás debe tumbar la respuesta al visitante. Si +// falla, queda en el log para reconciliar a mano. +func RegistrarUsoUmind(agenteID uint, tipo string, cantidad float64, unidad string) { + if agenteID == 0 || cantidad <= 0 { + return + } + agente, err := GetUmindAgenteByID(agenteID) + if err != nil { + log.Printf("[UMIND_USO] agente %d no encontrado, no se registra el consumo: %v", agenteID, err) + return + } + + plan := GetPlanDeTenant(agente.TenantID) + moneda := "COP" + if plan != nil { + moneda = plan.Moneda + } + costo := costoDeUso(plan, tipo, cantidad) + + uso := &UmindUso{ + TenantID: agente.TenantID, AgenteID: agenteID, + Tipo: tipo, Cantidad: cantidad, Unidad: unidad, + Costo: costo, Moneda: moneda, + } + if err := app.Http.Database.DB.Create(uso).Error; err != nil { + log.Printf("[UMIND_USO] no se pudo registrar consumo del agente %d (%s %.2f %s): %v", agenteID, tipo, cantidad, unidad, err) + } +} + +// costoDeUso aplica la tarifa del plan. Sin plan (tenants viejos) o tipo no +// tarifado, el consumo se registra pero no cuesta. +func costoDeUso(plan *UmindPlan, tipo string, cantidad float64) float64 { + if plan == nil { + return 0 + } + switch tipo { + case UsoTipoIA: + return cantidad / 1000 * plan.PrecioPor1kTokens + case UsoTipoOCR: + return cantidad * plan.PrecioPorOCR + case UsoTipoWhisper: + return cantidad * plan.PrecioPorTranscripcion + } + return 0 +} + +// GetUsoUmind lista el consumo de un tenant en un rango. tipo vacío = todos. +func GetUsoUmind(tenantID uint, desde, hasta time.Time, tipo string) ([]UmindUso, error) { + var items []UmindUso + db := app.Http.Database.DB.Where("tenant_id = ? AND created_at >= ? AND created_at < ?", tenantID, desde, hasta) + if tipo != "" { + db = db.Where("tipo = ?", tipo) + } + err := db.Order("created_at DESC").Limit(1000).Find(&items).Error + return items, err +} + +// ResumenUso es el agregado por tipo que se muestra en el panel y se adjunta +// al correo de cobro. +type ResumenUso struct { + Tipo string `json:"tipo"` + Unidad string `json:"unidad"` + Cantidad float64 `json:"cantidad"` + Costo float64 `json:"costo"` + Eventos int64 `json:"eventos"` +} + +func GetResumenUso(tenantID uint, desde, hasta time.Time) ([]ResumenUso, error) { + var out []ResumenUso + err := app.Http.Database.DB.Model(&UmindUso{}). + Select("tipo, MAX(unidad) AS unidad, SUM(cantidad) AS cantidad, SUM(costo) AS costo, COUNT(*) AS eventos"). + Where("tenant_id = ? AND created_at >= ? AND created_at < ? AND deleted_at IS NULL", tenantID, desde, hasta). + Group("tipo").Scan(&out).Error + return out, err +} + +// SumarUsoPendiente devuelve el consumo todavía no facturado de un tenant — +// es lo que se le suma a la mensualidad al generar el link de cobro. +func SumarUsoPendiente(tenantID uint) (float64, error) { + var total float64 + err := app.Http.Database.DB.Model(&UmindUso{}). + Where("tenant_id = ? AND facturado_at IS NULL AND deleted_at IS NULL", tenantID). + Select("COALESCE(SUM(costo), 0)").Scan(&total).Error + return total, err +} + +// MarcarUsoFacturado cierra el consumo pendiente de un tenant. Es idempotente +// por construcción: el filtro facturado_at IS NULL hace que una segunda +// llamada (webhook de pago duplicado) no encuentre nada que marcar. +func MarcarUsoFacturado(tenantID uint) error { + ahora := time.Now() + return app.Http.Database.DB.Model(&UmindUso{}). + Where("tenant_id = ? AND facturado_at IS NULL", tenantID). + Update("facturado_at", ahora).Error +} diff --git a/pkg/models/umind_uso_test.go b/pkg/models/umind_uso_test.go new file mode 100644 index 0000000..e85d292 --- /dev/null +++ b/pkg/models/umind_uso_test.go @@ -0,0 +1,43 @@ +package models + +import "testing" + +// El costo se congela con el precio del plan al momento de registrar el +// consumo. Si esta cuenta se rompe, se le cobra de más o de menos a un +// cliente real, así que va con test. +func TestCalculoCostoPorTipo(t *testing.T) { + plan := &UmindPlan{ + PrecioPor1kTokens: 2.5, + PrecioPorOCR: 10, + PrecioPorTranscripcion: 40, + } + + casos := []struct { + tipo string + cantidad float64 + esperado float64 + }{ + {UsoTipoIA, 1000, 2.5}, // exactamente 1k tokens + {UsoTipoIA, 500, 1.25}, // fracción de 1k, no se redondea hacia arriba + {UsoTipoIA, 3200, 8.0}, // varios miles + {UsoTipoOCR, 1, 10}, // una imagen + {UsoTipoOCR, 3, 30}, // varias + {UsoTipoWhisper, 1, 40}, // una transcripción + {"desconocido", 100, 0}, // tipo no tarifado no cobra nada + } + + for _, cas := range casos { + got := costoDeUso(plan, cas.tipo, cas.cantidad) + if got != cas.esperado { + t.Errorf("costoDeUso(%s, %.0f) = %.4f, esperaba %.4f", cas.tipo, cas.cantidad, got, cas.esperado) + } + } +} + +// Sin plan asignado (tenants viejos) el consumo se registra pero no cuesta — +// no debe explotar ni inventar un precio. +func TestCalculoCostoSinPlan(t *testing.T) { + if got := costoDeUso(nil, UsoTipoIA, 5000); got != 0 { + t.Errorf("costoDeUso sin plan = %.4f, esperaba 0", got) + } +} diff --git a/pkg/services/cloudflare_service.go b/pkg/services/cloudflare_service.go index feb6808..8ef5e1e 100644 --- a/pkg/services/cloudflare_service.go +++ b/pkg/services/cloudflare_service.go @@ -134,18 +134,18 @@ type CFZoneAccount struct { // CFZone representa una zona (dominio) en Cloudflare. type CFZone struct { - ID string `json:"id"` - Name string `json:"name"` - Status string `json:"status"` - Paused bool `json:"paused"` - Type string `json:"type"` - NameServers []string `json:"name_servers"` - OriginalNS []string `json:"original_name_servers"` - CreatedOn string `json:"created_on"` - ModifiedOn string `json:"modified_on"` - ActivatedOn string `json:"activated_on"` - Account CFZoneAccount `json:"account"` - Plan CFPlan `json:"plan"` + ID string `json:"id"` + Name string `json:"name"` + Status string `json:"status"` + Paused bool `json:"paused"` + Type string `json:"type"` + NameServers []string `json:"name_servers"` + OriginalNS []string `json:"original_name_servers"` + CreatedOn string `json:"created_on"` + ModifiedOn string `json:"modified_on"` + ActivatedOn string `json:"activated_on"` + Account CFZoneAccount `json:"account"` + Plan CFPlan `json:"plan"` } // CFPlan representa el plan de una zona. @@ -162,30 +162,30 @@ type CFDNSRecordSettings struct { // CFDNSRecord representa un registro DNS. type CFDNSRecord struct { - ID string `json:"id"` - Type string `json:"type"` - Name string `json:"name"` - Content string `json:"content"` - Comment string `json:"comment,omitempty"` - Tags []string `json:"tags,omitempty"` - Settings CFDNSRecordSettings `json:"settings,omitempty"` - PrivateRouting bool `json:"private_routing,omitempty"` - Proxied bool `json:"proxied"` - Proxiable bool `json:"proxiable"` - TTL int `json:"ttl"` - Priority int `json:"priority,omitempty"` - CreatedOn string `json:"created_on"` - ModifiedOn string `json:"modified_on"` - CommentModifiedOn string `json:"comment_modified_on,omitempty"` - TagsModifiedOn string `json:"tags_modified_on,omitempty"` + ID string `json:"id"` + Type string `json:"type"` + Name string `json:"name"` + Content string `json:"content"` + Comment string `json:"comment,omitempty"` + Tags []string `json:"tags,omitempty"` + Settings CFDNSRecordSettings `json:"settings,omitempty"` + PrivateRouting bool `json:"private_routing,omitempty"` + Proxied bool `json:"proxied"` + Proxiable bool `json:"proxiable"` + TTL int `json:"ttl"` + Priority int `json:"priority,omitempty"` + CreatedOn string `json:"created_on"` + ModifiedOn string `json:"modified_on"` + CommentModifiedOn string `json:"comment_modified_on,omitempty"` + TagsModifiedOn string `json:"tags_modified_on,omitempty"` } // CFSSLStatus representa un certificate pack de una zona. type CFSSLStatus struct { ID string `json:"id"` - Type string `json:"type"` // universal, advanced, custom, sni_custom + Type string `json:"type"` // universal, advanced, custom, sni_custom Hosts []string `json:"hosts"` - Status string `json:"status"` // active, pending_validation, deleted + Status string `json:"status"` // active, pending_validation, deleted ValidationMethod string `json:"validation_method,omitempty"` ValidityDays int `json:"validity_days,omitempty"` CertificateAuthority string `json:"certificate_authority,omitempty"` @@ -243,10 +243,10 @@ type CFTokenVerify struct { // CFTokenPolicy representa una política de permisos del token. type CFTokenPolicy struct { - ID string `json:"id"` - Effect string `json:"effect"` - Resources map[string]string `json:"resources"` - PermGroups []CFTokenPermGroup `json:"permission_groups"` + ID string `json:"id"` + Effect string `json:"effect"` + Resources map[string]string `json:"resources"` + PermGroups []CFTokenPermGroup `json:"permission_groups"` } // CFTokenPermGroup un grupo de permisos. @@ -478,14 +478,14 @@ func (c *CloudflareClient) doRequest(method, path string, payload interface{}, d // CFDNSRecordInput es el payload para crear o actualizar un registro DNS. type CFDNSRecordInput struct { - Type string `json:"type"` // A, AAAA, CNAME, TXT, MX, NS, SRV, CAA… - Name string `json:"name"` // Nombre del registro (ej. "www" o "@") - Content string `json:"content"` // Valor del registro - TTL int `json:"ttl"` // 1 = automático, o segundos (min 60) - Proxied bool `json:"proxied"` // true = nube naranja - Priority int `json:"priority,omitempty"` // Solo para MX / SRV - Comment string `json:"comment,omitempty"` // Comentario descriptivo - Tags []string `json:"tags,omitempty"` // Etiquetas (ej. ["owner:team"]) + Type string `json:"type"` // A, AAAA, CNAME, TXT, MX, NS, SRV, CAA… + Name string `json:"name"` // Nombre del registro (ej. "www" o "@") + Content string `json:"content"` // Valor del registro + TTL int `json:"ttl"` // 1 = automático, o segundos (min 60) + Proxied bool `json:"proxied"` // true = nube naranja + Priority int `json:"priority,omitempty"` // Solo para MX / SRV + Comment string `json:"comment,omitempty"` // Comentario descriptivo + Tags []string `json:"tags,omitempty"` // Etiquetas (ej. ["owner:team"]) Settings *CFDNSRecordSettings `json:"settings,omitempty"` // Configuraciones adicionales } diff --git a/pkg/services/ocr_service.go b/pkg/services/ocr_service.go index 1bf7ceb..58d7006 100644 --- a/pkg/services/ocr_service.go +++ b/pkg/services/ocr_service.go @@ -16,7 +16,10 @@ 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) { +// +// agenteID identifica a quién cobrarle la imagen procesada; 0 = no medir, que +// es lo que pasa el botón "Probar" del panel de staff (no es de ningún cliente). +func ExtraerTextoOCR(agenteID uint, 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)") @@ -63,5 +66,6 @@ func ExtraerTextoOCR(imagenBytes []byte, mimeType string) (string, error) { } return "", fmt.Errorf("%s", msg) } + RegistrarUso(agenteID, models.UsoTipoOCR, 1, "imagen") return out.Text, nil } diff --git a/pkg/services/saas_dispatch_service.go b/pkg/services/saas_dispatch_service.go index b1ed2de..f759ec3 100644 --- a/pkg/services/saas_dispatch_service.go +++ b/pkg/services/saas_dispatch_service.go @@ -31,9 +31,10 @@ type DispatchPayload struct { // asociados al contrato. Se ejecuta en goroutine separada desde los webhooks. // // Cadena de vinculación: -// Contrato → contrato_servicios (m2m) → servicios.id -// servicios.id ↔ saas_productos.servicio_id → saas_productos.id -// saas_productos.id → saas_api_configs.saas_id (activo = true) +// +// Contrato → contrato_servicios (m2m) → servicios.id +// servicios.id ↔ saas_productos.servicio_id → saas_productos.id +// saas_productos.id → saas_api_configs.saas_id (activo = true) func DispatchSaasPaymentNotification(contratoID uint, payerEmail, fuente string, monto float64, moneda string) { referencia := fmt.Sprintf("contrato-%d", contratoID) diff --git a/pkg/services/telegram_agent_service.go b/pkg/services/telegram_agent_service.go index 3286363..9fae16e 100644 --- a/pkg/services/telegram_agent_service.go +++ b/pkg/services/telegram_agent_service.go @@ -103,6 +103,13 @@ type agentChatResp struct { Message agentMessage `json:"message"` FinishReason string `json:"finish_reason"` } `json:"choices"` + // Usage lo devuelven todos los proveedores OpenAI-compatibles con el mismo + // nombre de campo, así que no hace falta un caso por proveedor. + Usage struct { + PromptTokens int `json:"prompt_tokens"` + CompletionTokens int `json:"completion_tokens"` + TotalTokens int `json:"total_tokens"` + } `json:"usage"` Error *struct { Message string `json:"message"` } `json:"error"` @@ -1277,7 +1284,11 @@ type anthropicReq struct { type anthropicResp struct { Content []anthropicContentBlock `json:"content"` StopReason string `json:"stop_reason"` - Error *struct { + Usage struct { + InputTokens int `json:"input_tokens"` + OutputTokens int `json:"output_tokens"` + } `json:"usage"` + Error *struct { Message string `json:"message"` Type string `json:"type"` } `json:"error"` @@ -1286,7 +1297,9 @@ type anthropicResp struct { // ─── Llamada al AI con function calling ────────────────────────────────────── // callAI despacha al provider correcto (Anthropic o OpenAI-compatible). -func callAI(ai *models.AiConfig, messages []agentMessage, tools []agentTool) (*agentMessage, error) { +// El segundo valor son los tokens totales que reportó el proveedor (0 si no +// los informa) — lo usa uMind para medir el consumo facturable. +func callAI(ai *models.AiConfig, messages []agentMessage, tools []agentTool) (*agentMessage, int, error) { if strings.ToLower(ai.Provider) == "anthropic" { return callAnthropicAI(ai, messages, tools) } @@ -1294,7 +1307,7 @@ func callAI(ai *models.AiConfig, messages []agentMessage, tools []agentTool) (*a } // callOpenAICompatibleAI usa el formato de OpenAI (también vale para qwen, groq, deepseek, etc.) -func callOpenAICompatibleAI(ai *models.AiConfig, messages []agentMessage, tools []agentTool) (*agentMessage, error) { +func callOpenAICompatibleAI(ai *models.AiConfig, messages []agentMessage, tools []agentTool) (*agentMessage, int, error) { baseURL := ai.BaseURL if baseURL == "" { baseURL = ProviderDefaultURL(ai.Provider) @@ -1312,7 +1325,7 @@ func callOpenAICompatibleAI(ai *models.AiConfig, messages []agentMessage, tools payload, _ := json.Marshal(reqBody) req, err := http.NewRequest("POST", baseURL+"/chat/completions", bytes.NewReader(payload)) if err != nil { - return nil, err + return nil, 0, err } req.Header.Set("Authorization", "Bearer "+ai.ClaveEnClaro()) req.Header.Set("Content-Type", "application/json") @@ -1320,27 +1333,27 @@ func callOpenAICompatibleAI(ai *models.AiConfig, messages []agentMessage, tools client := &http.Client{Timeout: 120 * time.Second} resp, err := client.Do(req) if err != nil { - return nil, err + return nil, 0, err } defer resp.Body.Close() raw, _ := io.ReadAll(io.LimitReader(resp.Body, 1024*1024)) var chatResp agentChatResp if err := json.Unmarshal(raw, &chatResp); err != nil { - return nil, fmt.Errorf("respuesta inesperada del AI: %s", string(raw[:min(200, len(raw))])) + return nil, 0, fmt.Errorf("respuesta inesperada del AI: %s", string(raw[:min(200, len(raw))])) } if chatResp.Error != nil { - return nil, fmt.Errorf("error del AI: %s", chatResp.Error.Message) + return nil, 0, fmt.Errorf("error del AI: %s", chatResp.Error.Message) } if len(chatResp.Choices) == 0 { - return nil, fmt.Errorf("el AI no devolvió respuesta") + return nil, 0, fmt.Errorf("el AI no devolvió respuesta") } msg := chatResp.Choices[0].Message - return &msg, nil + return &msg, chatResp.Usage.TotalTokens, nil } // callAnthropicAI llama a la API nativa de Anthropic con tool use. -func callAnthropicAI(ai *models.AiConfig, messages []agentMessage, tools []agentTool) (*agentMessage, error) { +func callAnthropicAI(ai *models.AiConfig, messages []agentMessage, tools []agentTool) (*agentMessage, int, error) { // Convertir herramientas al formato Anthropic anthropicTools := make([]anthropicTool, len(tools)) for i, t := range tools { @@ -1459,7 +1472,7 @@ func callAnthropicAI(ai *models.AiConfig, messages []agentMessage, tools []agent payload, _ := json.Marshal(reqBody) req, err := http.NewRequest("POST", "https://api.anthropic.com/v1/messages", bytes.NewReader(payload)) if err != nil { - return nil, err + return nil, 0, err } req.Header.Set("x-api-key", ai.ClaveEnClaro()) req.Header.Set("anthropic-version", "2023-06-01") @@ -1468,17 +1481,17 @@ func callAnthropicAI(ai *models.AiConfig, messages []agentMessage, tools []agent client := &http.Client{Timeout: 120 * time.Second} resp, err := client.Do(req) if err != nil { - return nil, err + return nil, 0, err } defer resp.Body.Close() raw, _ := io.ReadAll(io.LimitReader(resp.Body, 1024*1024)) var anthropicRsp anthropicResp if err := json.Unmarshal(raw, &anthropicRsp); err != nil { - return nil, fmt.Errorf("respuesta inesperada de Anthropic: %s", string(raw[:min(200, len(raw))])) + return nil, 0, fmt.Errorf("respuesta inesperada de Anthropic: %s", string(raw[:min(200, len(raw))])) } if anthropicRsp.Error != nil { - return nil, fmt.Errorf("error de Anthropic: %s", anthropicRsp.Error.Message) + return nil, 0, fmt.Errorf("error de Anthropic: %s", anthropicRsp.Error.Message) } // Convertir respuesta Anthropic → agentMessage (formato interno OpenAI) @@ -1512,7 +1525,7 @@ func callAnthropicAI(ai *models.AiConfig, messages []agentMessage, tools []agent result.Content = strings.Join(textParts, "\n") } result.ToolCalls = toolCalls - return result, nil + return result, anthropicRsp.Usage.InputTokens + anthropicRsp.Usage.OutputTokens, nil } func ProviderDefaultURL(provider string) string { @@ -1683,7 +1696,7 @@ Comandos: /reset · /instancias · /ayuda`, nil // Loop de function calling (máximo 6 rondas) var finalResponse string for round := 0; round < 6; round++ { - aiMsg, err := callAI(ai, messages, tools) + aiMsg, _, err := callAI(ai, messages, tools) if err != nil { log.Printf("[AGENT] Error llamando AI round %d: %v", round, err) return "Error al contactar el sistema de IA. Intenta de nuevo.", err diff --git a/pkg/services/umind_agent_service.go b/pkg/services/umind_agent_service.go index 0589698..0dd2db2 100644 --- a/pkg/services/umind_agent_service.go +++ b/pkg/services/umind_agent_service.go @@ -283,12 +283,15 @@ func ProcessWidgetMessage(agente *models.UmindAgente, sessionID, userText string var finalResponse string for round := 0; round < 3; round++ { - aiMsg, err := callAI(&ai, messages, tools) + aiMsg, tokens, err := callAI(&ai, messages, tools) if err != nil { log.Printf("[UMIND] Error llamando AI (agente %d) round %d: %v", agente.ID, round, err) models.RegistrarEventoUmind(agente.ID, "error", "ai", fmt.Sprintf("Error contactando el AI (ronda %d)", round), err.Error()) return "", fmt.Errorf("error al contactar el sistema de IA") } + // Se mide cada ronda, no solo la última: las rondas de tool-calling + // consumen tokens reales aunque el visitante solo vea una respuesta. + RegistrarUso(agente.ID, models.UsoTipoIA, float64(tokens), "tokens") if len(aiMsg.ToolCalls) == 0 { content := "" diff --git a/pkg/services/umind_canal_telegram_service.go b/pkg/services/umind_canal_telegram_service.go index 152cefc..14958c0 100644 --- a/pkg/services/umind_canal_telegram_service.go +++ b/pkg/services/umind_canal_telegram_service.go @@ -68,7 +68,7 @@ func ProcesarMediaTelegramUmind(canal *models.UmindCanal, chatID int64, fileID, if err != nil { return fmt.Errorf("no se pudo descargar el audio de Telegram: %w", err) } - texto, err = TranscribirAudioSelfHosted(data, "audio.ogg") + texto, err = TranscribirAudioSelfHosted(canal.AgenteID, data, "audio.ogg") if err != nil { return err } @@ -80,7 +80,7 @@ func ProcesarMediaTelegramUmind(canal *models.UmindCanal, chatID int64, fileID, if err != nil { return fmt.Errorf("no se pudo descargar la imagen de Telegram: %w", err) } - texto, err = ExtraerTextoOCR(data, "image/jpeg") + texto, err = ExtraerTextoOCR(canal.AgenteID, data, "image/jpeg") if err != nil { return err } diff --git a/pkg/services/umind_canal_whatsapp_service.go b/pkg/services/umind_canal_whatsapp_service.go index c04b9ed..11a3a69 100644 --- a/pkg/services/umind_canal_whatsapp_service.go +++ b/pkg/services/umind_canal_whatsapp_service.go @@ -77,7 +77,7 @@ func ProcesarMediaWhatsAppUmind(canal *models.UmindCanal, from, mediaID, tipo st if err != nil { return fmt.Errorf("no se pudo descargar el audio de WhatsApp: %w", err) } - texto, err = TranscribirAudioSelfHosted(data, "audio.ogg") + texto, err = TranscribirAudioSelfHosted(canal.AgenteID, data, "audio.ogg") if err != nil { return err } @@ -92,7 +92,7 @@ func ProcesarMediaWhatsAppUmind(canal *models.UmindCanal, from, mediaID, tipo st if mimeType == "" { mimeType = "image/jpeg" } - texto, err = ExtraerTextoOCR(data, mimeType) + texto, err = ExtraerTextoOCR(canal.AgenteID, data, mimeType) if err != nil { return err } diff --git a/pkg/services/umind_uso_service.go b/pkg/services/umind_uso_service.go new file mode 100644 index 0000000..43873f0 --- /dev/null +++ b/pkg/services/umind_uso_service.go @@ -0,0 +1,92 @@ +package services + +import ( + "fmt" + "log" + "sync" + "time" + + "github.com/sujit-baniya/fiber-boilerplate/pkg/models" +) + +// avisosTope recuerda a qué tenants ya se les avisó en el ciclo actual, para +// no mandar un aviso por cada mensaje una vez pasado el tope. Se pierde al +// reiniciar (y entonces se vuelve a avisar una vez): aceptable para un aviso, +// y mucho más simple que una columna de estado en el tenant. +// +// ponytail: en memoria y por proceso. Si algún día corren varias réplicas, +// cada una avisa una vez — mover el flag a la BD si eso molesta. +var avisosTope struct { + sync.Mutex + ultimo map[uint]string // tenantID → "2026-08" del último aviso +} + +// RegistrarUso persiste el consumo y avisa si el tenant superó el tope de su +// plan. El aviso NO corta el servicio: el agente sigue respondiendo. +func RegistrarUso(agenteID uint, tipo string, cantidad float64, unidad string) { + if agenteID == 0 || cantidad <= 0 { + return + } + models.RegistrarUsoUmind(agenteID, tipo, cantidad, unidad) + + agente, err := models.GetUmindAgenteByID(agenteID) + if err != nil { + return + } + plan := models.GetPlanDeTenant(agente.TenantID) + if plan == nil || plan.TopeConsumoMensual <= 0 { + return + } + + desde, hasta := cicloActual() + resumen, err := models.GetResumenUso(agente.TenantID, desde, hasta) + if err != nil { + return + } + total := 0.0 + for _, r := range resumen { + total += r.Costo + } + if total < plan.TopeConsumoMensual { + return + } + avisarTopeConsumo(agente.TenantID, plan, total, desde) +} + +// cicloActual devuelve el mes calendario en curso. Se usa el mes natural (no +// la fecha de alta del contrato) porque es lo que el cliente entiende al +// mirar "consumo de agosto" en su panel. +func cicloActual() (time.Time, time.Time) { + ahora := time.Now() + desde := time.Date(ahora.Year(), ahora.Month(), 1, 0, 0, 0, 0, ahora.Location()) + return desde, desde.AddDate(0, 1, 0) +} + +func avisarTopeConsumo(tenantID uint, plan *models.UmindPlan, total float64, desde time.Time) { + periodo := desde.Format("2006-01") + + avisosTope.Lock() + if avisosTope.ultimo == nil { + avisosTope.ultimo = map[uint]string{} + } + yaAvisado := avisosTope.ultimo[tenantID] == periodo + avisosTope.ultimo[tenantID] = periodo + avisosTope.Unlock() + if yaAvisado { + return + } + + tenant, err := models.GetUmindTenantByID(tenantID) + nombre := fmt.Sprintf("tenant %d", tenantID) + if err == nil { + nombre = tenant.Nombre + } + + log.Printf("[UMIND_USO] %s superó el tope de consumo del plan %s: %.2f %s de %.2f", + nombre, plan.Nombre, total, plan.Moneda, plan.TopeConsumoMensual) + + sendTelegramAdmin(fmt.Sprintf( + "📊 Tope de consumo superado\nCliente: %s\nPlan: %s\nConsumo del mes: %s %.2f (tope %s %.2f)\n\nEl servicio sigue activo — el excedente se cobra en el próximo ciclo.", + escapeTelegramHTML(nombre), escapeTelegramHTML(plan.Nombre), + plan.Moneda, total, plan.Moneda, plan.TopeConsumoMensual)) +} diff --git a/pkg/services/websms_service.go b/pkg/services/websms_service.go index d3fd949..2158f70 100644 --- a/pkg/services/websms_service.go +++ b/pkg/services/websms_service.go @@ -15,8 +15,8 @@ import ( const websmsAPIBase = "https://api.labsmobile.com/json/send" type WebSmsRequest struct { - Message string `json:"message"` - TPOA string `json:"tpoa,omitempty"` + Message string `json:"message"` + TPOA string `json:"tpoa,omitempty"` Recipient []WebSmsRecipient `json:"recipient"` } @@ -67,7 +67,7 @@ func SendWebSms(cfg *models.WebSmsConfig, para, mensaje string) (*WebSmsResponse auth := base64.StdEncoding.EncodeToString([]byte(cfg.Username + ":" + cfg.ApiToken)) req := WebSmsRequest{ - Message: mensaje, + Message: mensaje, Recipient: []WebSmsRecipient{{MSISDN: para}}, } if cfg.Sender != "" { diff --git a/pkg/services/whisper_asr_service.go b/pkg/services/whisper_asr_service.go index 0379652..1cfc956 100644 --- a/pkg/services/whisper_asr_service.go +++ b/pkg/services/whisper_asr_service.go @@ -16,7 +16,13 @@ var whisperAsrHTTPClient = &http.Client{Timeout: 120 * time.Second} // audio lar // 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) { +// +// agenteID identifica a quién cobrarle la transcripción; 0 = no medir. +// +// ponytail: se cobra por transcripción, no por minuto de audio — +// whisper-asr-webservice con response_format=json no devuelve la duración. Si +// hace falta cobrar por minuto, pedirle verbose_json y sumar los segments. +func TranscribirAudioSelfHosted(agenteID uint, 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)") @@ -63,5 +69,6 @@ func TranscribirAudioSelfHosted(audioBytes []byte, filename string) (string, err if err := json.Unmarshal(raw, &out); err != nil { return "", fmt.Errorf("respuesta inesperada del servicio de transcripción: %s", string(raw)) } + RegistrarUso(agenteID, models.UsoTipoWhisper, 1, "transcripcion") return out.Text, nil } diff --git a/rest/controllers/cloudflare_controller.go b/rest/controllers/cloudflare_controller.go index 1899140..dea0f22 100644 --- a/rest/controllers/cloudflare_controller.go +++ b/rest/controllers/cloudflare_controller.go @@ -116,9 +116,9 @@ func CleanCloudflareToken(c *fiber.Ctx) error { return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": err.Error()}) } return c.JSON(fiber.Map{ - "message": "Token re-guardado limpio", - "chars_before": before, - "chars_after": after, + "message": "Token re-guardado limpio", + "chars_before": before, + "chars_after": after, "chars_removed": before - after, }) } @@ -244,11 +244,11 @@ func VerifyCloudflareToken(c *fiber.Ctx) error { } return c.JSON(fiber.Map{ - "ok": true, - "auth_type": "global_key", - "user_email": user.Email, - "user_id": user.ID, - "token": tokenDiag, + "ok": true, + "auth_type": "global_key", + "user_email": user.Email, + "user_id": user.ID, + "token": tokenDiag, "permissions_test": fiber.Map{ "zone_read": fiber.Map{"ok": zonesOK, "error": zonesErrMsg}, "dns_read": fiber.Map{"ok": dnsOK, "error": dnsErrMsg, "zone_tested": dnsZoneID}, @@ -311,10 +311,15 @@ func VerifyCloudflareToken(c *fiber.Ctx) error { "token_prev": preview, "was_dirty": dirty, "detail_error": detailErrMsg, - "policies": func() interface{} { if detail != nil { return detail.Policies }; return nil }(), + "policies": func() interface{} { + if detail != nil { + return detail.Policies + } + return nil + }(), "permissions_test": fiber.Map{ - "zone_read": fiber.Map{"ok": zonesOK, "error": zonesErrMsg}, - "dns_read": fiber.Map{"ok": dnsOK, "error": dnsErrMsg, "zone_tested": dnsZoneID}, + "zone_read": fiber.Map{"ok": zonesOK, "error": zonesErrMsg}, + "dns_read": fiber.Map{"ok": dnsOK, "error": dnsErrMsg, "zone_tested": dnsZoneID}, }, "hint": hint, }) @@ -408,7 +413,12 @@ func GetCloudflareSSL(c *fiber.Ctx) error { "source": "universal_ssl", "universal_ssl": fiber.Map{ "enabled": universal.Enabled, - "note": "Plan actual no expone certificate_packs. Universal SSL: " + func() string { if universal.Enabled { return "ACTIVO" }; return "INACTIVO" }(), + "note": "Plan actual no expone certificate_packs. Universal SSL: " + func() string { + if universal.Enabled { + return "ACTIVO" + } + return "INACTIVO" + }(), }, "packs_error": packsErr.Error(), }) diff --git a/rest/controllers/factura_controller.go b/rest/controllers/factura_controller.go index ecbdeeb..16eab1d 100644 --- a/rest/controllers/factura_controller.go +++ b/rest/controllers/factura_controller.go @@ -67,15 +67,15 @@ func CreateFactura(c *fiber.Ctx) error { return c.Status(400).JSON(fiber.Map{"error": "cliente_id es requerido"}) } f := &models.Factura{ - ClienteID: req.ClienteID, - ProyectoID: req.ProyectoID, - Numero: req.Numero, - Descripcion: req.Descripcion, - Monto: req.Monto, - Moneda: req.Moneda, - Estado: req.Estado, - Visible: req.Visible, - Notas: req.Notas, + ClienteID: req.ClienteID, + ProyectoID: req.ProyectoID, + Numero: req.Numero, + Descripcion: req.Descripcion, + Monto: req.Monto, + Moneda: req.Moneda, + Estado: req.Estado, + Visible: req.Visible, + Notas: req.Notas, FechaEmision: time.Now(), } if req.FechaEmision != "" { diff --git a/rest/controllers/ocr_controller.go b/rest/controllers/ocr_controller.go index 5267972..80a30c5 100644 --- a/rest/controllers/ocr_controller.go +++ b/rest/controllers/ocr_controller.go @@ -76,7 +76,7 @@ func TestOcrConfigHandler(c *fiber.Ctx) error { if mimeType == "" { mimeType = "image/png" } - texto, err := services.ExtraerTextoOCR(data, mimeType) + texto, err := services.ExtraerTextoOCR(0, data, mimeType) if err != nil { return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": err.Error()}) } diff --git a/rest/controllers/pasarelas_controller.go b/rest/controllers/pasarelas_controller.go index 59cd53e..970f3c4 100644 --- a/rest/controllers/pasarelas_controller.go +++ b/rest/controllers/pasarelas_controller.go @@ -597,4 +597,3 @@ func SavePaypalConfigWeb(c *fiber.Ctx) error { } return c.JSON(fiber.Map{"message": "Configuración PayPal guardada"}) } - diff --git a/rest/controllers/proyecto_controller.go b/rest/controllers/proyecto_controller.go index a82c81a..1c68f24 100644 --- a/rest/controllers/proyecto_controller.go +++ b/rest/controllers/proyecto_controller.go @@ -401,7 +401,9 @@ func DownloadEntregable(c *fiber.Ctx) error { func UpdateEntregableVisibilidad(c *fiber.Ctx) error { entID, _ := strconv.ParseUint(c.Params("entID"), 10, 32) - type Req struct{ Visible bool `json:"visible"` } + type Req struct { + Visible bool `json:"visible"` + } var req Req if err := c.BodyParser(&req); err != nil { return c.Status(400).JSON(fiber.Map{"error": err.Error()}) @@ -530,7 +532,9 @@ func GetTickets(c *fiber.Ctx) error { func UpdateTicketEstadoAdmin(c *fiber.Ctx) error { ticketID, _ := strconv.ParseUint(c.Params("ticketID"), 10, 32) - type Req struct{ Estado string `json:"estado"` } + type Req struct { + Estado string `json:"estado"` + } var req Req if err := c.BodyParser(&req); err != nil { return c.Status(400).JSON(fiber.Map{"error": err.Error()}) @@ -543,7 +547,9 @@ func UpdateTicketEstadoAdmin(c *fiber.Ctx) error { func AdminResponderTicket(c *fiber.Ctx) error { ticketID, _ := strconv.ParseUint(c.Params("ticketID"), 10, 32) - type Req struct{ Contenido string `json:"contenido"` } + type Req struct { + Contenido string `json:"contenido"` + } var req Req if err := c.BodyParser(&req); err != nil { return c.Status(400).JSON(fiber.Map{"error": err.Error()}) diff --git a/rest/controllers/register_controller.go b/rest/controllers/register_controller.go index f378f52..504a36a 100755 --- a/rest/controllers/register_controller.go +++ b/rest/controllers/register_controller.go @@ -103,7 +103,6 @@ func RequestPasswordResetPost(c *fiber.Ctx) error { }) } - // todo: Generar contrasenas aleatorias /do/generate-password func generatePassword(length int) (string, error) { const charset = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789!@#$%&" diff --git a/rest/controllers/role_controller.go b/rest/controllers/role_controller.go index 231f024..544cc0d 100755 --- a/rest/controllers/role_controller.go +++ b/rest/controllers/role_controller.go @@ -17,7 +17,7 @@ func CreateNewRole(c *fiber.Ctx) error { func RemoveRole(c *fiber.Ctx) error { var role models.Role c.BodyParser(&role) - + app.Http.Database. Delete(&models.RoleAndPermission{}) app.Http.Auth.Enforcer.LoadPolicy() @@ -30,10 +30,10 @@ func AssignRoleToUser(c *fiber.Ctx) error { c.BodyParser(&roleRequest) role := models.RoleAndPermission{ Ptype: "g", - V0: fmt.Sprintf("%d", roleRequest.UserID), - V1: roleRequest.Role, + V0: fmt.Sprintf("%d", roleRequest.UserID), + V1: roleRequest.Role, } - err := app.Http.Database.Unscoped().First(&role1, models.RoleAndPermission{Ptype: role.Ptype,V0: role.V0, V1: role.V1}).Error + err := app.Http.Database.Unscoped().First(&role1, models.RoleAndPermission{Ptype: role.Ptype, V0: role.V0, V1: role.V1}).Error if err != nil { app.Http.Database.Create(&role) app.Http.Auth.Enforcer.LoadPolicy() @@ -60,7 +60,7 @@ func ChangeRoleForUser(c *fiber.Ctx) error { var role models.RoleRequest var role1 models.RoleAndPermission c.BodyParser(&role) - err := app.Http.Database.Unscoped().First(&role1, models.RoleAndPermission{Ptype: "g",V0: fmt.Sprintf("%d", role.UserID), V1: role.OldRole}).Error + err := app.Http.Database.Unscoped().First(&role1, models.RoleAndPermission{Ptype: "g", V0: fmt.Sprintf("%d", role.UserID), V1: role.OldRole}).Error if err == nil { role1.V1 = role.Role role1.DeletedAt = gorm.DeletedAt{Valid: false} @@ -77,13 +77,13 @@ func AddPermissionOnRole(c *fiber.Ctx) error { c.BodyParser(&permission) if permission.Role != "" && permission.Module != "" && permission.Action != "" { role := models.RoleAndPermission{ - Ptype: "p", - V0: permission.Role, - V1: permission.Module, - V2: permission.Action, + Ptype: "p", + V0: permission.Role, + V1: permission.Module, + V2: permission.Action, Category: "permission", } - err := app.Http.Database.Unscoped().First(&role1, models.RoleAndPermission{Ptype: role.Ptype,V0: role.V0, V1: role.V1, V2: role.V2}).Error + err := app.Http.Database.Unscoped().First(&role1, models.RoleAndPermission{Ptype: role.Ptype, V0: role.V0, V1: role.V1, V2: role.V2}).Error if err != nil { app.Http.Database.Create(&role) } @@ -93,13 +93,13 @@ func AddPermissionOnRole(c *fiber.Ctx) error { if permission.Role != "" && permission.Route != "" && permission.Method != "" { fmt.Println(1) role := models.RoleAndPermission{ - Ptype: "p", - V0: permission.Role, - V1: permission.Route, - V2: permission.Method, + Ptype: "p", + V0: permission.Role, + V1: permission.Route, + V2: permission.Method, Category: "route", } - err := app.Http.Database.Unscoped().First(&role1, models.RoleAndPermission{Ptype: role.Ptype,V0: role.V0, V1: role.V1, V2: role.V2}).Error + err := app.Http.Database.Unscoped().First(&role1, models.RoleAndPermission{Ptype: role.Ptype, V0: role.V0, V1: role.V1, V2: role.V2}).Error if err != nil { app.Http.Database.Create(&role) } diff --git a/rest/controllers/saas_api_controller.go b/rest/controllers/saas_api_controller.go index 454bd85..9142692 100644 --- a/rest/controllers/saas_api_controller.go +++ b/rest/controllers/saas_api_controller.go @@ -349,9 +349,9 @@ func SaasWebhookInHandler(c *fiber.Ctx) error { cfg.Nombre, cfg.ID, c.IP(), c.Method()) return c.Status(fiber.StatusOK).JSON(fiber.Map{ - "ok": true, - "integracion": cfg.Nombre, - "saas_api_id": cfg.ID, - "message": fmt.Sprintf("Webhook recibido para integración: %s", cfg.Nombre), + "ok": true, + "integracion": cfg.Nombre, + "saas_api_id": cfg.ID, + "message": fmt.Sprintf("Webhook recibido para integración: %s", cfg.Nombre), }) } diff --git a/rest/controllers/servidor_controller.go b/rest/controllers/servidor_controller.go index 1121c6a..e1e5f38 100755 --- a/rest/controllers/servidor_controller.go +++ b/rest/controllers/servidor_controller.go @@ -167,17 +167,17 @@ func UpdateServidor(c *fiber.Ctx) error { // Usar map para Updates y así no perder campos con valor cero (incluido hostinger_vps_id=nil) updateMap := map[string]interface{}{ - "nombre": m.Nombre, - "ip_servidor": m.IpServidor, - "so": m.So, - "vencimiento": m.Vencimiento, - "ram": m.Ram, - "nucleos": m.Nucleos, - "disco": m.Disco, - "ultimo_ping": m.UltimoPing, - "prov_servidor_id": m.ProvServidorID, - "tipo_servidor_id": m.TipoServidorID, - "hostinger_vps_id": m.HostingerVpsID, + "nombre": m.Nombre, + "ip_servidor": m.IpServidor, + "so": m.So, + "vencimiento": m.Vencimiento, + "ram": m.Ram, + "nucleos": m.Nucleos, + "disco": m.Disco, + "ultimo_ping": m.UltimoPing, + "prov_servidor_id": m.ProvServidorID, + "tipo_servidor_id": m.TipoServidorID, + "hostinger_vps_id": m.HostingerVpsID, "hostinger_subscription_id": m.HostingerSubscriptionID, } if err := app.Http.Database.DB.Model(&models.Servidor{}).Where("id = ?", uid).Updates(updateMap).Error; err != nil { diff --git a/rest/controllers/tipo_servidor_controller.go b/rest/controllers/tipo_servidor_controller.go index 6b82d84..10c9240 100755 --- a/rest/controllers/tipo_servidor_controller.go +++ b/rest/controllers/tipo_servidor_controller.go @@ -36,13 +36,12 @@ func GetTipoServidor(c *fiber.Ctx) error { } return c.JSON(fiber.Map{ - "registros": records, - "total": total, + "registros": records, + "total": total, "totalPages": 1, }) } - func CreateTipoServidor(c *fiber.Ctx) error { var m models.TipoServidor diff --git a/rest/controllers/umind_admin_controller.go b/rest/controllers/umind_admin_controller.go index 8630f10..8ed7c2d 100644 --- a/rest/controllers/umind_admin_controller.go +++ b/rest/controllers/umind_admin_controller.go @@ -6,6 +6,7 @@ import ( "regexp" "strconv" "strings" + "time" "github.com/gofiber/fiber/v2" "github.com/sujit-baniya/fiber-boilerplate/app" @@ -611,3 +612,65 @@ func GetUmindEventosHandler(c *fiber.Ctx) error { } return c.JSON(fiber.Map{"items": items}) } + +// ─── Consumo ──────────────────────────────────────────────────────────────── + +// GetUmindUsoHandler devuelve el consumo de un tenant en un rango de fechas: +// el resumen agregado por tipo (lo que se factura) y el detalle línea a línea. +// Sin fechas, el rango es el mes en curso. +func GetUmindUsoHandler(c *fiber.Ctx) error { + tenantID, err := strconv.ParseUint(c.Query("tenant_id"), 10, 64) + if err != nil || tenantID == 0 { + return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "tenant_id requerido"}) + } + desde, hasta := rangoFechas(c.Query("desde"), c.Query("hasta")) + + resumen, err := models.GetResumenUso(uint(tenantID), desde, hasta) + if err != nil { + return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": err.Error()}) + } + detalle, err := models.GetUsoUmind(uint(tenantID), desde, hasta, c.Query("tipo")) + if err != nil { + return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": err.Error()}) + } + pendiente, _ := models.SumarUsoPendiente(uint(tenantID)) + + total := 0.0 + for _, r := range resumen { + total += r.Costo + } + + out := fiber.Map{ + "desde": desde.Format("2006-01-02"), "hasta": hasta.Format("2006-01-02"), + "resumen": resumen, "detalle": detalle, + "total_periodo": total, "pendiente_facturar": pendiente, + } + if plan := models.GetPlanDeTenant(uint(tenantID)); plan != nil { + out["plan"] = fiber.Map{ + "nombre": plan.Nombre, "moneda": plan.Moneda, + "precio_mensual": plan.PrecioMensual, "tope_consumo_mensual": plan.TopeConsumoMensual, + "max_agentes": plan.MaxAgentes, + } + } + return c.JSON(out) +} + +// rangoFechas parsea el filtro del panel. Ante una fecha inválida cae al mes +// en curso en vez de devolver error: es un filtro de lectura, no vale la pena +// romperle la pantalla al usuario por un query param mal escrito. +func rangoFechas(desdeStr, hastaStr string) (time.Time, time.Time) { + ahora := time.Now() + desde := time.Date(ahora.Year(), ahora.Month(), 1, 0, 0, 0, 0, ahora.Location()) + hasta := desde.AddDate(0, 1, 0) + + if d, err := time.Parse("2006-01-02", desdeStr); err == nil { + desde = d + } + if h, err := time.Parse("2006-01-02", hastaStr); err == nil { + hasta = h.AddDate(0, 0, 1) // inclusivo: "hasta el 31" incluye el 31 entero + } + if !hasta.After(desde) { + hasta = desde.AddDate(0, 1, 0) + } + return desde, hasta +} diff --git a/rest/controllers/user_controller.go b/rest/controllers/user_controller.go index 0dddd8a..7733833 100755 --- a/rest/controllers/user_controller.go +++ b/rest/controllers/user_controller.go @@ -7,7 +7,6 @@ import ( "strconv" ) - func UserInfo(c *fiber.Ctx) error { users, err := models.GetUserById(c.Params("id")) if err != nil { diff --git a/rest/controllers/websms_controller.go b/rest/controllers/websms_controller.go index 69c78ee..875b709 100644 --- a/rest/controllers/websms_controller.go +++ b/rest/controllers/websms_controller.go @@ -189,8 +189,13 @@ func ApiSendSms(c *fiber.Ctx) error { Para: b.Numero, Mensaje: b.Mensaje, Status: status, - MsgID: func() string { if resp != nil { return resp.MsgID() }; return "" }(), - Error: errStr, + MsgID: func() string { + if resp != nil { + return resp.MsgID() + } + return "" + }(), + Error: errStr, }) if err != nil { diff --git a/rest/controllers/whisper_asr_controller.go b/rest/controllers/whisper_asr_controller.go index 201cf1d..7093039 100644 --- a/rest/controllers/whisper_asr_controller.go +++ b/rest/controllers/whisper_asr_controller.go @@ -72,7 +72,7 @@ func TestWhisperAsrConfigHandler(c *fiber.Ctx) error { return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": "no se pudo leer el archivo"}) } - texto, err := services.TranscribirAudioSelfHosted(data, fh.Filename) + texto, err := services.TranscribirAudioSelfHosted(0, data, fh.Filename) if err != nil { return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": err.Error()}) } diff --git a/rest/routes/user.go b/rest/routes/user.go index bdd0cf6..e245e93 100755 --- a/rest/routes/user.go +++ b/rest/routes/user.go @@ -383,6 +383,7 @@ func UserRoutes(app fiber.Router) { protected.Get("/umind/conexiones/callback/:proveedor", controllers.UmindOAuthCallbackHandler) protected.Delete("/umind/conexiones/:id", middlewares.SoloAdmin, controllers.DeleteUmindConexionHandler) protected.Get("/umind/eventos", controllers.GetUmindEventosHandler) + protected.Get("/umind/uso", controllers.GetUmindUsoHandler) // Planes: definen el límite de agentes y los precios por consumo. Solo staff. protected.Get("/umind-planes", middlewares.MenuMiddleware, controllers.UmindPlanesPage) protected.Get("/umind-planes/list", controllers.GetUmindPlanesHandler)