Merge remote-tracking branch 'upstream/dev' into dev

This commit is contained in:
ntduc committed 2026-07-22 20:24:10 +07:00
commit b49c6abcc7
22 files changed
+1641 -67

No files matched your search

+4
View File
@@ -278,6 +278,10 @@ func runGateway() {
browserMgr.SetCookieProvider(newStoreBrowserCookieProvider(pgStores.BrowserCookies))
}
if ttsTool != nil && pgStores.SystemConfigs != nil {
ttsTool.SetSystemConfigStore(pgStores.SystemConfigs)
}
// Recover from crashes: flip ghost 'summoning' rows to 'summon_failed'.
// Summon goroutines don't survive process restart; stale DB rows would trap the UI.
if pgStores.Agents != nil {
+51 -1
View File
@@ -7,6 +7,7 @@ import (
"github.com/nextlevelbuilder/goclaw/internal/audio/elevenlabs"
geminiaudio "github.com/nextlevelbuilder/goclaw/internal/audio/gemini"
minimaxaudio "github.com/nextlevelbuilder/goclaw/internal/audio/minimax"
"github.com/nextlevelbuilder/goclaw/internal/audio/openaicompat"
"github.com/nextlevelbuilder/goclaw/internal/bus"
"github.com/nextlevelbuilder/goclaw/internal/config"
"github.com/nextlevelbuilder/goclaw/internal/memory"
@@ -275,6 +276,26 @@ func setupTTS(cfg *config.Config) *tts.Manager {
}))
}
// OpenAI-compatible self-hosted endpoint. api_base is the enable switch —
// unlike the vendor providers there is no API key to gate on, since these
// endpoints commonly have no auth.
if base := ttsCfg.OpenAICompat.APIBase; base != "" {
provider, err := openaicompat.NewTTSProvider(openaicompat.Config{
APIBase: base,
APIKey: ttsCfg.OpenAICompat.APIKey,
TTSModel: ttsCfg.OpenAICompat.Model,
TTSVoice: ttsCfg.OpenAICompat.Voice,
TTSFormat: ttsCfg.OpenAICompat.Format,
TimeoutMs: ttsCfg.TimeoutMs,
})
if err != nil {
slog.Warn("audio.tts: openai_compat not registered", "error", err)
} else {
mgr.RegisterProvider(provider)
slog.Info("audio.tts: openai_compat registered", "api_base", base)
}
}
if key := ttsCfg.ElevenLabs.APIKey; key != "" {
mgr.RegisterProvider(tts.NewElevenLabsProvider(tts.ElevenLabsConfig{
APIKey: key,
@@ -359,6 +380,29 @@ func setupAudioExtras(cfg *config.Config, mgr *tts.Manager) {
}
}
// STT chain. Built from what actually registered, so Transcribe never walks
// a name it will only skip with a warning. "proxy" is always appended: it is
// registered later, per channel, by BridgeLegacySTT.
var sttChain []string
// OpenAI-compatible self-hosted endpoint, first when present: it is an
// explicit operator choice, and it keeps audio on the local network.
if base := cfg.Tts.OpenAICompat.APIBase; base != "" {
provider, err := openaicompat.NewSTTProvider(openaicompat.Config{
APIBase: base,
APIKey: cfg.Tts.OpenAICompat.APIKey,
STTModel: cfg.Tts.OpenAICompat.STTModel,
TimeoutMs: cfg.Tts.TimeoutMs,
})
if err != nil {
slog.Warn("audio.stt: openai_compat not registered", "error", err)
} else {
mgr.RegisterSTT(provider)
sttChain = append(sttChain, provider.Name())
slog.Info("audio.stt: openai_compat registered", "api_base", base)
}
}
// ElevenLabs STT (Scribe v2) — reuse TTS credentials. Registered as tenant-scope
// default; per-request tenant override lands via builtin_tools[stt] in Phase 5
// channel migration. Legacy per-channel STTProxyURL is bridged separately.
@@ -367,7 +411,13 @@ func setupAudioExtras(cfg *config.Config, mgr *tts.Manager) {
APIKey: ellKey,
BaseURL: ellBase,
}))
mgr.SetSTTChain([]string{"elevenlabs", "proxy"})
sttChain = append(sttChain, "elevenlabs")
slog.Info("audio.stt: elevenlabs registered")
}
if len(sttChain) > 0 {
sttChain = append(sttChain, "proxy")
mgr.SetSTTChain(sttChain)
slog.Info("audio.stt: chain configured", "chain", sttChain)
}
}
+74
View File
@@ -0,0 +1,74 @@
// Package openaicompat implements the OpenAI audio API (speech + transcriptions)
// against any OpenAI-compatible endpoint — gpu-manager, Speaches, vLLM, LocalAI,
// llama.cpp, Ollama.
//
// It is deliberately distinct from the openai package. Both speak the same wire
// format, but "openai" means api.openai.com semantics: a fixed voice set
// (alloy, echo, ...), an mp3 default, and a base URL that falls back to the
// public API. A self-hosted engine shares none of that — its voices are its own
// (e.g. Piper's "fr_FR-gilles-low"), its formats are whatever it built in, and
// there is no sane default host.
//
// Keeping them separate matters beyond tidiness: audio.IsVoiceCompatible applies
// OpenAI's voice allowlist to any provider named "openai" and silently
// substitutes the default voice for anything outside it. Routing a self-hosted
// engine through that name turns a valid local voice ID into "alloy" with no
// error. Providers without validation rules pass through untouched, which is the
// correct behaviour here.
package openaicompat
import "errors"
// ErrAPIBaseRequired is returned by the constructors when APIBase is empty.
// Unlike the openai package there is no public endpoint to fall back to, and
// defaulting to api.openai.com would send self-hosted traffic to a vendor.
var ErrAPIBaseRequired = errors.New("openai_compat: api_base is required")
// providerName is the stable identifier reported to the audio Manager and used
// in chain configuration. It must not be "openai" — see the package doc.
const providerName = "openai_compat"
// Default tuning. Formats intentionally follow the OpenAI wire defaults; an
// endpoint that cannot encode mp3 is configured with Format: "wav".
const (
defaultTTSFormat = "mp3"
defaultTimeoutMs = 30000
)
// Config bundles the endpoint, credentials and defaults for both the TTS and
// STT providers. APIBase is required; everything else has a default or is
// resolved per-request.
type Config struct {
// APIBase is the OpenAI-compatible root, including any version prefix —
// e.g. "http://gpu-manager:8080/v1". Required.
APIBase string
// APIKey is sent as a Bearer token when non-empty. Self-hosted endpoints
// frequently have no auth, so an empty key omits the header entirely
// rather than sending "Bearer ".
APIKey string
// TTSModel is the default model for synthesis. Optional: some engines
// resolve the model from the requested voice instead.
TTSModel string
// TTSVoice is the default voice for synthesis. Optional.
TTSVoice string
// TTSFormat is the default response_format for synthesis. Defaults to
// "mp3" to match OpenAI. Set to "wav" or "pcm" for engines without an
// encoder.
TTSFormat string
// STTModel is the default model for transcription. Optional.
STTModel string
// TimeoutMs is the per-request timeout. Defaults to 30000.
TimeoutMs int
}
// withDefaults returns a copy with zero-valued optional fields filled in.
// APIBase is not defaulted — its absence is an error, not a fallback.
func (c Config) withDefaults() Config {
if c.TTSFormat == "" {
c.TTSFormat = defaultTTSFormat
}
if c.TimeoutMs <= 0 {
c.TimeoutMs = defaultTimeoutMs
}
return c
}
+211
View File
@@ -0,0 +1,211 @@
package openaicompat
import (
"bytes"
"context"
"encoding/json"
"fmt"
"io"
"mime/multipart"
"net/http"
"os"
"path/filepath"
"strings"
"time"
"github.com/nextlevelbuilder/goclaw/internal/audio"
)
const (
// sttMaxBytes matches the OpenAI upload limit, which compatible engines
// generally adopt. Enforced before the request so an oversized file fails
// locally instead of after a full upload.
sttMaxBytes = 25 << 20 // 25 MB
// sttDefaultTimeout is the floor for transcription, which is slower than
// synthesis and routinely exceeds the shared 30 s default.
sttDefaultTimeout = 120 * time.Second
)
// STTProvider transcribes audio via an OpenAI-compatible
// POST /audio/transcriptions.
type STTProvider struct {
cfg Config
}
// NewSTTProvider returns an STT provider for the configured endpoint.
// Returns ErrAPIBaseRequired when cfg.APIBase is empty.
func NewSTTProvider(cfg Config) (*STTProvider, error) {
if strings.TrimSpace(cfg.APIBase) == "" {
return nil, ErrAPIBaseRequired
}
return &STTProvider{cfg: cfg.withDefaults()}, nil
}
// Name returns the stable provider identifier.
func (p *STTProvider) Name() string { return providerName }
// Transcribe converts audio to text.
//
// The multipart filename is significant, not cosmetic: several compatible
// engines type the audio by its extension rather than by Content-Type, and
// reject an upload whose name carries no format they recognise. The filename is
// therefore derived from STTInput.Filename, then the base of FilePath, then the
// MIME type — never left blank.
func (p *STTProvider) Transcribe(ctx context.Context, in audio.STTInput, opts audio.STTOptions) (*audio.TranscriptResult, error) {
body, contentType, err := p.buildForm(in, opts)
if err != nil {
return nil, err
}
url := strings.TrimRight(p.cfg.APIBase, "/") + "/audio/transcriptions"
req, err := http.NewRequestWithContext(ctx, http.MethodPost, url, body)
if err != nil {
return nil, fmt.Errorf("openai_compat stt: create request: %w", err)
}
req.Header.Set("Content-Type", contentType)
if p.cfg.APIKey != "" {
req.Header.Set("Authorization", "Bearer "+p.cfg.APIKey)
}
timeout := sttDefaultTimeout
if opts.TimeoutMs > 0 {
timeout = time.Duration(opts.TimeoutMs) * time.Millisecond
}
hc := &http.Client{Timeout: timeout}
resp, err := hc.Do(req)
if err != nil {
return nil, fmt.Errorf("openai_compat stt: http request: %w", err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
errBody, _ := io.ReadAll(io.LimitReader(resp.Body, errBodyLimit))
return nil, fmt.Errorf("openai_compat stt: endpoint error %d: %s", resp.StatusCode, strings.TrimSpace(string(errBody)))
}
// verbose_json is a superset of json; language and duration are absent from
// a plain json response and stay zero-valued, which the Manager tolerates.
var result struct {
Text string `json:"text"`
Language string `json:"language"`
Duration float64 `json:"duration"`
}
if err := json.NewDecoder(resp.Body).Decode(&result); err != nil {
return nil, fmt.Errorf("openai_compat stt: parse response: %w", err)
}
language := result.Language
if language == "" {
language = opts.Language
}
return &audio.TranscriptResult{
Text: result.Text,
Language: language,
Duration: result.Duration,
Provider: providerName,
}, nil
}
// buildForm assembles the multipart body, reading the audio from FilePath or
// Bytes. Returns the body and its Content-Type header value.
func (p *STTProvider) buildForm(in audio.STTInput, opts audio.STTOptions) (io.Reader, string, error) {
var buf bytes.Buffer
mw := multipart.NewWriter(&buf)
if model := firstNonEmpty(opts.ModelID, p.cfg.STTModel); model != "" {
if err := mw.WriteField("model", model); err != nil {
return nil, "", fmt.Errorf("openai_compat stt: write model field: %w", err)
}
}
if opts.Language != "" {
if err := mw.WriteField("language", opts.Language); err != nil {
return nil, "", fmt.Errorf("openai_compat stt: write language field: %w", err)
}
}
if err := mw.WriteField("response_format", "json"); err != nil {
return nil, "", fmt.Errorf("openai_compat stt: write response_format field: %w", err)
}
fw, err := mw.CreateFormFile("file", sttFilename(in))
if err != nil {
return nil, "", fmt.Errorf("openai_compat stt: create form file: %w", err)
}
if err := writeAudio(fw, in); err != nil {
return nil, "", err
}
if err := mw.Close(); err != nil {
return nil, "", fmt.Errorf("openai_compat stt: close multipart writer: %w", err)
}
return &buf, mw.FormDataContentType(), nil
}
// writeAudio copies the input audio into dst, enforcing the size cap.
func writeAudio(dst io.Writer, in audio.STTInput) error {
if in.FilePath != "" {
info, err := os.Stat(in.FilePath)
if err != nil {
return fmt.Errorf("openai_compat stt: stat file: %w", err)
}
if info.Size() > sttMaxBytes {
return fmt.Errorf("openai_compat stt: file too large (%d bytes, max %d)", info.Size(), sttMaxBytes)
}
f, err := os.Open(in.FilePath)
if err != nil {
return fmt.Errorf("openai_compat stt: open file: %w", err)
}
defer f.Close()
if _, err := io.Copy(dst, f); err != nil {
return fmt.Errorf("openai_compat stt: read file: %w", err)
}
return nil
}
if len(in.Bytes) == 0 {
return fmt.Errorf("openai_compat stt: neither FilePath nor Bytes provided")
}
if len(in.Bytes) > sttMaxBytes {
return fmt.Errorf("openai_compat stt: audio too large (%d bytes, max %d)", len(in.Bytes), sttMaxBytes)
}
if _, err := dst.Write(in.Bytes); err != nil {
return fmt.Errorf("openai_compat stt: write audio bytes: %w", err)
}
return nil
}
// sttFilename derives the multipart filename. See Transcribe for why a blank
// name is not an option.
func sttFilename(in audio.STTInput) string {
if in.Filename != "" {
return in.Filename
}
if in.FilePath != "" {
return filepath.Base(in.FilePath)
}
return "audio" + extFromMime(in.MimeType)
}
// extFromMime maps a MIME type to a file extension, including the leading dot.
func extFromMime(mime string) string {
// Strip codec parameters, e.g. "audio/ogg; codecs=opus".
if idx := strings.IndexByte(mime, ';'); idx >= 0 {
mime = mime[:idx]
}
switch strings.TrimSpace(mime) {
case "audio/ogg", "audio/opus":
return ".ogg"
case "audio/mpeg", "audio/mp3":
return ".mp3"
case "audio/wav", "audio/wave", "audio/x-wav":
return ".wav"
case "audio/pcm", "audio/l16":
return ".pcm"
case "audio/mp4", "audio/m4a", "audio/x-m4a":
return ".m4a"
case "audio/webm":
return ".webm"
case "audio/flac", "audio/x-flac":
return ".flac"
default:
return ".bin"
}
}
+267
View File
@@ -0,0 +1,267 @@
package openaicompat_test
import (
"context"
"io"
"net/http"
"net/http/httptest"
"os"
"path/filepath"
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"github.com/nextlevelbuilder/goclaw/internal/audio"
"github.com/nextlevelbuilder/goclaw/internal/audio/openaicompat"
)
// sttForm captures what the fake endpoint received.
type sttForm struct {
path string
auth string
filename string
fileData string
model string
language string
format string
}
// newSTTServer returns a fake OpenAI-compatible transcription endpoint that
// records the multipart form and replies with body.
func newSTTServer(t *testing.T, got *sttForm, status int, body string) *httptest.Server {
t.Helper()
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
got.path = r.URL.Path
got.auth = r.Header.Get("Authorization")
require.NoError(t, r.ParseMultipartForm(32<<20))
got.model = r.FormValue("model")
got.language = r.FormValue("language")
got.format = r.FormValue("response_format")
file, header, err := r.FormFile("file")
if err == nil {
defer file.Close()
got.filename = header.Filename
data, err := io.ReadAll(file)
require.NoError(t, err)
got.fileData = string(data)
}
w.WriteHeader(status)
_, _ = io.WriteString(w, body)
}))
t.Cleanup(func() { srv.Close() })
return srv
}
func TestNewSTTProviderRequiresAPIBase(t *testing.T) {
_, err := openaicompat.NewSTTProvider(openaicompat.Config{})
require.ErrorIs(t, err, openaicompat.ErrAPIBaseRequired)
}
func TestSTTProviderName(t *testing.T) {
provider, err := openaicompat.NewSTTProvider(openaicompat.Config{APIBase: "http://x/v1"})
require.NoError(t, err)
assert.Equal(t, "openai_compat", provider.Name())
}
func TestSTTTranscribeFromBytes(t *testing.T) {
var got sttForm
srv := newSTTServer(t, &got, http.StatusOK, `{"text":"bonjour le monde","language":"fr","duration":1.5}`)
provider, err := openaicompat.NewSTTProvider(openaicompat.Config{
APIBase: srv.URL + "/v1",
APIKey: "secret",
STTModel: "whisper-1",
})
require.NoError(t, err)
result, err := provider.Transcribe(context.Background(),
audio.STTInput{Bytes: []byte("RIFFfake"), MimeType: "audio/wav", Filename: "utterance.wav"},
audio.STTOptions{Language: "fr"},
)
require.NoError(t, err)
assert.Equal(t, "/v1/audio/transcriptions", got.path)
assert.Equal(t, "Bearer secret", got.auth)
assert.Equal(t, "utterance.wav", got.filename)
assert.Equal(t, "RIFFfake", got.fileData)
assert.Equal(t, "whisper-1", got.model)
assert.Equal(t, "fr", got.language)
assert.Equal(t, "json", got.format)
assert.Equal(t, "bonjour le monde", result.Text)
assert.Equal(t, "fr", result.Language)
assert.InEpsilon(t, 1.5, result.Duration, 0.0001)
assert.Equal(t, "openai_compat", result.Provider)
}
func TestSTTTranscribeFromFilePath(t *testing.T) {
dir := t.TempDir()
path := filepath.Join(dir, "voice-note.ogg")
require.NoError(t, os.WriteFile(path, []byte("OggS-fake"), 0o600))
var got sttForm
srv := newSTTServer(t, &got, http.StatusOK, `{"text":"salut"}`)
provider, err := openaicompat.NewSTTProvider(openaicompat.Config{APIBase: srv.URL})
require.NoError(t, err)
result, err := provider.Transcribe(context.Background(),
audio.STTInput{FilePath: path, MimeType: "audio/ogg"},
audio.STTOptions{},
)
require.NoError(t, err)
assert.Equal(t, "voice-note.ogg", got.filename, "filename falls back to the base of FilePath")
assert.Equal(t, "OggS-fake", got.fileData)
assert.Equal(t, "salut", result.Text)
}
// TestSTTFilenameIsNeverBlank guards the extension-typing requirement: some
// compatible engines (gpu-manager among them) infer the audio format from the
// filename and reject an upload they cannot type.
func TestSTTFilenameIsNeverBlank(t *testing.T) {
tests := []struct {
name string
in audio.STTInput
wantName string
}{
{
name: "explicit filename wins",
in: audio.STTInput{Bytes: []byte("x"), Filename: "utterance.wav", MimeType: "audio/ogg"},
wantName: "utterance.wav",
},
{
name: "derived from wav mime",
in: audio.STTInput{Bytes: []byte("x"), MimeType: "audio/wav"},
wantName: "audio.wav",
},
{
name: "derived from ogg mime",
in: audio.STTInput{Bytes: []byte("x"), MimeType: "audio/ogg"},
wantName: "audio.ogg",
},
{
name: "codec parameters stripped",
in: audio.STTInput{Bytes: []byte("x"), MimeType: "audio/ogg; codecs=opus"},
wantName: "audio.ogg",
},
{
name: "mpeg mime",
in: audio.STTInput{Bytes: []byte("x"), MimeType: "audio/mpeg"},
wantName: "audio.mp3",
},
{
name: "unknown mime still carries an extension",
in: audio.STTInput{Bytes: []byte("x"), MimeType: "audio/weird"},
wantName: "audio.bin",
},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
var got sttForm
srv := newSTTServer(t, &got, http.StatusOK, `{"text":"ok"}`)
provider, err := openaicompat.NewSTTProvider(openaicompat.Config{APIBase: srv.URL})
require.NoError(t, err)
_, err = provider.Transcribe(context.Background(), tc.in, audio.STTOptions{})
require.NoError(t, err)
assert.Equal(t, tc.wantName, got.filename)
})
}
}
func TestSTTOmitsModelWhenUnset(t *testing.T) {
var got sttForm
srv := newSTTServer(t, &got, http.StatusOK, `{"text":"ok"}`)
provider, err := openaicompat.NewSTTProvider(openaicompat.Config{APIBase: srv.URL})
require.NoError(t, err)
_, err = provider.Transcribe(context.Background(),
audio.STTInput{Bytes: []byte("x"), MimeType: "audio/wav"}, audio.STTOptions{})
require.NoError(t, err)
assert.Empty(t, got.model, "an unset model must be omitted so the endpoint picks its own")
}
func TestSTTOptionsModelOverridesConfig(t *testing.T) {
var got sttForm
srv := newSTTServer(t, &got, http.StatusOK, `{"text":"ok"}`)
provider, err := openaicompat.NewSTTProvider(openaicompat.Config{APIBase: srv.URL, STTModel: "config-model"})
require.NoError(t, err)
_, err = provider.Transcribe(context.Background(),
audio.STTInput{Bytes: []byte("x"), MimeType: "audio/wav"},
audio.STTOptions{ModelID: "opts-model"})
require.NoError(t, err)
assert.Equal(t, "opts-model", got.model)
}
// TestSTTLanguageFallsBackToHint covers a plain (non-verbose) json response,
// which carries no language field.
func TestSTTLanguageFallsBackToHint(t *testing.T) {
var got sttForm
srv := newSTTServer(t, &got, http.StatusOK, `{"text":"bonjour"}`)
provider, err := openaicompat.NewSTTProvider(openaicompat.Config{APIBase: srv.URL})
require.NoError(t, err)
result, err := provider.Transcribe(context.Background(),
audio.STTInput{Bytes: []byte("x"), MimeType: "audio/wav"},
audio.STTOptions{Language: "fr"})
require.NoError(t, err)
assert.Equal(t, "fr", result.Language)
}
func TestSTTErrorStatus(t *testing.T) {
var got sttForm
srv := newSTTServer(t, &got, http.StatusUnsupportedMediaType, `{"error":"unsupported audio format"}`)
provider, err := openaicompat.NewSTTProvider(openaicompat.Config{APIBase: srv.URL})
require.NoError(t, err)
_, err = provider.Transcribe(context.Background(),
audio.STTInput{Bytes: []byte("x"), MimeType: "audio/ogg"}, audio.STTOptions{})
require.Error(t, err)
assert.Contains(t, err.Error(), "415")
assert.Contains(t, err.Error(), "unsupported audio format")
}
func TestSTTRejectsEmptyInput(t *testing.T) {
provider, err := openaicompat.NewSTTProvider(openaicompat.Config{APIBase: "http://x/v1"})
require.NoError(t, err)
_, err = provider.Transcribe(context.Background(), audio.STTInput{}, audio.STTOptions{})
require.Error(t, err)
assert.Contains(t, err.Error(), "neither FilePath nor Bytes")
}
func TestSTTRejectsOversizedBytes(t *testing.T) {
provider, err := openaicompat.NewSTTProvider(openaicompat.Config{APIBase: "http://x/v1"})
require.NoError(t, err)
_, err = provider.Transcribe(context.Background(),
audio.STTInput{Bytes: make([]byte, (25<<20)+1), MimeType: "audio/wav"},
audio.STTOptions{})
require.Error(t, err)
assert.Contains(t, err.Error(), "too large")
}
func TestSTTRejectsOversizedFile(t *testing.T) {
dir := t.TempDir()
path := filepath.Join(dir, "big.wav")
require.NoError(t, os.WriteFile(path, make([]byte, (25<<20)+1), 0o600))
provider, err := openaicompat.NewSTTProvider(openaicompat.Config{APIBase: "http://x/v1"})
require.NoError(t, err)
_, err = provider.Transcribe(context.Background(),
audio.STTInput{FilePath: path, MimeType: "audio/wav"}, audio.STTOptions{})
require.Error(t, err)
assert.Contains(t, err.Error(), "too large")
}
+170
View File
@@ -0,0 +1,170 @@
package openaicompat
import (
"bytes"
"context"
"encoding/json"
"fmt"
"io"
"net/http"
"strings"
"time"
"github.com/nextlevelbuilder/goclaw/internal/audio"
)
// errBodyLimit caps how much of an error response body is read into a message.
const errBodyLimit = 512
// TTSProvider synthesizes speech via an OpenAI-compatible POST /audio/speech.
type TTSProvider struct {
cfg Config
}
// NewTTSProvider returns a TTS provider for the configured endpoint.
// Returns ErrAPIBaseRequired when cfg.APIBase is empty.
func NewTTSProvider(cfg Config) (*TTSProvider, error) {
if strings.TrimSpace(cfg.APIBase) == "" {
return nil, ErrAPIBaseRequired
}
return &TTSProvider{cfg: cfg.withDefaults()}, nil
}
// Name returns the stable provider identifier.
func (p *TTSProvider) Name() string { return providerName }
// Synthesize calls POST {api_base}/audio/speech and returns the audio bytes.
//
// Resolution order for voice, model and format is: per-request opts, then
// opts.Params, then the provider config. Voice and model are omitted from the
// request when they resolve empty — some engines pick the model from the voice,
// and sending an empty string would override that choice rather than defer to it.
func (p *TTSProvider) Synthesize(ctx context.Context, text string, opts audio.TTSOptions) (*audio.SynthResult, error) {
format := firstNonEmpty(opts.Format, paramString(opts.Params, "response_format"), p.cfg.TTSFormat)
voice := firstNonEmpty(opts.Voice, p.cfg.TTSVoice)
model := firstNonEmpty(opts.Model, p.cfg.TTSModel)
body := map[string]any{
"input": text,
"response_format": format,
}
if voice != "" {
body["voice"] = voice
}
if model != "" {
body["model"] = model
}
if speed := paramFloat(opts.Params, "speed"); speed > 0 {
body["speed"] = speed
}
bodyJSON, err := json.Marshal(body)
if err != nil {
return nil, fmt.Errorf("openai_compat tts: marshal request: %w", err)
}
url := strings.TrimRight(p.cfg.APIBase, "/") + "/audio/speech"
req, err := http.NewRequestWithContext(ctx, http.MethodPost, url, bytes.NewReader(bodyJSON))
if err != nil {
return nil, fmt.Errorf("openai_compat tts: create request: %w", err)
}
req.Header.Set("Content-Type", "application/json")
if p.cfg.APIKey != "" {
req.Header.Set("Authorization", "Bearer "+p.cfg.APIKey)
}
hc := &http.Client{Timeout: time.Duration(p.cfg.TimeoutMs) * time.Millisecond}
resp, err := hc.Do(req)
if err != nil {
return nil, fmt.Errorf("openai_compat tts: http request: %w", err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
errBody, _ := io.ReadAll(io.LimitReader(resp.Body, errBodyLimit))
return nil, fmt.Errorf("openai_compat tts: endpoint error %d: %s", resp.StatusCode, strings.TrimSpace(string(errBody)))
}
audioBytes, err := io.ReadAll(resp.Body)
if err != nil {
return nil, fmt.Errorf("openai_compat tts: read response: %w", err)
}
ext, mime := formatToExtMime(format)
return &audio.SynthResult{
Audio: audioBytes,
Extension: ext,
MimeType: mime,
}, nil
}
// formatToExtMime maps an OpenAI response_format to a file extension and MIME
// type. Unknown formats are passed through as their own extension with a
// generic MIME type rather than guessed at.
func formatToExtMime(format string) (ext, mime string) {
switch format {
case "mp3":
return "mp3", "audio/mpeg"
case "opus":
return "ogg", "audio/ogg"
case "aac":
return "aac", "audio/aac"
case "flac":
return "flac", "audio/flac"
case "wav":
return "wav", "audio/wav"
case "pcm":
return "pcm", "audio/pcm"
default:
return format, "application/octet-stream"
}
}
// firstNonEmpty returns the first non-empty string, or "" if all are empty.
func firstNonEmpty(values ...string) string {
for _, v := range values {
if v != "" {
return v
}
}
return ""
}
// paramString reads a string param, returning "" when absent or not a string.
func paramString(params map[string]any, key string) string {
if params == nil {
return ""
}
v, ok := audio.GetNested(params, key)
if !ok {
return ""
}
s, ok := v.(string)
if !ok {
return ""
}
return s
}
// paramFloat reads a numeric param, returning 0 when absent or not numeric.
func paramFloat(params map[string]any, key string) float64 {
if params == nil {
return 0
}
v, ok := audio.GetNested(params, key)
if !ok {
return 0
}
switch n := v.(type) {
case float64:
return n
case float32:
return float64(n)
case int:
return float64(n)
case int64:
return float64(n)
default:
return 0
}
}
+209
View File
@@ -0,0 +1,209 @@
package openaicompat_test
import (
"context"
"encoding/json"
"io"
"net/http"
"net/http/httptest"
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"github.com/nextlevelbuilder/goclaw/internal/audio"
"github.com/nextlevelbuilder/goclaw/internal/audio/openaicompat"
)
// piperVoice is a real Piper voice ID. It is deliberately used throughout:
// it is exactly the kind of self-hosted voice that the "openai" provider name
// would silently rewrite to "alloy".
const piperVoice = "fr_FR-gilles-low"
func TestNewTTSProviderRequiresAPIBase(t *testing.T) {
_, err := openaicompat.NewTTSProvider(openaicompat.Config{})
require.ErrorIs(t, err, openaicompat.ErrAPIBaseRequired)
_, err = openaicompat.NewTTSProvider(openaicompat.Config{APIBase: " "})
require.ErrorIs(t, err, openaicompat.ErrAPIBaseRequired, "whitespace-only api_base must not pass")
}
func TestTTSProviderName(t *testing.T) {
provider, err := openaicompat.NewTTSProvider(openaicompat.Config{APIBase: "http://x/v1"})
require.NoError(t, err)
assert.Equal(t, "openai_compat", provider.Name())
}
// TestTTSProviderNameEscapesOpenAIVoiceValidation is the regression guard for
// the reason this package exists. Named "openai", a self-hosted voice ID is
// replaced by "alloy" before the request is ever sent.
func TestTTSProviderNameEscapesOpenAIVoiceValidation(t *testing.T) {
provider, err := openaicompat.NewTTSProvider(openaicompat.Config{APIBase: "http://x/v1"})
require.NoError(t, err)
assert.True(t, audio.IsVoiceCompatible(provider.Name(), piperVoice),
"a self-hosted voice must survive validation under this provider name")
filtered, changed := audio.FilterVoiceForProvider(provider.Name(), piperVoice, false)
assert.Equal(t, piperVoice, filtered)
assert.False(t, changed)
// Contrast: the same voice under the "openai" name is silently replaced.
clobbered, changed := audio.FilterVoiceForProvider("openai", piperVoice, false)
assert.Equal(t, "alloy", clobbered)
assert.True(t, changed)
}
func TestTTSSynthesize(t *testing.T) {
var gotBody map[string]any
var gotAuth, gotPath string
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
gotPath = r.URL.Path
gotAuth = r.Header.Get("Authorization")
require.NoError(t, json.NewDecoder(r.Body).Decode(&gotBody))
w.Header().Set("Content-Type", "audio/wav")
_, _ = w.Write([]byte("RIFFfake"))
}))
t.Cleanup(func() { srv.Close() })
provider, err := openaicompat.NewTTSProvider(openaicompat.Config{
APIBase: srv.URL + "/v1",
APIKey: "secret",
TTSFormat: "wav",
TTSVoice: piperVoice,
})
require.NoError(t, err)
result, err := provider.Synthesize(context.Background(), "bonjour", audio.TTSOptions{})
require.NoError(t, err)
assert.Equal(t, "/v1/audio/speech", gotPath)
assert.Equal(t, "Bearer secret", gotAuth)
assert.Equal(t, "bonjour", gotBody["input"])
assert.Equal(t, piperVoice, gotBody["voice"], "configured voice must reach the endpoint verbatim")
assert.Equal(t, "wav", gotBody["response_format"])
assert.NotContains(t, gotBody, "model", "an empty model must be omitted, not sent blank")
assert.Equal(t, []byte("RIFFfake"), result.Audio)
assert.Equal(t, "wav", result.Extension)
assert.Equal(t, "audio/wav", result.MimeType)
}
func TestTTSSynthesizeResolutionOrder(t *testing.T) {
var gotBody map[string]any
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
require.NoError(t, json.NewDecoder(r.Body).Decode(&gotBody))
_, _ = w.Write([]byte("x"))
}))
t.Cleanup(func() { srv.Close() })
provider, err := openaicompat.NewTTSProvider(openaicompat.Config{
APIBase: srv.URL,
TTSVoice: "config-voice",
TTSModel: "config-model",
TTSFormat: "wav",
})
require.NoError(t, err)
_, err = provider.Synthesize(context.Background(), "hi", audio.TTSOptions{
Voice: "opts-voice",
Model: "opts-model",
Format: "pcm",
Params: map[string]any{"speed": 1.5},
})
require.NoError(t, err)
assert.Equal(t, "opts-voice", gotBody["voice"], "per-request opts win over config")
assert.Equal(t, "opts-model", gotBody["model"])
assert.Equal(t, "pcm", gotBody["response_format"])
assert.InEpsilon(t, 1.5, gotBody["speed"], 0.0001)
}
func TestTTSSynthesizeParamsFormatOverridesConfig(t *testing.T) {
var gotBody map[string]any
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
require.NoError(t, json.NewDecoder(r.Body).Decode(&gotBody))
_, _ = w.Write([]byte("x"))
}))
t.Cleanup(func() { srv.Close() })
provider, err := openaicompat.NewTTSProvider(openaicompat.Config{
APIBase: srv.URL,
TTSFormat: "wav",
})
require.NoError(t, err)
_, err = provider.Synthesize(context.Background(), "hi", audio.TTSOptions{
Params: map[string]any{"response_format": "mp3"},
})
require.NoError(t, err)
assert.Equal(t, "mp3", gotBody["response_format"])
}
func TestTTSSynthesizeOmitsAuthWhenNoKey(t *testing.T) {
var hasAuth bool
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
_, hasAuth = r.Header["Authorization"]
_, _ = w.Write([]byte("x"))
}))
t.Cleanup(func() { srv.Close() })
provider, err := openaicompat.NewTTSProvider(openaicompat.Config{APIBase: srv.URL})
require.NoError(t, err)
_, err = provider.Synthesize(context.Background(), "hi", audio.TTSOptions{})
require.NoError(t, err)
assert.False(t, hasAuth, "self-hosted endpoints without auth must not receive an empty Bearer header")
}
func TestTTSSynthesizeErrorStatus(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
w.WriteHeader(http.StatusBadRequest)
_, _ = io.WriteString(w, `{"error":"response_format must be wav or pcm"}`)
}))
t.Cleanup(func() { srv.Close() })
provider, err := openaicompat.NewTTSProvider(openaicompat.Config{APIBase: srv.URL})
require.NoError(t, err)
_, err = provider.Synthesize(context.Background(), "hi", audio.TTSOptions{})
require.Error(t, err)
assert.Contains(t, err.Error(), "400")
assert.Contains(t, err.Error(), "response_format must be wav or pcm",
"the endpoint's own message must survive so a format mismatch is diagnosable")
}
func TestTTSFormatExtensionMapping(t *testing.T) {
tests := []struct {
format string
wantExt string
wantMIME string
}{
{"mp3", "mp3", "audio/mpeg"},
{"opus", "ogg", "audio/ogg"},
{"wav", "wav", "audio/wav"},
{"pcm", "pcm", "audio/pcm"},
{"flac", "flac", "audio/flac"},
{"aac", "aac", "audio/aac"},
}
for _, tc := range tests {
t.Run(tc.format, func(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
_, _ = w.Write([]byte("x"))
}))
t.Cleanup(func() { srv.Close() })
provider, err := openaicompat.NewTTSProvider(openaicompat.Config{
APIBase: srv.URL,
TTSFormat: tc.format,
})
require.NoError(t, err)
result, err := provider.Synthesize(context.Background(), "hi", audio.TTSOptions{})
require.NoError(t, err)
assert.Equal(t, tc.wantExt, result.Extension)
assert.Equal(t, tc.wantMIME, result.MimeType)
})
}
}
+6
View File
@@ -56,6 +56,12 @@ type Channel struct {
mentionMu sync.Mutex
mentionRe *mentionMatcher
// reactions tracks the current status-reaction emoji per user message so
// OnReactionEvent can replace the previous one as the agent's status
// progresses (thinking → tool → done). Keyed by "<chatID>:<messageID>";
// values are *reactionState. See reactions.go.
reactions sync.Map
// MCP lazy provisioner (Phase C). All fields nil / zero when
// provisioning is disabled — channel then works exactly as before
// (messages flow through without trying to mint MCP credentials).
+52 -16
View File
@@ -13,6 +13,7 @@ import (
"github.com/nextlevelbuilder/goclaw/internal/channels"
"github.com/nextlevelbuilder/goclaw/internal/channels/media"
"github.com/nextlevelbuilder/goclaw/internal/store"
"github.com/nextlevelbuilder/goclaw/internal/systemmessages"
)
// mentionMatcher is the compiled-once regex + rendered tag string used to
@@ -91,8 +92,8 @@ func (c *Channel) DispatchEvent(ctx context.Context, evt *Event) {
// 2. Group-only: require-mention gate + strip the mention from the text.
// 3. Skip empty payloads (no text + no media).
// 4. Policy: DM vs Group via BaseChannel.CheckDMPolicy / CheckGroupPolicy.
// Pairing policies trigger a pairing-reply stub (Phase 07 will wire up
// the full pairing flow; Phase 03 logs and drops).
// Pairing policies send a pairing reply (code + approve instructions) via
// sendPairingReply; deny drops silently.
// 5. Build metadata map with bitrix_* keys so the agent can echo / reply
// to the right dialog + message ID later.
// 6. Forward to BaseChannel.HandleMessage → publishes bus.InboundMessage.
@@ -258,16 +259,17 @@ func (c *Channel) handleMessage(ctx context.Context, evt *Event) {
peerKind = "group"
}
// Policy gating. BaseChannel reports PolicyNeedsPairing for unpaired DMs
// under the default "pairing" policy. Phase 07 will answer with a real
// pairing reply; Phase 03 logs the intent so the flow is visible while
// the rest of the channel comes online.
// Policy gating. BaseChannel reports PolicyNeedsPairing for unpaired senders
// under the "pairing" policy. Answer with a real pairing reply (code + how to
// approve) via the v2 chat API, matching the other channels (Discord/Zalo/
// WhatsApp/Slack/Feishu). Group pairings are keyed by "group:<chatID>" so the
// generated code matches what CheckGroupPolicy.IsPaired later verifies.
if peerKind == "direct" {
switch c.CheckDMPolicy(ctx, senderID, c.cfg.DMPolicy) {
case channels.PolicyDeny:
return
case channels.PolicyNeedsPairing:
c.logPairingNeeded(senderID, chatID, peerKind)
c.sendPairingReply(ctx, senderID, chatID, peerKind)
return
}
} else {
@@ -275,7 +277,7 @@ func (c *Channel) handleMessage(ctx context.Context, evt *Event) {
case channels.PolicyDeny:
return
case channels.PolicyNeedsPairing:
c.logPairingNeeded(senderID, chatID, peerKind)
c.sendPairingReply(ctx, "group:"+chatID, chatID, peerKind)
return
}
}
@@ -301,6 +303,11 @@ func (c *Channel) handleMessage(ctx context.Context, evt *Event) {
// Bitrix replyId.
MetaKeyMessageID: evt.Params.MessageID,
MetaKeyVisibility: visibility,
// Generic run-tracking key the gateway consumer reads to thread
// streaming / status-reaction events back to this message (all other
// channels set it). Distinct from bitrix_message_id above (same value
// here, but that key is Bitrix-specific reply-link routing).
"message_id": evt.Params.MessageID,
}
// Echo the connector sender tag back on the reply so the Open Channel
// connector routes the answer to the right external message:
@@ -597,16 +604,45 @@ func (c *Channel) mention() *mentionMatcher {
return c.mentionRe
}
// logPairingNeeded is the Phase 03 stand-in for a real pairing reply.
// Phase 07 replaces this with a proper pairing message via imbot.message.add;
// until then we log the intent so operators can see unpaired attempts.
func (c *Channel) logPairingNeeded(senderID, chatID, peerKind string) {
if !c.CanSendPairingNotif(senderID, pairingDebounce) {
// sendPairingReply requests a pairing code for an unpaired sender and sends it
// back into the chat via the v2 API (imbot.v2.Chat.Message.send) so an admin can
// approve access. Mirrors the pairing reply the other channels already send
// (Discord/Zalo/WhatsApp/Slack/Feishu) using the shared pairing.account_required
// system message. Debounced per-sender so a spammy sender can't flood the chat.
//
// pairingSenderID is the id the pairing is keyed by — the raw senderID for DMs,
// "group:<chatID>" for groups — so the generated code matches what CheckDMPolicy
// / CheckGroupPolicy later verify via IsPaired.
func (c *Channel) sendPairingReply(ctx context.Context, pairingSenderID, chatID, peerKind string) {
ps := c.PairingService()
if ps == nil {
return
}
c.MarkPairingNotifSent(senderID)
slog.Info("bitrix24: pairing required (Phase 07 will send pairing reply)",
"sender_id", senderID, "chat_id", chatID, "peer_kind", peerKind,
if !c.CanSendPairingNotif(pairingSenderID, pairingDebounce) {
return
}
code, err := ps.RequestPairing(ctx, pairingSenderID, c.Name(), chatID, "default", nil)
if err != nil {
slog.Debug("bitrix24 pairing: request failed",
"sender_id", pairingSenderID, "chat_id", chatID, "err", err)
return
}
replyText := c.SystemMessage("", systemmessages.KeyPairingAccountRequired, systemmessages.Vars{
"platform": "Bitrix24",
"sender_id": pairingSenderID,
"code": code,
})
if err := c.sendChunkV2Public(ctx, chatID, replyText, 0); err != nil {
slog.Warn("bitrix24 pairing: reply send failed",
"sender_id", pairingSenderID, "chat_id", chatID, "err", err)
return
}
c.MarkPairingNotifSent(pairingSenderID)
slog.Info("bitrix24 pairing: reply sent",
"sender_id", pairingSenderID, "chat_id", chatID, "peer_kind", peerKind,
"portal", c.cfg.Portal)
}
+118
View File
@@ -0,0 +1,118 @@
package bitrix24
import (
"context"
"encoding/json"
"net/http"
"net/http/httptest"
"strings"
"sync/atomic"
"testing"
"github.com/nextlevelbuilder/goclaw/internal/store"
"github.com/nextlevelbuilder/goclaw/internal/systemmessages"
)
// fakeBitrixPairingStore implements store.PairingStore for pairing-reply tests.
type fakeBitrixPairingStore struct {
requested bool
requestedFor string
}
func (s *fakeBitrixPairingStore) RequestPairing(_ context.Context, senderID, _, _, _ string, _ map[string]string) (string, error) {
s.requested = true
s.requestedFor = senderID
return "code123", nil
}
func (s *fakeBitrixPairingStore) ApprovePairing(context.Context, string, string) (*store.PairedDeviceData, error) {
return nil, nil
}
func (s *fakeBitrixPairingStore) DenyPairing(context.Context, string) error { return nil }
func (s *fakeBitrixPairingStore) RevokePairing(context.Context, string, string) error { return nil }
func (s *fakeBitrixPairingStore) IsPaired(context.Context, string, string) (bool, error) {
return false, nil
}
func (s *fakeBitrixPairingStore) ListPending(context.Context) []store.PairingRequestData { return nil }
func (s *fakeBitrixPairingStore) ListPaired(context.Context) []store.PairedDeviceData { return nil }
func (s *fakeBitrixPairingStore) MigrateGroupChatID(context.Context, string, string, string) error {
return nil
}
// TestSendPairingReply_SendsCodeViaV2 verifies an unpaired sender gets a pairing
// code sent back through the v2 chat API (imbot.v2.Chat.Message.send), not the
// old v1 imbot.message.add, and not silence.
func TestSendPairingReply_SendsCodeViaV2(t *testing.T) {
var calls int32
var gotPath, gotMessage string
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
atomic.AddInt32(&calls, 1)
gotPath = r.URL.Path
_ = r.ParseForm()
gotMessage = r.Form.Get("fields[message]")
w.Header().Set("Content-Type", "application/json")
_ = json.NewEncoder(w).Encode(map[string]any{"result": map[string]any{"id": 999}})
}))
defer srv.Close()
bc := newChannelWithBoundPortal(t, srv)
bc.SetSystemMessages(systemmessages.NewResolver(nil))
ps := &fakeBitrixPairingStore{}
bc.SetPairingService(ps)
bc.sendPairingReply(context.Background(), "42", "chat100", "direct")
if atomic.LoadInt32(&calls) == 0 {
t.Fatal("expected a REST send for the pairing reply, got none (bot stayed silent)")
}
if !strings.HasSuffix(gotPath, "/rest/imbot.v2.Chat.Message.send.json") {
t.Errorf("pairing reply used wrong endpoint %q; want v2 imbot.v2.Chat.Message.send", gotPath)
}
if !ps.requested {
t.Error("expected RequestPairing to be called to generate a code")
}
if !strings.Contains(gotMessage, "code123") {
t.Errorf("pairing message should contain the code; got %q", gotMessage)
}
}
// TestSendPairingReply_NoService_NoOp: without a pairing service the call is a
// safe no-op (no send).
func TestSendPairingReply_NoService_NoOp(t *testing.T) {
var calls int32
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
atomic.AddInt32(&calls, 1)
w.Header().Set("Content-Type", "application/json")
_ = json.NewEncoder(w).Encode(map[string]any{"result": map[string]any{"id": 1}})
}))
defer srv.Close()
bc := newChannelWithBoundPortal(t, srv) // no SetPairingService → PairingService() nil
bc.sendPairingReply(context.Background(), "42", "chat100", "direct")
if got := atomic.LoadInt32(&calls); got != 0 {
t.Errorf("expected no send without a pairing service, got %d", got)
}
}
// TestSendPairingReply_Debounced: a second call within the debounce window does
// not fire a second send.
func TestSendPairingReply_Debounced(t *testing.T) {
var calls int32
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
atomic.AddInt32(&calls, 1)
w.Header().Set("Content-Type", "application/json")
_ = json.NewEncoder(w).Encode(map[string]any{"result": map[string]any{"id": 1}})
}))
defer srv.Close()
bc := newChannelWithBoundPortal(t, srv)
bc.SetSystemMessages(systemmessages.NewResolver(nil))
bc.SetPairingService(&fakeBitrixPairingStore{})
bc.sendPairingReply(context.Background(), "42", "chat100", "direct")
bc.sendPairingReply(context.Background(), "42", "chat100", "direct") // debounced
if got := atomic.LoadInt32(&calls); got != 1 {
t.Errorf("expected 1 send (second debounced), got %d", got)
}
}
+167
View File
@@ -0,0 +1,167 @@
package bitrix24
import (
"context"
"errors"
"log/slog"
"strconv"
"strings"
"sync"
"time"
"github.com/nextlevelbuilder/goclaw/internal/channels"
)
// Compile-time guarantee the Bitrix24 channel provides status reactions.
var _ channels.ReactionChannel = (*Channel)(nil)
// reactionDebounceInterval throttles intermediate status-reaction updates so a
// fast think→tool→tool sequence doesn't hammer the REST API. Terminal states
// (done/error) bypass the debounce so the final outcome always lands.
const reactionDebounceInterval = 700 * time.Millisecond
// statusReaction maps a GoClaw agent status to a Bitrix24 v2 reaction code.
// Codes come from imbot.v2.Chat.Message.Reaction.add's documented set.
var statusReaction = map[string]string{
"queued": "eyes", // 👀 waiting to process
"thinking": "thinkingFace", // 🤔 processing
"tool": "fire", // 🔥 running a tool
"coding": "fire", // 🔥 executing code
"web": "fire", // 🔥 browsing / API call
"done": "whiteHeavyCheckMark", // ✅ completed
"error": "crossMark", // ❌ failed
"stall": "sleepingSymbol", // 😴 no activity
"stallSoft": "sleepingSymbol", // 😴
"stallHard": "flushedFace", // 😳
}
// reactionState tracks the current reaction code set on one user message so
// OnReactionEvent can replace it as the agent's status progresses.
type reactionState struct {
mu sync.Mutex
current string
lastUpdate time.Time
}
// OnReactionEvent sets a status-reaction on the user's message to show agent
// progress (thinking → tool → done/error). Implements channels.ReactionChannel.
// messageID is the Bitrix MESSAGE_ID (numeric) of the message that started the run.
func (c *Channel) OnReactionEvent(ctx context.Context, chatID, messageID, status string) error {
level := c.cfg.ReactionLevel
if level == "" || level == "off" {
return nil
}
code, ok := statusReaction[status]
if !ok {
return nil
}
terminal := status == "done" || status == "error"
// "minimal" shows only the final outcome (done/error), not intermediate steps.
if level == "minimal" && !terminal {
return nil
}
msgID, err := strconv.Atoi(strings.TrimSpace(messageID))
if err != nil || msgID <= 0 {
return nil // not a Bitrix message id (e.g. connector synthetic id)
}
botID := c.BotID()
if botID <= 0 || c.Client() == nil {
return nil
}
key := chatID + ":" + messageID
stVal, _ := c.reactions.LoadOrStore(key, &reactionState{})
st := stVal.(*reactionState)
st.mu.Lock()
defer st.mu.Unlock()
if !terminal && time.Since(st.lastUpdate) < reactionDebounceInterval {
return nil
}
if st.current == code {
return nil
}
// Bitrix has no "replace" — delete the previous reaction, then add the new.
if st.current != "" {
c.removeReaction(ctx, botID, msgID, st.current)
}
if err := c.addReaction(ctx, botID, msgID, code); err != nil {
slog.Debug("bitrix24 reaction: add failed",
"code", code, "message_id", msgID, "err", err)
return nil
}
st.current = code
st.lastUpdate = time.Now()
// The run is over on terminal states — stop tracking this message.
if terminal {
c.reactions.Delete(key)
}
return nil
}
// ClearReaction removes the current status-reaction from a message.
// Implements channels.ReactionChannel.
func (c *Channel) ClearReaction(ctx context.Context, chatID, messageID string) error {
key := chatID + ":" + messageID
stVal, ok := c.reactions.LoadAndDelete(key)
if !ok {
return nil
}
st := stVal.(*reactionState)
st.mu.Lock()
defer st.mu.Unlock()
if st.current == "" {
return nil
}
msgID, err := strconv.Atoi(strings.TrimSpace(messageID))
if err != nil || msgID <= 0 {
return nil
}
if botID := c.BotID(); botID > 0 && c.Client() != nil {
c.removeReaction(ctx, botID, msgID, st.current)
}
st.current = ""
return nil
}
// addReaction adds a bot reaction via imbot.v2.Chat.Message.Reaction.add.
// REACTION_ALREADY_SET is treated as success (the reaction is already present).
func (c *Channel) addReaction(ctx context.Context, botID, messageID int, code string) error {
_, err := c.Client().Call(ctx, "imbot.v2.Chat.Message.Reaction.add", map[string]any{
"botId": botID,
"messageId": messageID,
"reaction": code,
})
if err != nil && isReactionAlreadySet(err) {
return nil
}
return err
}
// removeReaction removes a bot reaction via imbot.v2.Chat.Message.Reaction.delete.
// Best-effort: a failed delete is logged and swallowed (worst case a stale
// reaction lingers, which is cosmetic).
func (c *Channel) removeReaction(ctx context.Context, botID, messageID int, code string) {
if _, err := c.Client().Call(ctx, "imbot.v2.Chat.Message.Reaction.delete", map[string]any{
"botId": botID,
"messageId": messageID,
"reaction": code,
}); err != nil {
slog.Debug("bitrix24 reaction: delete failed",
"code", code, "message_id", messageID, "err", err)
}
}
// isReactionAlreadySet reports whether err is the benign REACTION_ALREADY_SET
// application error from Bitrix (the bot already reacted with this code).
func isReactionAlreadySet(err error) bool {
var apiErr *APIError
if errors.As(err, &apiErr) {
return apiErr.Code == "REACTION_ALREADY_SET"
}
return false
}
@@ -0,0 +1,129 @@
package bitrix24
import (
"context"
"encoding/json"
"net/http"
"net/http/httptest"
"strings"
"sync"
"testing"
)
type reactionCall struct {
op string // "add" | "delete"
reaction string
}
// reactionRecorder serves the v2 reaction endpoints and records each call.
func reactionRecorder(t *testing.T) (*httptest.Server, func() []reactionCall) {
t.Helper()
var mu sync.Mutex
var calls []reactionCall
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
_ = r.ParseForm()
op := ""
switch {
case strings.HasSuffix(r.URL.Path, "/rest/imbot.v2.Chat.Message.Reaction.add.json"):
op = "add"
case strings.HasSuffix(r.URL.Path, "/rest/imbot.v2.Chat.Message.Reaction.delete.json"):
op = "delete"
default:
t.Errorf("unexpected reaction path: %q", r.URL.Path)
}
mu.Lock()
calls = append(calls, reactionCall{op: op, reaction: r.Form.Get("reaction")})
mu.Unlock()
w.Header().Set("Content-Type", "application/json")
_ = json.NewEncoder(w).Encode(map[string]any{"result": true})
}))
return srv, func() []reactionCall {
mu.Lock()
defer mu.Unlock()
out := make([]reactionCall, len(calls))
copy(out, calls)
return out
}
}
func TestOnReactionEvent_FullFlow_AddThenReplaceViaV2(t *testing.T) {
srv, snapshot := reactionRecorder(t)
defer srv.Close()
bc := newChannelWithBoundPortal(t, srv)
bc.cfg.ReactionLevel = "full"
ctx := context.Background()
// thinking → add thinkingFace
_ = bc.OnReactionEvent(ctx, "chat100", "5001", "thinking")
// done (terminal, bypasses debounce) → delete thinkingFace + add whiteHeavyCheckMark
_ = bc.OnReactionEvent(ctx, "chat100", "5001", "done")
calls := snapshot()
if len(calls) != 3 {
t.Fatalf("expected 3 reaction calls (add, delete, add), got %d: %+v", len(calls), calls)
}
if calls[0].op != "add" || calls[0].reaction != "thinkingFace" {
t.Errorf("call[0] = %+v; want add thinkingFace", calls[0])
}
if calls[1].op != "delete" || calls[1].reaction != "thinkingFace" {
t.Errorf("call[1] = %+v; want delete thinkingFace", calls[1])
}
if calls[2].op != "add" || calls[2].reaction != "whiteHeavyCheckMark" {
t.Errorf("call[2] = %+v; want add whiteHeavyCheckMark", calls[2])
}
}
func TestOnReactionEvent_MinimalSkipsIntermediate(t *testing.T) {
srv, snapshot := reactionRecorder(t)
defer srv.Close()
bc := newChannelWithBoundPortal(t, srv)
bc.cfg.ReactionLevel = "minimal"
ctx := context.Background()
_ = bc.OnReactionEvent(ctx, "chat100", "5001", "thinking") // skipped in minimal
_ = bc.OnReactionEvent(ctx, "chat100", "5001", "error") // terminal → shown
calls := snapshot()
if len(calls) != 1 || calls[0].op != "add" || calls[0].reaction != "crossMark" {
t.Fatalf("minimal should send only the terminal reaction; got %+v", calls)
}
}
func TestOnReactionEvent_OffAndInvalidAreNoOp(t *testing.T) {
srv, snapshot := reactionRecorder(t)
defer srv.Close()
// off level
bc := newChannelWithBoundPortal(t, srv)
bc.cfg.ReactionLevel = "off"
_ = bc.OnReactionEvent(context.Background(), "chat100", "5001", "done")
// full level but non-numeric message id (connector synthetic id)
bc2 := newChannelWithBoundPortal(t, srv)
bc2.cfg.ReactionLevel = "full"
_ = bc2.OnReactionEvent(context.Background(), "chat100", "not-a-number", "done")
// full level, unknown status
bc2.cfg.ReactionLevel = "full"
_ = bc2.OnReactionEvent(context.Background(), "chat100", "5001", "bogus-status")
if calls := snapshot(); len(calls) != 0 {
t.Fatalf("expected no reaction calls for off/invalid/unknown, got %+v", calls)
}
}
func TestClearReaction_RemovesCurrent(t *testing.T) {
srv, snapshot := reactionRecorder(t)
defer srv.Close()
bc := newChannelWithBoundPortal(t, srv)
bc.cfg.ReactionLevel = "full"
ctx := context.Background()
_ = bc.OnReactionEvent(ctx, "chat100", "5001", "thinking") // add thinkingFace
_ = bc.ClearReaction(ctx, "chat100", "5001") // delete thinkingFace
calls := snapshot()
if len(calls) != 2 || calls[1].op != "delete" || calls[1].reaction != "thinkingFace" {
t.Fatalf("expected add then delete thinkingFace; got %+v", calls)
}
}
+7
View File
@@ -16,6 +16,13 @@ var routingMetaKeys = []string{
"pancake_mode", // pancake inbox vs comment routing
"post_id", // pancake: post id for template vars
"display_name", // pancake: commenter display name for template vars
// bitrix24 whisper/public visibility. MUST survive into intermediate
// replies (quick-ack, progress, reasoning bubbles, block replies) so a
// whisper-mode turn doesn't leak its FIRST (intermediate) message to the
// external connector while only the final answer is whispered. Literal
// string mirrors bitrix24.MetaKeyVisibility (kept as a string here to
// avoid a channels→bitrix24 import cycle). Non-bitrix channels ignore it.
"bitrix_visibility",
}
var finalReplyMetaKeys = append([]string{
@@ -62,3 +62,20 @@ func TestCopyRoutingMeta_PreservesPancakePrivateReplyKeys(t *testing.T) {
}
}
}
// TestCopyRoutingMeta_PreservesBitrixVisibility guards the whisper-leak
// regression: intermediate replies (quick-ack, progress, reasoning bubbles,
// block replies) must carry bitrix_visibility so a whisper-mode turn does not
// leak its FIRST (intermediate) message to the external connector while only
// the final answer is whispered. copyRoutingMeta is the path used by every
// intermediate publish site; CopyFinalRoutingMeta must keep it too.
func TestCopyRoutingMeta_PreservesBitrixVisibility(t *testing.T) {
src := map[string]string{"bitrix_visibility": "whisper"}
if got := copyRoutingMeta(src); got["bitrix_visibility"] != "whisper" {
t.Fatalf("copyRoutingMeta()[%q] = %q, want %q", "bitrix_visibility", got["bitrix_visibility"], "whisper")
}
if got := CopyFinalRoutingMeta(src); got["bitrix_visibility"] != "whisper" {
t.Fatalf("CopyFinalRoutingMeta()[%q] = %q, want %q", "bitrix_visibility", got["bitrix_visibility"], "whisper")
}
}
+27 -10
View File
@@ -576,16 +576,33 @@ type SessionsConfig struct {
// TtsConfig configures text-to-speech.
// Matching TS src/config/types.tts.ts.
type TtsConfig struct {
Provider string `json:"provider,omitempty"` // "openai", "elevenlabs", "edge", "minimax", "gemini"
Auto string `json:"auto,omitempty"` // "off" (default), "always", "inbound", "tagged"
Mode string `json:"mode,omitempty"` // "final" (default), "all"
MaxLength int `json:"max_length,omitempty"` // max text length before truncation (default 1500)
TimeoutMs int `json:"timeout_ms,omitempty"` // API timeout in ms (default 30000)
OpenAI TtsOpenAIConfig `json:"openai"`
ElevenLabs TtsElevenLabsConfig `json:"elevenlabs"`
Edge TtsEdgeConfig `json:"edge"`
MiniMax TtsMiniMaxConfig `json:"minimax"`
Gemini TtsGeminiConfig `json:"gemini"`
Provider string `json:"provider,omitempty"` // "openai", "openai_compat", "elevenlabs", "edge", "minimax", "gemini"
Auto string `json:"auto,omitempty"` // "off" (default), "always", "inbound", "tagged"
Mode string `json:"mode,omitempty"` // "final" (default), "all"
MaxLength int `json:"max_length,omitempty"` // max text length before truncation (default 1500)
TimeoutMs int `json:"timeout_ms,omitempty"` // API timeout in ms (default 30000)
OpenAI TtsOpenAIConfig `json:"openai"`
OpenAICompat TtsOpenAICompatConfig `json:"openai_compat"`
ElevenLabs TtsElevenLabsConfig `json:"elevenlabs"`
Edge TtsEdgeConfig `json:"edge"`
MiniMax TtsMiniMaxConfig `json:"minimax"`
Gemini TtsGeminiConfig `json:"gemini"`
}
// TtsOpenAICompatConfig configures a self-hosted OpenAI-compatible audio
// endpoint (gpu-manager, Speaches, vLLM, LocalAI, llama.cpp, Ollama) for both
// TTS and STT. Kept separate from TtsOpenAIConfig because the voice namespace
// is the engine's own — see internal/audio/openaicompat for why the provider
// name must not be "openai".
//
// Setting api_base is what enables the provider; there is no vendor default.
type TtsOpenAICompatConfig struct {
APIBase string `json:"api_base,omitempty"` // required to enable, e.g. "http://gpu-manager:8080/v1"
APIKey string `json:"api_key,omitempty"` // optional; omitted entirely when empty
Model string `json:"model,omitempty"` // TTS model; optional when the engine picks it from the voice
Voice string `json:"voice,omitempty"` // engine-specific voice ID, e.g. "fr_FR-gilles-low"
Format string `json:"format,omitempty"` // response_format; default "mp3", use "wav" for engines without an encoder
STTModel string `json:"stt_model,omitempty"` // transcription model; optional
}
// TtsGeminiConfig configures the Google Gemini TTS provider.
+1 -1
View File
@@ -52,7 +52,7 @@ func (s *PGTeamStore) ListAllFollowupDueTasks(ctx context.Context) ([]store.Team
rows, err := s.db.QueryContext(ctx,
`SELECT `+taskSelectCols+`
`+taskJoinClause+`
`+v2ActiveTeamJoin+`
`+activeTeamJoin+`
WHERE t.followup_at IS NOT NULL
AND t.followup_at <= $1
AND t.status = $2
+8 -14
View File
@@ -71,15 +71,14 @@ func (s *PGTeamStore) RenewTaskLock(ctx context.Context, taskID, teamID uuid.UUI
}
// ============================================================
// Stale recovery (batch — all v2 active teams in one query)
// Stale recovery (batch — all active teams in one query)
// ============================================================
// v2ActiveTeamJoin is the JOIN clause that filters to v2 active teams.
const v2ActiveTeamJoin = `JOIN agent_teams tm ON tm.id = t.team_id
AND tm.status = 'active'
AND COALESCE((tm.settings->>'version')::int, 0) >= 2`
// activeTeamJoin is the JOIN clause that filters to active teams.
const activeTeamJoin = `JOIN agent_teams tm ON tm.id = t.team_id
AND tm.status = 'active'`
// RecoverAllStaleTasks resets in_progress tasks with expired locks across all v2 active teams.
// RecoverAllStaleTasks resets in_progress tasks with expired locks across all active teams.
func (s *PGTeamStore) RecoverAllStaleTasks(ctx context.Context) ([]store.RecoveredTaskInfo, error) {
now := time.Now()
rows, err := s.db.QueryContext(ctx,
@@ -89,7 +88,6 @@ func (s *PGTeamStore) RecoverAllStaleTasks(ctx context.Context) ([]store.Recover
followup_channel = NULL, followup_chat_id = NULL, updated_at = $2
FROM agent_teams tm
WHERE t.team_id = tm.id AND tm.status = 'active'
AND COALESCE((tm.settings->>'version')::int, 0) >= 2
AND t.status = $3
AND t.lock_expires_at IS NOT NULL AND t.lock_expires_at < $2
RETURNING t.id, t.team_id, t.tenant_id, t.task_number, t.subject, COALESCE(t.channel, ''), COALESCE(t.chat_id, '')`,
@@ -102,7 +100,7 @@ func (s *PGTeamStore) RecoverAllStaleTasks(ctx context.Context) ([]store.Recover
return scanRecoveredTaskInfoRows(rows)
}
// ForceRecoverAllTasks resets ALL in_progress tasks across v2 active teams (startup).
// ForceRecoverAllTasks resets ALL in_progress tasks across active teams (startup).
func (s *PGTeamStore) ForceRecoverAllTasks(ctx context.Context) ([]store.RecoveredTaskInfo, error) {
now := time.Now()
rows, err := s.db.QueryContext(ctx,
@@ -112,7 +110,6 @@ func (s *PGTeamStore) ForceRecoverAllTasks(ctx context.Context) ([]store.Recover
followup_channel = NULL, followup_chat_id = NULL, updated_at = $2
FROM agent_teams tm
WHERE t.team_id = tm.id AND tm.status = 'active'
AND COALESCE((tm.settings->>'version')::int, 0) >= 2
AND t.status = $3
RETURNING t.id, t.team_id, t.tenant_id, t.task_number, t.subject, COALESCE(t.channel, ''), COALESCE(t.chat_id, '')`,
store.TeamTaskStatusPending, now, store.TeamTaskStatusInProgress,
@@ -148,7 +145,7 @@ func (s *PGTeamStore) ListRecoverableTasks(ctx context.Context, teamID uuid.UUID
return scanTaskRowsJoined(rows)
}
// MarkAllStaleTasks marks pending tasks older than olderThan as stale across all v2 active teams.
// MarkAllStaleTasks marks pending tasks older than olderThan as stale across all active teams.
func (s *PGTeamStore) MarkAllStaleTasks(ctx context.Context, olderThan time.Time) ([]store.RecoveredTaskInfo, error) {
now := time.Now()
rows, err := s.db.QueryContext(ctx,
@@ -156,7 +153,6 @@ func (s *PGTeamStore) MarkAllStaleTasks(ctx context.Context, olderThan time.Time
SET status = $1, updated_at = $2
FROM agent_teams tm
WHERE t.team_id = tm.id AND tm.status = 'active'
AND COALESCE((tm.settings->>'version')::int, 0) >= 2
AND t.status = $3 AND t.updated_at < $4
RETURNING t.id, t.team_id, t.tenant_id, t.task_number, t.subject, COALESCE(t.channel, ''), COALESCE(t.chat_id, '')`,
store.TeamTaskStatusStale, now, store.TeamTaskStatusPending, olderThan,
@@ -168,7 +164,7 @@ func (s *PGTeamStore) MarkAllStaleTasks(ctx context.Context, olderThan time.Time
return scanRecoveredTaskInfoRows(rows)
}
// MarkInReviewStaleTasks marks in_review tasks older than olderThan as stale across all v2 active teams.
// MarkInReviewStaleTasks marks in_review tasks older than olderThan as stale across all active teams.
func (s *PGTeamStore) MarkInReviewStaleTasks(ctx context.Context, olderThan time.Time) ([]store.RecoveredTaskInfo, error) {
now := time.Now()
rows, err := s.db.QueryContext(ctx,
@@ -176,7 +172,6 @@ func (s *PGTeamStore) MarkInReviewStaleTasks(ctx context.Context, olderThan time
SET status = $1, updated_at = $2
FROM agent_teams tm
WHERE t.team_id = tm.id AND tm.status = 'active'
AND COALESCE((tm.settings->>'version')::int, 0) >= 2
AND t.status = $3 AND t.updated_at < $4
RETURNING t.id, t.team_id, t.tenant_id, t.task_number, t.subject, COALESCE(t.channel, ''), COALESCE(t.chat_id, '')`,
store.TeamTaskStatusStale, now, store.TeamTaskStatusInReview, olderThan,
@@ -197,7 +192,6 @@ func (s *PGTeamStore) FixOrphanedBlockedTasks(ctx context.Context) ([]store.Reco
SET blocked_by = '{}', status = $1, updated_at = $2
FROM agent_teams tm
WHERE t.team_id = tm.id AND tm.status = 'active'
AND COALESCE((tm.settings->>'version')::int, 0) >= 2
AND t.status = 'blocked'
AND array_length(t.blocked_by, 1) > 0
AND NOT EXISTS (
@@ -352,11 +352,10 @@ func (s *SQLiteTeamStore) RenewTaskLock(ctx context.Context, taskID, teamID uuid
// Stale recovery
// ============================================================
// v2ActiveTeamJoin is the JOIN that filters to v2 active teams.
// activeTeamJoin is the JOIN that filters to active teams.
// SQLite uses json_extract instead of PG's ->> operator.
const v2ActiveTeamJoin = `JOIN agent_teams tm ON tm.id = t.team_id
AND tm.status = 'active'
AND COALESCE(CAST(json_extract(tm.settings, '$.version') AS INTEGER), 0) >= 2`
const activeTeamJoin = `JOIN agent_teams tm ON tm.id = t.team_id
AND tm.status = 'active'`
func (s *SQLiteTeamStore) RecoverAllStaleTasks(ctx context.Context) ([]store.RecoveredTaskInfo, error) {
now := time.Now()
@@ -368,7 +367,6 @@ func (s *SQLiteTeamStore) RecoverAllStaleTasks(ctx context.Context) ([]store.Rec
WHERE status = ? AND lock_expires_at IS NOT NULL AND lock_expires_at < ?
AND team_id IN (
SELECT id FROM agent_teams WHERE status = 'active'
AND COALESCE(CAST(json_extract(settings, '$.version') AS INTEGER), 0) >= 2
)`,
store.TeamTaskStatusPending, now, store.TeamTaskStatusInProgress, now,
)
@@ -387,7 +385,6 @@ func (s *SQLiteTeamStore) ForceRecoverAllTasks(ctx context.Context) ([]store.Rec
WHERE status = ?
AND team_id IN (
SELECT id FROM agent_teams WHERE status = 'active'
AND COALESCE(CAST(json_extract(settings, '$.version') AS INTEGER), 0) >= 2
)`,
store.TeamTaskStatusPending, now, store.TeamTaskStatusInProgress,
)
@@ -426,7 +423,6 @@ func (s *SQLiteTeamStore) MarkAllStaleTasks(ctx context.Context, olderThan time.
WHERE status = ? AND updated_at < ?
AND team_id IN (
SELECT id FROM agent_teams WHERE status = 'active'
AND COALESCE(CAST(json_extract(settings, '$.version') AS INTEGER), 0) >= 2
)`,
store.TeamTaskStatusStale, now, store.TeamTaskStatusPending, olderThan,
)
@@ -443,7 +439,6 @@ func (s *SQLiteTeamStore) MarkInReviewStaleTasks(ctx context.Context, olderThan
WHERE status = ? AND updated_at < ?
AND team_id IN (
SELECT id FROM agent_teams WHERE status = 'active'
AND COALESCE(CAST(json_extract(settings, '$.version') AS INTEGER), 0) >= 2
)`,
store.TeamTaskStatusStale, now, store.TeamTaskStatusInReview, olderThan,
)
@@ -462,7 +457,6 @@ func (s *SQLiteTeamStore) FixOrphanedBlockedTasks(ctx context.Context) ([]store.
COALESCE(t.channel, ''), COALESCE(t.chat_id, '')
FROM team_tasks t
JOIN agent_teams tm ON tm.id = t.team_id AND tm.status = 'active'
AND COALESCE(CAST(json_extract(tm.settings, '$.version') AS INTEGER), 0) >= 2
WHERE t.status = 'blocked'
AND t.blocked_by IS NOT NULL AND t.blocked_by != '[]'
AND NOT EXISTS (
@@ -501,7 +495,6 @@ func (s *SQLiteTeamStore) queryRecoveredTasks(ctx context.Context, status string
AND t.updated_at >= ?
AND t.team_id IN (
SELECT id FROM agent_teams WHERE status = 'active'
AND COALESCE(CAST(json_extract(settings, '$.version') AS INTEGER), 0) >= 2
)
ORDER BY t.updated_at DESC
LIMIT 200`,
@@ -599,7 +592,7 @@ func (s *SQLiteTeamStore) ListAllFollowupDueTasks(ctx context.Context) ([]store.
rows, err := s.db.QueryContext(ctx,
`SELECT `+taskSelectCols+`
`+taskJoinClause+`
`+v2ActiveTeamJoin+`
`+activeTeamJoin+`
WHERE t.followup_at IS NOT NULL
AND t.followup_at <= ?
AND t.status = ?
+34 -8
View File
@@ -27,13 +27,20 @@ import (
// Implements Tool + ContextualTool interfaces.
// Per-call channel is read from ctx for thread-safety.
type TtsTool struct {
mu sync.RWMutex
manager *tts.Manager
vaultIntc *VaultInterceptor
mu sync.RWMutex
manager *tts.Manager
vaultIntc *VaultInterceptor
systemConfigs store.SystemConfigStore
}
func (t *TtsTool) SetVaultInterceptor(v *VaultInterceptor) { t.vaultIntc = v }
func (t *TtsTool) SetSystemConfigStore(s store.SystemConfigStore) {
t.mu.Lock()
defer t.mu.Unlock()
t.systemConfigs = s
}
// NewTtsTool creates a TTS tool backed by the given manager.
func NewTtsTool(mgr *tts.Manager) *TtsTool {
return &TtsTool{manager: mgr}
@@ -98,7 +105,7 @@ type agentAudioConfig struct {
// resolveVoiceAndModel computes the effective voice + model IDs for the
// request using the documented precedence order:
//
// args > agent (store.AgentAudioFromCtx OtherConfig) > tenant (BuiltinToolSettings) > empty.
// args > agent (store.AgentAudioFromCtx OtherConfig) > tenant (BuiltinToolSettings) > system_configs > empty.
//
// Empty return values signal "use provider default" downstream — they are not
// errors. Missing agent snapshot emits slog.Warn so operators can spot
@@ -108,7 +115,7 @@ type agentAudioConfig struct {
// tts_voice_id override (not from tool args or tenant defaults). Callers use
// this flag to emit a warning when the voice is later found incompatible with
// the selected provider.
func (t *TtsTool) resolveVoiceAndModel(ctx context.Context, argVoice, argModel string) (voice, model string, voiceFromAgent bool) {
func (t *TtsTool) resolveVoiceAndModel(ctx context.Context, providerName, argVoice, argModel string) (voice, model string, voiceFromAgent bool) {
voice, model = argVoice, argModel
// Pull agent-level config from the dispatcher-injected snapshot.
@@ -153,6 +160,22 @@ func (t *TtsTool) resolveVoiceAndModel(ctx context.Context, argVoice, argModel s
model = tenantCfg.DefaultModel
}
}
t.mu.RLock()
systemConfigs := t.systemConfigs
t.mu.RUnlock()
if (voice == "" || model == "") && systemConfigs != nil && providerName != "" {
if voice == "" {
if v, err := systemConfigs.Get(ctx, "tts."+providerName+".voice"); err == nil && v != "" {
voice = v
}
}
if model == "" {
if m, err := systemConfigs.Get(ctx, "tts."+providerName+".model"); err == nil && m != "" {
model = m
}
}
}
return voice, model, voiceFromAgent
}
@@ -252,9 +275,6 @@ func (t *TtsTool) Execute(ctx context.Context, args map[string]any) *Result {
argModel, _ := args["model"].(string)
providerName, _ := args["provider"].(string)
// Resolve voice/model via args > agent (ctx snapshot) > tenant > default.
voice, model, voiceFromAgent := t.resolveVoiceAndModel(ctx, argVoice, argModel)
// Read generic agent TTS params once; adapt PER-ATTEMPT below (Finding #1 CRITICAL).
// Storing generic keys here so each fallback provider gets its own adapted copy.
genericAgentParams := t.resolveAgentGenericTTSParams(ctx)
@@ -264,6 +284,12 @@ func (t *TtsTool) Execute(ctx context.Context, args map[string]any) *Result {
mgr := t.manager
t.mu.RUnlock()
effectiveProvider := providerName
if effectiveProvider == "" {
effectiveProvider = t.resolvePrimary(ctx, mgr)
}
voice, model, voiceFromAgent := t.resolveVoiceAndModel(ctx, effectiveProvider, argVoice, argModel)
// Determine format based on channel (read from ctx — thread-safe)
channel := ToolChannelFromCtx(ctx)
opts := tts.Options{Voice: voice, Model: model}
+5 -5
View File
@@ -35,7 +35,7 @@ func ctxWithAgentAudio(t *testing.T, voiceID, modelID string) context.Context {
func TestResolveVoiceAndModel_ArgsWinOverAgent(t *testing.T) {
tool := NewTtsTool(makeTTSManager("elevenlabs"))
ctx := ctxWithAgentAudio(t, "AGENT_V", "AGENT_M")
v, m, _ := tool.resolveVoiceAndModel(ctx, "ARG_V", "ARG_M")
v, m, _ := tool.resolveVoiceAndModel(ctx, "edge", "ARG_V", "ARG_M")
if v != "ARG_V" {
t.Errorf("voice: got %q, want ARG_V (args must win)", v)
}
@@ -50,7 +50,7 @@ func TestResolveVoiceAndModel_AgentWinsOverTenantWhenArgsEmpty(t *testing.T) {
ctx = WithBuiltinToolSettings(ctx, BuiltinToolSettings{
"tts": rawJSON(t, map[string]string{"default_voice_id": "TENANT_V", "default_model": "TENANT_M"}),
})
v, m, _ := tool.resolveVoiceAndModel(ctx, "", "")
v, m, _ := tool.resolveVoiceAndModel(ctx, "edge", "", "")
if v != "AGENT_V" {
t.Errorf("voice: got %q, want AGENT_V (agent > tenant)", v)
}
@@ -65,7 +65,7 @@ func TestResolveVoiceAndModel_TenantFallbackWhenAgentSilent(t *testing.T) {
ctx := WithBuiltinToolSettings(context.Background(), BuiltinToolSettings{
"tts": rawJSON(t, map[string]string{"default_voice_id": "TENANT_V", "default_model": "TENANT_M"}),
})
v, m, _ := tool.resolveVoiceAndModel(ctx, "", "")
v, m, _ := tool.resolveVoiceAndModel(ctx, "edge", "", "")
if v != "TENANT_V" {
t.Errorf("voice: got %q, want TENANT_V", v)
}
@@ -76,7 +76,7 @@ func TestResolveVoiceAndModel_TenantFallbackWhenAgentSilent(t *testing.T) {
func TestResolveVoiceAndModel_EmptyAllMeansDefault(t *testing.T) {
tool := NewTtsTool(makeTTSManager("elevenlabs"))
v, m, _ := tool.resolveVoiceAndModel(context.Background(), "", "")
v, m, _ := tool.resolveVoiceAndModel(context.Background(), "edge", "", "")
if v != "" {
t.Errorf("voice: got %q, want empty (no sources → provider default)", v)
}
@@ -92,7 +92,7 @@ func TestResolveVoiceAndModel_PartialAgentConfig(t *testing.T) {
ctx = WithBuiltinToolSettings(ctx, BuiltinToolSettings{
"tts": rawJSON(t, map[string]string{"default_model": "TENANT_M"}),
})
v, m, _ := tool.resolveVoiceAndModel(ctx, "", "")
v, m, _ := tool.resolveVoiceAndModel(ctx, "edge", "", "")
if v != "AGENT_V" {
t.Errorf("voice: got %q, want AGENT_V", v)
}
@@ -0,0 +1,76 @@
package tools
import (
"context"
"testing"
)
type fakeSystemConfigStore struct {
data map[string]string
}
func (f *fakeSystemConfigStore) Get(_ context.Context, key string) (string, error) {
return f.data[key], nil
}
func (f *fakeSystemConfigStore) Set(_ context.Context, key, value string) error {
if f.data == nil {
f.data = map[string]string{}
}
f.data[key] = value
return nil
}
func (f *fakeSystemConfigStore) Delete(_ context.Context, key string) error {
delete(f.data, key)
return nil
}
func (f *fakeSystemConfigStore) List(_ context.Context) (map[string]string, error) {
return f.data, nil
}
func TestResolveVoiceAndModel_SystemConfigsFallback(t *testing.T) {
tool := NewTtsTool(nil)
sc := &fakeSystemConfigStore{data: map[string]string{
"tts.edge.voice": "vi-VN-HoaiMyNeural",
"tts.edge.model": "edge-tts-1",
}}
tool.SetSystemConfigStore(sc)
v, m, _ := tool.resolveVoiceAndModel(context.Background(), "edge", "", "")
if v != "vi-VN-HoaiMyNeural" {
t.Errorf("voice fallback failed: got %q, want vi-VN-HoaiMyNeural", v)
}
if m != "edge-tts-1" {
t.Errorf("model fallback failed: got %q, want edge-tts-1", m)
}
}
func TestResolveVoiceAndModel_ArgWinsOverSystemConfigs(t *testing.T) {
tool := NewTtsTool(nil)
tool.SetSystemConfigStore(&fakeSystemConfigStore{data: map[string]string{
"tts.edge.voice": "vi-VN-HoaiMyNeural",
}})
v, _, _ := tool.resolveVoiceAndModel(context.Background(), "edge", "en-US-AriaNeural", "")
if v != "en-US-AriaNeural" {
t.Errorf("arg voice must win over system_configs: got %q", v)
}
}
func TestResolveVoiceAndModel_NoStoreNoFallback(t *testing.T) {
tool := NewTtsTool(nil)
v, m, _ := tool.resolveVoiceAndModel(context.Background(), "edge", "", "")
if v != "" || m != "" {
t.Errorf("expected empty fallback when no system_configs wired, got voice=%q model=%q", v, m)
}
}
func TestResolveVoiceAndModel_EmptyProviderSkipsFallback(t *testing.T) {
tool := NewTtsTool(nil)
tool.SetSystemConfigStore(&fakeSystemConfigStore{data: map[string]string{
"tts..voice": "should-not-match",
}})
v, m, _ := tool.resolveVoiceAndModel(context.Background(), "", "", "")
if v != "" || m != "" {
t.Errorf("empty provider must skip lookup, got voice=%q model=%q", v, m)
}
}
@@ -30,7 +30,10 @@ export function SystemMessagesSection({ data, schema, onSave, saving }: Props) {
const definitions = useMemo(() => systemMessageDefinitionsFromSchema(schema), [schema]);
const [draft, setDraft] = useState<SystemMessagesDraft>(() => normalizeSystemMessagesDraft(data, definitions));
const [selectedKey, setSelectedKey] = useState("");
const [selectedLocale, setSelectedLocale] = useState<(typeof SYSTEM_MESSAGE_LOCALES)[number]["code"]>("vi");
const initialLocale = (i18n.resolvedLanguage || i18n.language || "en").slice(0, 2) as (typeof SYSTEM_MESSAGE_LOCALES)[number]["code"];
const [selectedLocale, setSelectedLocale] = useState<(typeof SYSTEM_MESSAGE_LOCALES)[number]["code"]>(
SYSTEM_MESSAGE_LOCALES.some((l) => l.code === initialLocale) ? initialLocale : "en",
);
const [defaultLocale, setDefaultLocale] = useState<(typeof SYSTEM_MESSAGE_LOCALES)[number]["code"]>(
(data?.default_locale as (typeof SYSTEM_MESSAGE_LOCALES)[number]["code"]) || "en",
);