mirror of
https://github.com/tiennm99/goclaw.git
synced 2026-08-05 22:24:42 +00:00
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:
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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"`
|
||||
|
||||
Reference in New Issue
Block a user