feat(telegram): add voiceguard error sanitization and STT concurrency control (#33)

Cherry-pick two features from PR #33:

- Voiceguard: intercept technical errors (rate limits, tool failures, exit codes)
  in voice agent replies and replace with user-friendly fallback messages.
  Configurable error markers and fallback templates via TelegramConfig.
- STT: shared HTTP client with connection pooling (sync.Once) and concurrency
  semaphore (max 4 concurrent calls) to prevent STT proxy overload.
This commit is contained in:
viettranx
2026-03-20 07:50:13 +07:00
parent a4a7a59b6a
commit 9f80842bbc
5 changed files with 392 additions and 6 deletions
+14 -4
View File
@@ -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)
}
+30 -2
View File
@@ -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)
}
@@ -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 <transcript>...</transcript> tags.
var transcriptRe = regexp.MustCompile(`(?s)<transcript>\s*(.*?)\s*</transcript>`)
// 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 <media:voice> or <media:audio>
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, "<media:voice") || strings.Contains(inbound, "<media:audio")
}
// containsErrorLanguage checks whether the reply contains any error marker.
// When custom markers are provided, they replace the built-in defaults.
func containsErrorLanguage(reply string, customMarkers []string) bool {
lower := strings.ToLower(reply)
markers := defaultErrorMarkers
if len(customMarkers) > 0 {
markers = customMarkers
}
for _, m := range markers {
if strings.Contains(lower, strings.ToLower(m)) {
return true
}
}
return false
}
// extractTranscript extracts the transcript text from <transcript>...</transcript> 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]), " ")
}
@@ -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",
"<media:voice>…</media:voice>", "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",
"<media:voice>…</media:voice>", "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",
"<media:voice>…</media:voice>", "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",
"<media:voice>…</media:voice>", "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",
"<media:voice>…</media:voice>", "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 := `<media:voice><transcript>I usually wake up at seven</transcript></media:voice>`
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 := `<media:voice><transcript>hello world</transcript></media:voice>`
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 := `<media:voice><transcript>hello world</transcript></media:voice>`
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",
"<media:voice>…</media:voice>", "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",
"<media:audio>…</media:audio>", "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 := `<media:audio><transcript>good morning</transcript></media:audio>`
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",
"<media:voice>…</media:voice>", "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",
"<media:voice>…</media:voice>", "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
}{
{`<media:voice><transcript>hello world</transcript></media:voice>`, "hello world"},
{`<media:audio><transcript> spaces around </transcript></media:audio>`, "spaces around"},
{"<media:voice>\n<transcript>\nMulti\nline\ntranscript\n</transcript>\n</media:voice>", "Multi line transcript"},
{"<transcript>only transcript</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{
"<media:voice>…</media:voice>",
"plain text message",
"",
}
for _, s := range cases {
got := extractTranscript(s)
if got != "" {
t.Errorf("expected empty transcript for %q, got %q", s, got)
}
}
}
+6
View File
@@ -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"`