diff --git a/cmd/gateway_consumer_normal.go b/cmd/gateway_consumer_normal.go
index 825772bb..7d08d910 100644
--- a/cmd/gateway_consumer_normal.go
+++ b/cmd/gateway_consumer_normal.go
@@ -12,6 +12,7 @@ import (
"github.com/nextlevelbuilder/goclaw/internal/agent"
"github.com/nextlevelbuilder/goclaw/internal/bus"
"github.com/nextlevelbuilder/goclaw/internal/channels"
+ "github.com/nextlevelbuilder/goclaw/internal/channels/telegram/voiceguard"
"github.com/nextlevelbuilder/goclaw/internal/config"
"github.com/nextlevelbuilder/goclaw/internal/i18n"
"github.com/nextlevelbuilder/goclaw/internal/scheduler"
@@ -321,7 +322,7 @@ func processNormalMessage(
})
// Handle result asynchronously to not block the flush callback.
- go func(agentKey, channel, chatID, session, rID string, meta map[string]string, blockReplyEnabled bool, ptd *tools.PendingTeamDispatch) {
+ go func(agentKey, channel, chatID, session, rID, peerKind, inboundContent string, meta map[string]string, blockReplyEnabled bool, ptd *tools.PendingTeamDispatch) {
outcome := <-outCh
// Release team create lock — tasks already visible in DB, other goroutines can list.
@@ -396,11 +397,20 @@ func processNormalMessage(
return
}
+ // Sanitize voice agent replies: replace technical errors with user-friendly fallback.
+ replyContent := voiceguard.SanitizeReply(
+ cfg.Channels.Telegram.VoiceAgentID, agentKey,
+ channel, peerKind, inboundContent, outcome.Result.Content,
+ cfg.Channels.Telegram.AudioGuardFallbackTranscript,
+ cfg.Channels.Telegram.AudioGuardFallbackNoTranscript,
+ cfg.Channels.Telegram.AudioGuardErrorMarkers,
+ )
+
// Publish response back to the channel
outMsg := bus.OutboundMessage{
Channel: channel,
ChatID: chatID,
- Content: outcome.Result.Content,
+ Content: replyContent,
Metadata: meta,
}
@@ -410,7 +420,7 @@ func processNormalMessage(
// Auto-set followup when lead agent replies on a real channel with in_progress tasks.
if teamStore != nil && channel != tools.ChannelSystem && channel != tools.ChannelTeammate && channel != tools.ChannelDashboard {
- go autoSetFollowup(ctx, teamStore, agentStore, agentKey, channel, chatID, outcome.Result.Content)
+ go autoSetFollowup(ctx, teamStore, agentStore, agentKey, channel, chatID, replyContent)
}
- }(agentID, msg.Channel, msg.ChatID, sessionKey, runID, outMeta, blockReply, ptd)
+ }(agentID, msg.Channel, msg.ChatID, sessionKey, runID, peerKind, msg.Content, outMeta, blockReply, ptd)
}
diff --git a/internal/channels/media/stt_client.go b/internal/channels/media/stt_client.go
index d7a689f5..fd166385 100644
--- a/internal/channels/media/stt_client.go
+++ b/internal/channels/media/stt_client.go
@@ -11,6 +11,7 @@ import (
"net/http"
"os"
"path/filepath"
+ "sync"
"time"
)
@@ -22,6 +23,26 @@ const (
sttTranscribeEndpoint = "/transcribe_audio"
)
+var (
+ sttClient *http.Client
+ sttClientOnce sync.Once
+ sttSem = make(chan struct{}, 4) // max 4 concurrent STT calls
+)
+
+// getSTTClient returns a shared HTTP client with connection pooling for STT requests.
+func getSTTClient() *http.Client {
+ sttClientOnce.Do(func() {
+ sttClient = &http.Client{
+ Timeout: 60 * time.Second, // defensive cap in case caller context has no deadline
+ Transport: &http.Transport{
+ MaxIdleConnsPerHost: 4,
+ IdleConnTimeout: 90 * time.Second,
+ },
+ }
+ })
+ return sttClient
+}
+
// STTConfig holds configuration for the Speech-to-Text proxy service.
type STTConfig struct {
ProxyURL string // base URL of the STT proxy (e.g. "http://localhost:8080")
@@ -95,8 +116,15 @@ func TranscribeAudio(ctx context.Context, cfg STTConfig, filePath string) (strin
slog.Debug("stt: calling proxy", "url", url, "file", filepath.Base(filePath))
- client := &http.Client{}
- resp, err := client.Do(req)
+ // Acquire concurrency slot (blocks if 4 calls already in-flight).
+ select {
+ case sttSem <- struct{}{}:
+ defer func() { <-sttSem }()
+ case <-reqCtx.Done():
+ return "", fmt.Errorf("stt: context cancelled waiting for concurrency slot: %w", reqCtx.Err())
+ }
+
+ resp, err := getSTTClient().Do(req)
if err != nil {
return "", fmt.Errorf("stt: request to %q failed: %w", url, err)
}
diff --git a/internal/channels/telegram/voiceguard/guard.go b/internal/channels/telegram/voiceguard/guard.go
new file mode 100644
index 00000000..9930b815
--- /dev/null
+++ b/internal/channels/telegram/voiceguard/guard.go
@@ -0,0 +1,116 @@
+// Package voiceguard intercepts technical error language in voice agent replies
+// and replaces it with user-friendly fallback messages. Pure string transformation
+// with zero dependencies on Telegram SDK or message bus.
+package voiceguard
+
+import (
+ "regexp"
+ "strings"
+)
+
+// defaultErrorMarkers are patterns that indicate a technical/system error in the reply.
+// Matched case-insensitively against the full reply text.
+var defaultErrorMarkers = []string{
+ // English
+ "system error",
+ "exit status",
+ "rate limit",
+ "tool error",
+ "service unavailable",
+ "technical issue",
+ // Vietnamese
+ "vấn đề kỹ thuật",
+ "lỗi hệ thống",
+ "vấn đề hệ thống",
+}
+
+const (
+ defaultFallbackTranscript = `I heard you say: "%s". Let me process that — please try again in a moment!`
+ defaultFallbackNoTranscript = "I received your voice message but had trouble processing it. Please try again!"
+)
+
+// transcriptRe extracts text between ... tags.
+var transcriptRe = regexp.MustCompile(`(?s)\s*(.*?)\s*`)
+
+// SanitizeReply checks whether a voice agent reply contains technical error language
+// and replaces it with a user-friendly fallback. Returns the original reply unchanged
+// when guard conditions are not met or the reply is clean.
+//
+// Guard conditions (all must be true):
+// - voiceAgentID is non-empty and matches agentID
+// - channel is "telegram"
+// - peerKind is "direct"
+// - inbound contains or
+func SanitizeReply(
+ voiceAgentID, agentID, channel, peerKind, inbound, reply string,
+ fallbackTranscript, fallbackNoTranscript string,
+ errorMarkers []string,
+) string {
+ // Guard: feature not configured or agent mismatch.
+ if voiceAgentID == "" || agentID != voiceAgentID {
+ return reply
+ }
+ // Guard: only Telegram DMs with audio/voice media.
+ if channel != "telegram" || peerKind != "direct" {
+ return reply
+ }
+ if !hasAudioTag(inbound) {
+ return reply
+ }
+ // Guard: reply is clean (no technical error language).
+ if !containsErrorLanguage(reply, errorMarkers) {
+ return reply
+ }
+
+ // Build fallback message.
+ transcript := extractTranscript(inbound)
+ if transcript != "" {
+ tpl := fallbackTranscript
+ if tpl == "" {
+ tpl = defaultFallbackTranscript
+ }
+ if strings.Contains(tpl, "%s") {
+ return strings.Replace(tpl, "%s", transcript, 1)
+ }
+ // Template has no placeholder — return it as-is to avoid fmt garbage.
+ return tpl
+ }
+
+ fb := fallbackNoTranscript
+ if fb == "" {
+ fb = defaultFallbackNoTranscript
+ }
+ return fb
+}
+
+// hasAudioTag checks whether the inbound message contains a voice or audio media tag.
+func hasAudioTag(inbound string) bool {
+ return strings.Contains(inbound, " 0 {
+ markers = customMarkers
+ }
+ for _, m := range markers {
+ if strings.Contains(lower, strings.ToLower(m)) {
+ return true
+ }
+ }
+ return false
+}
+
+// extractTranscript extracts the transcript text from ... tags
+// in the inbound message. Returns empty string if no transcript is found.
+func extractTranscript(inbound string) string {
+ m := transcriptRe.FindStringSubmatch(inbound)
+ if len(m) < 2 {
+ return ""
+ }
+ // Collapse internal whitespace/newlines into single spaces.
+ return strings.Join(strings.Fields(m[1]), " ")
+}
diff --git a/internal/channels/telegram/voiceguard/guard_test.go b/internal/channels/telegram/voiceguard/guard_test.go
new file mode 100644
index 00000000..9cb5ef04
--- /dev/null
+++ b/internal/channels/telegram/voiceguard/guard_test.go
@@ -0,0 +1,226 @@
+package voiceguard
+
+import (
+ "strings"
+ "testing"
+)
+
+// ---------------------------------------------------------------------------
+// SanitizeReply — passthrough cases
+// ---------------------------------------------------------------------------
+
+func TestSanitize_PassThrough_WrongAgent(t *testing.T) {
+ got := SanitizeReply("voice-agent", "other-agent", "telegram", "direct",
+ "…", "system error occurred", "", "", nil)
+ if got != "system error occurred" {
+ t.Errorf("expected passthrough, got %q", got)
+ }
+}
+
+func TestSanitize_PassThrough_EmptyVoiceAgentID(t *testing.T) {
+ got := SanitizeReply("", "voice-agent", "telegram", "direct",
+ "…", "exit status 1", "", "", nil)
+ if got != "exit status 1" {
+ t.Errorf("expected passthrough when VoiceAgentID empty, got %q", got)
+ }
+}
+
+func TestSanitize_PassThrough_NonTelegram(t *testing.T) {
+ got := SanitizeReply("voice-agent", "voice-agent", "discord", "direct",
+ "…", "rate limit exceeded", "", "", nil)
+ if got != "rate limit exceeded" {
+ t.Errorf("expected passthrough for non-telegram channel, got %q", got)
+ }
+}
+
+func TestSanitize_PassThrough_GroupChat(t *testing.T) {
+ got := SanitizeReply("voice-agent", "voice-agent", "telegram", "group",
+ "…", "system error occurred", "", "", nil)
+ if got != "system error occurred" {
+ t.Errorf("expected passthrough for group chat, got %q", got)
+ }
+}
+
+func TestSanitize_PassThrough_NoAudioTag(t *testing.T) {
+ got := SanitizeReply("voice-agent", "voice-agent", "telegram", "direct",
+ "just a regular text message", "system error occurred", "", "", nil)
+ if got != "system error occurred" {
+ t.Errorf("expected passthrough when no audio tag, got %q", got)
+ }
+}
+
+func TestSanitize_PassThrough_CleanReply(t *testing.T) {
+ got := SanitizeReply("voice-agent", "voice-agent", "telegram", "direct",
+ "…", "Great job! Your pronunciation is improving.", "", "", nil)
+ if got != "Great job! Your pronunciation is improving." {
+ t.Errorf("expected clean reply passthrough, got %q", got)
+ }
+}
+
+// ---------------------------------------------------------------------------
+// SanitizeReply — error detection + fallback
+// ---------------------------------------------------------------------------
+
+func TestSanitize_ErrorWithTranscript_DefaultFallback(t *testing.T) {
+ inbound := `I usually wake up at seven`
+ got := SanitizeReply("voice-agent", "voice-agent", "telegram", "direct",
+ inbound, "system error: tool execution failed", "", "", nil)
+
+ if !strings.Contains(got, "I usually wake up at seven") {
+ t.Errorf("expected transcript in fallback, got: %q", got)
+ }
+ if strings.Contains(got, "system error") {
+ t.Errorf("technical error leaked into fallback: %q", got)
+ }
+}
+
+func TestSanitize_ErrorWithTranscript_CustomFallback(t *testing.T) {
+ inbound := `hello world`
+ got := SanitizeReply("voice-agent", "voice-agent", "telegram", "direct",
+ inbound, "rate limit exceeded",
+ "Transcript received: %s. Please send again!", "", nil)
+
+ want := "Transcript received: hello world. Please send again!"
+ if got != want {
+ t.Errorf("expected %q, got %q", want, got)
+ }
+}
+
+func TestSanitize_ErrorWithTranscript_CustomFallbackNoPlaceholder(t *testing.T) {
+ customTpl := "Please resend your voice note, there was a small hiccup!"
+ inbound := `hello world`
+ got := SanitizeReply("voice-agent", "voice-agent", "telegram", "direct",
+ inbound, "system error: tool execution failed", customTpl, "", nil)
+
+ if got != customTpl {
+ t.Errorf("expected clean fallback %q, got %q", customTpl, got)
+ }
+ if strings.Contains(got, "%!") {
+ t.Errorf("fmt.Sprintf garbage leaked into output: %q", got)
+ }
+}
+
+func TestSanitize_ErrorNoTranscript_DefaultFallback(t *testing.T) {
+ got := SanitizeReply("voice-agent", "voice-agent", "telegram", "direct",
+ "…", "exit status 1", "", "", nil)
+
+ if strings.Contains(got, "exit status") {
+ t.Errorf("technical error leaked into fallback: %q", got)
+ }
+ if got == "" {
+ t.Error("expected non-empty fallback, got empty string")
+ }
+}
+
+func TestSanitize_ErrorNoTranscript_CustomFallback(t *testing.T) {
+ custom := "Sorry, please resend your voice note."
+ got := SanitizeReply("voice-agent", "voice-agent", "telegram", "direct",
+ "…", "tool error: service unavailable", "", custom, nil)
+
+ if got != custom {
+ t.Errorf("expected custom no-transcript fallback %q, got %q", custom, got)
+ }
+}
+
+func TestSanitize_MediaAudioTag(t *testing.T) {
+ inbound := `good morning`
+ got := SanitizeReply("voice-agent", "voice-agent", "telegram", "direct",
+ inbound, "rate limit: too many requests", "", "", nil)
+
+ if strings.Contains(got, "rate limit") {
+ t.Errorf("technical error leaked: %q", got)
+ }
+ if !strings.Contains(got, "good morning") {
+ t.Errorf("expected transcript in fallback, got: %q", got)
+ }
+}
+
+func TestSanitize_CustomErrorMarkers(t *testing.T) {
+ markers := []string{"custom failure", "oops"}
+ // Default marker should NOT trigger when custom markers are set.
+ got := SanitizeReply("voice-agent", "voice-agent", "telegram", "direct",
+ "…", "system error occurred", "", "", markers)
+ if got != "system error occurred" {
+ t.Errorf("expected passthrough (custom markers don't include 'system error'), got %q", got)
+ }
+ // Custom marker should trigger.
+ got = SanitizeReply("voice-agent", "voice-agent", "telegram", "direct",
+ "…", "oops something went wrong", "", "", markers)
+ if got == "oops something went wrong" {
+ t.Error("expected fallback for custom marker 'oops', got passthrough")
+ }
+}
+
+// ---------------------------------------------------------------------------
+// containsErrorLanguage
+// ---------------------------------------------------------------------------
+
+func TestContainsErrorLanguage_Positives(t *testing.T) {
+ cases := []string{
+ "vấn đề kỹ thuật xảy ra",
+ "lỗi hệ thống",
+ "vấn đề hệ thống",
+ "technical issue detected",
+ "system error: something broke",
+ "exit status 1",
+ "rate limit exceeded",
+ "tool error: execution failed",
+ "SYSTEM ERROR occurred", // mixed case
+ }
+ for _, s := range cases {
+ if !containsErrorLanguage(s, nil) {
+ t.Errorf("expected true for %q, got false", s)
+ }
+ }
+}
+
+func TestContainsErrorLanguage_Negatives(t *testing.T) {
+ cases := []string{
+ "",
+ "Great job!",
+ "Your pronunciation is improving.",
+ "Please try again.",
+ "I heard you say: hello world.",
+ }
+ for _, s := range cases {
+ if containsErrorLanguage(s, nil) {
+ t.Errorf("expected false for %q, got true", s)
+ }
+ }
+}
+
+// ---------------------------------------------------------------------------
+// extractTranscript
+// ---------------------------------------------------------------------------
+
+func TestExtractTranscript_Present(t *testing.T) {
+ cases := []struct {
+ input string
+ want string
+ }{
+ {`hello world`, "hello world"},
+ {` spaces around `, "spaces around"},
+ {"\n\nMulti\nline\ntranscript\n\n", "Multi line transcript"},
+ {"only transcript", "only transcript"},
+ }
+ for _, tc := range cases {
+ got := extractTranscript(tc.input)
+ if got != tc.want {
+ t.Errorf("input %q: expected %q, got %q", tc.input, tc.want, got)
+ }
+ }
+}
+
+func TestExtractTranscript_Absent(t *testing.T) {
+ cases := []string{
+ "…",
+ "plain text message",
+ "",
+ }
+ for _, s := range cases {
+ got := extractTranscript(s)
+ if got != "" {
+ t.Errorf("expected empty transcript for %q, got %q", s, got)
+ }
+ }
+}
diff --git a/internal/config/config_channels.go b/internal/config/config_channels.go
index 0d2e1dfb..fc3f1b5f 100644
--- a/internal/config/config_channels.go
+++ b/internal/config/config_channels.go
@@ -54,6 +54,12 @@ type TelegramConfig struct {
// agent instead of the default channel agent. Requires the named agent to exist in the config.
VoiceAgentID string `json:"voice_agent_id,omitempty"` // agent ID to route voice inbound to (e.g. "speaking-agent")
+ // Audio guard: intercept technical errors in voice agent replies and replace with friendly fallbacks.
+ // Only active when VoiceAgentID is set. Custom error markers replace built-in defaults when provided.
+ AudioGuardFallbackTranscript string `json:"audio_guard_fallback_transcript,omitempty"` // fallback with %s for transcript (e.g. "I heard: \"%s\". Try again!")
+ AudioGuardFallbackNoTranscript string `json:"audio_guard_fallback_no_transcript,omitempty"` // fallback when no transcript available
+ AudioGuardErrorMarkers []string `json:"audio_guard_error_markers,omitempty"` // custom error detection markers (replaces defaults)
+
// Per-group (and per-topic) overrides. Key is chat ID string (e.g. "-100123456") or "*" for wildcard.
// TS ref: channels.telegram.groups in src/config/types.telegram.ts.
Groups map[string]*TelegramGroupConfig `json:"groups,omitempty"`