feat(tts): add test-connection endpoint, hot-reload support, and Telegram voice messages

- Add POST /v1/tts/test-connection for testing TTS credentials before saving
- Add UpdateManager() with RWMutex for thread-safe HTTP handler hot-reload
- Add sendVoice() for Telegram voice bubble support via audio_as_voice metadata
- Wire ttsHandler into gateway deps for config reload propagation
This commit is contained in:
viettranx committed 2026-04-16 17:22:56 +07:00
1 parent c81cf6cf39
commit 34172d2767
9 files changed
+600 -6

No files matched your search

+22 -3
View File
@@ -8,6 +8,7 @@ import (
"log/slog"
"net/http"
"strings"
"sync"
"time"
"github.com/nextlevelbuilder/goclaw/internal/audio"
@@ -20,6 +21,7 @@ import (
// TTSHandler handles POST /v1/tts/synthesize — converts text to audio via a
// configured TTS provider and returns raw audio bytes with the appropriate MIME type.
type TTSHandler struct {
mu sync.RWMutex
manager *audio.Manager
rateLimiter func(string) bool // per-IP/token rate limit check (nil = no limit)
}
@@ -32,10 +34,22 @@ func NewTTSHandler(mgr *audio.Manager) *TTSHandler {
// SetRateLimiter injects the rate limiter function (reused from the server's global limiter).
func (h *TTSHandler) SetRateLimiter(fn func(string) bool) { h.rateLimiter = fn }
// RegisterRoutes wires POST /v1/tts/synthesize onto mux with RoleOperator auth.
// UpdateManager swaps the underlying manager (hot-reload safe).
func (h *TTSHandler) UpdateManager(mgr *audio.Manager) {
if mgr == nil {
return
}
h.mu.Lock()
defer h.mu.Unlock()
h.manager = mgr
}
// RegisterRoutes wires TTS endpoints onto mux with RoleOperator auth.
func (h *TTSHandler) RegisterRoutes(mux *http.ServeMux) {
mux.HandleFunc("POST /v1/tts/synthesize",
requireAuth(permissions.RoleOperator, h.handleSynthesize))
mux.HandleFunc("POST /v1/tts/test-connection",
requireAuth(permissions.RoleOperator, h.handleTestConnection))
}
// synthesizeRequest is the JSON body for POST /v1/tts/synthesize.
@@ -90,15 +104,20 @@ func (h *TTSHandler) handleSynthesize(w http.ResponseWriter, r *http.Request) {
}
// Resolve provider — explicit name or fall back to manager's primary.
// Copy manager reference under read lock to allow hot-reload.
h.mu.RLock()
mgr := h.manager
h.mu.RUnlock()
name := req.Provider
if name == "" {
name = h.manager.PrimaryProvider()
name = mgr.PrimaryProvider()
}
if name == "" {
http.Error(w, `{"error":"no tts provider configured"}`, http.StatusNotFound)
return
}
p, ok := h.manager.GetProvider(name)
p, ok := mgr.GetProvider(name)
if !ok {
http.Error(w, fmt.Sprintf(`{"error":%q}`, "provider not found: "+name), http.StatusNotFound)
return
+172
View File
@@ -0,0 +1,172 @@
package http
import (
"context"
"encoding/json"
"errors"
"fmt"
"log/slog"
"net/http"
"strings"
"time"
"github.com/nextlevelbuilder/goclaw/internal/audio"
"github.com/nextlevelbuilder/goclaw/internal/audio/edge"
"github.com/nextlevelbuilder/goclaw/internal/audio/elevenlabs"
"github.com/nextlevelbuilder/goclaw/internal/audio/minimax"
"github.com/nextlevelbuilder/goclaw/internal/audio/openai"
"github.com/nextlevelbuilder/goclaw/internal/i18n"
"github.com/nextlevelbuilder/goclaw/internal/store"
)
// testConnectionRequest is the JSON body for POST /v1/tts/test-connection.
type testConnectionRequest struct {
Provider string `json:"provider"`
APIKey string `json:"api_key,omitempty"`
APIBase string `json:"api_base,omitempty"`
VoiceID string `json:"voice_id,omitempty"`
ModelID string `json:"model_id,omitempty"`
GroupID string `json:"group_id,omitempty"` // MiniMax requires group_id
}
// testConnectionResponse is the JSON response for POST /v1/tts/test-connection.
type testConnectionResponse struct {
Success bool `json:"success"`
Provider string `json:"provider,omitempty"`
LatencyMs int64 `json:"latency_ms,omitempty"`
Error string `json:"error,omitempty"`
}
// supportedTestProviders lists providers that support ephemeral test-connection.
var supportedTestProviders = map[string]bool{
"openai": true,
"elevenlabs": true,
"edge": true,
"minimax": true,
}
// providersRequiringAPIKey lists providers that need an API key.
var providersRequiringAPIKey = map[string]bool{
"openai": true,
"elevenlabs": true,
"minimax": true,
}
const testConnectionTimeout = 10 * time.Second
// handleTestConnection serves POST /v1/tts/test-connection.
// Creates an ephemeral provider from request credentials and tests synthesis.
func (h *TTSHandler) handleTestConnection(w http.ResponseWriter, r *http.Request) {
ctx := r.Context()
locale := store.LocaleFromContext(ctx)
// Rate limit (same as synthesize).
if h.rateLimiter != nil {
key := r.RemoteAddr
if tok := extractBearerToken(r); tok != "" {
key = "token:" + tok
}
if !h.rateLimiter(key) {
w.Header().Set("Retry-After", "60")
http.Error(w, fmt.Sprintf(`{"error":%q}`, i18n.T(locale, i18n.MsgRateLimitExceeded)), http.StatusTooManyRequests)
return
}
}
r.Body = http.MaxBytesReader(w, r.Body, maxSynthesizeBodyBytes)
var req testConnectionRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
http.Error(w, fmt.Sprintf(`{"error":"invalid json: %s"}`, err.Error()), http.StatusBadRequest)
return
}
req.Provider = strings.TrimSpace(req.Provider)
if req.Provider == "" {
http.Error(w, `{"error":"provider is required"}`, http.StatusBadRequest)
return
}
if !supportedTestProviders[req.Provider] {
http.Error(w, fmt.Sprintf(`{"error":"unsupported provider: %s"}`, req.Provider), http.StatusBadRequest)
return
}
if providersRequiringAPIKey[req.Provider] && req.APIKey == "" {
http.Error(w, fmt.Sprintf(`{"error":"api_key is required for %s"}`, req.Provider), http.StatusBadRequest)
return
}
// Create ephemeral provider.
provider, err := createEphemeralTTSProvider(req)
if err != nil {
slog.Warn("tts.test-connection.provider-create-failed", "provider", req.Provider, "error", err)
http.Error(w, fmt.Sprintf(`{"error":%q}`, err.Error()), http.StatusBadRequest)
return
}
// Synthesize short test text.
synthCtx, cancel := context.WithTimeout(ctx, testConnectionTimeout)
defer cancel()
start := time.Now()
_, err = provider.Synthesize(synthCtx, "test", audio.TTSOptions{Voice: req.VoiceID, Model: req.ModelID})
dur := time.Since(start)
if err != nil {
if errors.Is(err, context.DeadlineExceeded) || errors.Is(err, context.Canceled) {
slog.Warn("tts.test-connection.timeout", "provider", req.Provider, "ms", dur.Milliseconds())
writeJSON(w, http.StatusGatewayTimeout, testConnectionResponse{
Success: false, Error: "test timeout",
})
return
}
slog.Warn("tts.test-connection.failed", "provider", req.Provider, "error", err)
writeJSON(w, http.StatusBadGateway, testConnectionResponse{
Success: false, Error: "upstream synthesis failed",
})
return
}
slog.Info("tts.test-connection.ok", "provider", req.Provider, "ms", dur.Milliseconds())
writeJSON(w, http.StatusOK, testConnectionResponse{
Success: true,
Provider: req.Provider,
LatencyMs: dur.Milliseconds(),
})
}
// createEphemeralTTSProvider creates a TTS provider from request credentials.
// The provider is ephemeral — not registered in the manager.
func createEphemeralTTSProvider(req testConnectionRequest) (audio.TTSProvider, error) {
switch req.Provider {
case "openai":
return openai.NewProvider(openai.Config{
APIKey: req.APIKey,
APIBase: req.APIBase,
Model: req.ModelID,
Voice: req.VoiceID,
}), nil
case "elevenlabs":
return elevenlabs.NewTTSProvider(elevenlabs.Config{
APIKey: req.APIKey,
BaseURL: req.APIBase,
VoiceID: req.VoiceID,
ModelID: req.ModelID,
}), nil
case "edge":
return edge.NewProvider(edge.Config{
Voice: req.VoiceID,
}), nil
case "minimax":
return minimax.NewProvider(minimax.Config{
APIKey: req.APIKey,
APIBase: req.APIBase,
GroupID: req.GroupID,
VoiceID: req.VoiceID,
Model: req.ModelID,
}), nil
default:
return nil, fmt.Errorf("unsupported provider: %s", req.Provider)
}
}
+134
View File
@@ -0,0 +1,134 @@
package http
import (
"encoding/json"
"net/http"
"net/http/httptest"
"testing"
"github.com/google/uuid"
"github.com/nextlevelbuilder/goclaw/internal/audio"
"github.com/nextlevelbuilder/goclaw/internal/crypto"
"github.com/nextlevelbuilder/goclaw/internal/store"
)
// TestTestConnection_MissingProvider verifies 400 when provider field is missing.
func TestTestConnection_MissingProvider(t *testing.T) {
setupTestToken(t, "") // dev mode
mgr := audio.NewManager(audio.ManagerConfig{})
mux := newTTSMux(mgr)
req := httptest.NewRequest("POST", "/v1/tts/test-connection",
ttsBody(t, map[string]string{}))
req.Header.Set("Content-Type", "application/json")
rr := httptest.NewRecorder()
mux.ServeHTTP(rr, req)
if rr.Code != http.StatusBadRequest {
t.Errorf("want 400, got %d: %s", rr.Code, rr.Body.String())
}
var resp map[string]any
json.Unmarshal(rr.Body.Bytes(), &resp)
if errStr, _ := resp["error"].(string); errStr != "provider is required" {
t.Errorf("want 'provider is required', got %q", errStr)
}
}
// TestTestConnection_UnsupportedProvider verifies 400 for unknown provider.
func TestTestConnection_UnsupportedProvider(t *testing.T) {
setupTestToken(t, "") // dev mode
mgr := audio.NewManager(audio.ManagerConfig{})
mux := newTTSMux(mgr)
req := httptest.NewRequest("POST", "/v1/tts/test-connection",
ttsBody(t, map[string]string{"provider": "unknown_provider"}))
req.Header.Set("Content-Type", "application/json")
rr := httptest.NewRecorder()
mux.ServeHTTP(rr, req)
if rr.Code != http.StatusBadRequest {
t.Errorf("want 400, got %d: %s", rr.Code, rr.Body.String())
}
var resp map[string]any
json.Unmarshal(rr.Body.Bytes(), &resp)
if errStr, _ := resp["error"].(string); errStr != "unsupported provider: unknown_provider" {
t.Errorf("want 'unsupported provider: unknown_provider', got %q", errStr)
}
}
// TestTestConnection_MissingAPIKey verifies 400 when API key is required but missing.
func TestTestConnection_MissingAPIKey(t *testing.T) {
setupTestToken(t, "") // dev mode
mgr := audio.NewManager(audio.ManagerConfig{})
mux := newTTSMux(mgr)
req := httptest.NewRequest("POST", "/v1/tts/test-connection",
ttsBody(t, map[string]string{"provider": "openai"}))
req.Header.Set("Content-Type", "application/json")
rr := httptest.NewRecorder()
mux.ServeHTTP(rr, req)
if rr.Code != http.StatusBadRequest {
t.Errorf("want 400, got %d: %s", rr.Code, rr.Body.String())
}
var resp map[string]any
json.Unmarshal(rr.Body.Bytes(), &resp)
if errStr, _ := resp["error"].(string); errStr != "api_key is required for openai" {
t.Errorf("want 'api_key is required for openai', got %q", errStr)
}
}
// TestTestConnection_EdgeNoAPIKey verifies Edge provider does not require API key.
func TestTestConnection_EdgeNoAPIKey(t *testing.T) {
setupTestToken(t, "") // dev mode
mgr := audio.NewManager(audio.ManagerConfig{})
mux := newTTSMux(mgr)
req := httptest.NewRequest("POST", "/v1/tts/test-connection",
ttsBody(t, map[string]string{"provider": "edge"}))
req.Header.Set("Content-Type", "application/json")
rr := httptest.NewRecorder()
mux.ServeHTTP(rr, req)
// Edge TTS requires edge-tts CLI. In tests, may fail with 502 if CLI not present,
// but should NOT return 400 "api_key is required".
if rr.Code == http.StatusBadRequest {
var resp map[string]any
json.Unmarshal(rr.Body.Bytes(), &resp)
if errStr, _ := resp["error"].(string); errStr == "api_key is required for edge" {
t.Error("edge provider should not require api_key")
}
}
// Either 200 (if edge-tts installed) or 502 (if not) is acceptable.
}
// TestTestConnection_BelowOperator verifies 403 for non-operator roles.
func TestTestConnection_BelowOperator(t *testing.T) {
setupTestToken(t, ttsTestToken) // token required for auth
viewerRaw := "test-conn-viewer-key"
setupTestCache(t, map[string]*store.APIKeyData{
crypto.HashAPIKey(viewerRaw): {
ID: uuid.New(),
Scopes: []string{"operator.read"},
},
})
mgr := audio.NewManager(audio.ManagerConfig{})
mux := newTTSMux(mgr)
req := httptest.NewRequest("POST", "/v1/tts/test-connection",
ttsBody(t, map[string]string{"provider": "openai", "api_key": "sk-test"}))
req.Header.Set("Content-Type", "application/json")
req.Header.Set("Authorization", "Bearer "+viewerRaw)
rr := httptest.NewRecorder()
mux.ServeHTTP(rr, req)
if rr.Code != http.StatusForbidden {
t.Errorf("want 403, got %d: %s", rr.Code, rr.Body.String())
}
}
+134
View File
@@ -0,0 +1,134 @@
package http
import (
"net/http"
"net/http/httptest"
"sync"
"testing"
"github.com/nextlevelbuilder/goclaw/internal/audio"
)
// TestTTSHandler_UpdateManager_SwapsProvider verifies that UpdateManager changes
// the underlying provider.
func TestTTSHandler_UpdateManager_SwapsProvider(t *testing.T) {
setupTestToken(t, "") // dev mode
// Initial provider "mock-a"
providerA := &mockTTSProvider{name: "mock-a"}
mgrA := audio.NewManager(audio.ManagerConfig{Primary: "mock-a"})
mgrA.RegisterTTS(providerA)
h := NewTTSHandler(mgrA)
mux := http.NewServeMux()
h.RegisterRoutes(mux)
// First request uses mock-a
req1 := httptest.NewRequest("POST", "/v1/tts/synthesize",
ttsBody(t, map[string]string{"text": "hello"}))
req1.Header.Set("Content-Type", "application/json")
rr1 := httptest.NewRecorder()
mux.ServeHTTP(rr1, req1)
if rr1.Code != http.StatusOK {
t.Fatalf("first request: want 200, got %d: %s", rr1.Code, rr1.Body.String())
}
// Swap to provider "mock-b"
providerB := &mockTTSProvider{name: "mock-b"}
mgrB := audio.NewManager(audio.ManagerConfig{Primary: "mock-b"})
mgrB.RegisterTTS(providerB)
h.UpdateManager(mgrB)
// Second request should use mock-b
req2 := httptest.NewRequest("POST", "/v1/tts/synthesize",
ttsBody(t, map[string]string{"text": "hello", "provider": "mock-b"}))
req2.Header.Set("Content-Type", "application/json")
rr2 := httptest.NewRecorder()
mux.ServeHTTP(rr2, req2)
if rr2.Code != http.StatusOK {
t.Fatalf("second request: want 200, got %d: %s", rr2.Code, rr2.Body.String())
}
// Old provider should no longer be accessible after swap
req3 := httptest.NewRequest("POST", "/v1/tts/synthesize",
ttsBody(t, map[string]string{"text": "hello", "provider": "mock-a"}))
req3.Header.Set("Content-Type", "application/json")
rr3 := httptest.NewRecorder()
mux.ServeHTTP(rr3, req3)
if rr3.Code != http.StatusNotFound {
t.Errorf("third request (old provider): want 404, got %d", rr3.Code)
}
}
// TestTTSHandler_UpdateManager_ConcurrentSafe verifies no race condition
// when calling UpdateManager while requests are in flight.
// Note: Uses stateless mock to avoid false race detection in mock's capturedOpts.
func TestTTSHandler_UpdateManager_ConcurrentSafe(t *testing.T) {
setupTestToken(t, "") // dev mode
// Use stateless mock — each provider is separate instance
mgr := audio.NewManager(audio.ManagerConfig{Primary: "mock"})
mgr.RegisterTTS(&mockTTSProvider{name: "mock"})
h := NewTTSHandler(mgr)
mux := http.NewServeMux()
h.RegisterRoutes(mux)
var wg sync.WaitGroup
const numRequests = 10
// Spawn concurrent requests
for i := range numRequests {
wg.Add(1)
go func(idx int) {
defer wg.Done()
body := ttsBody(t, map[string]string{"text": "hello"})
req := httptest.NewRequest("POST", "/v1/tts/synthesize", body)
req.Header.Set("Content-Type", "application/json")
rr := httptest.NewRecorder()
mux.ServeHTTP(rr, req)
// Either 200 or 404 is acceptable during swap
}(i)
}
// Mid-way call UpdateManager with new manager
wg.Add(1)
go func() {
defer wg.Done()
newMgr := audio.NewManager(audio.ManagerConfig{Primary: "mock-new"})
newMgr.RegisterTTS(&mockTTSProvider{name: "mock-new"})
h.UpdateManager(newMgr)
}()
wg.Wait()
// If we reach here without panic/race in handler code, test passes.
// Note: The handler's mutex protects manager access; mock races are test artifacts.
}
// TestTTSHandler_UpdateManager_NilManagerNoop verifies UpdateManager(nil)
// does not panic and keeps the old manager.
func TestTTSHandler_UpdateManager_NilManagerNoop(t *testing.T) {
setupTestToken(t, "") // dev mode
provider := &mockTTSProvider{name: "mock"}
mgr := audio.NewManager(audio.ManagerConfig{Primary: "mock"})
mgr.RegisterTTS(provider)
h := NewTTSHandler(mgr)
mux := http.NewServeMux()
h.RegisterRoutes(mux)
// Call with nil — should not panic
h.UpdateManager(nil)
// Request should still work with original manager
req := httptest.NewRequest("POST", "/v1/tts/synthesize",
ttsBody(t, map[string]string{"text": "hello"}))
req.Header.Set("Content-Type", "application/json")
rr := httptest.NewRecorder()
mux.ServeHTTP(rr, req)
if rr.Code != http.StatusOK {
t.Errorf("after nil update: want 200, got %d: %s", rr.Code, rr.Body.String())
}
}