mirror of
https://github.com/tiennm99/goclaw.git
synced 2026-10-14 02:15:57 +00:00
feat(telegram): add speech-to-text transcription for voice/audio messages with audio-aware routing (#14)
* feat(telegram): add speech-to-text transcription for voice/audio messages with audio-aware routing Implement an STT pipeline for inbound voice and audio messages in the Telegram channel: - Add new STT configuration options (stt_proxy_url, stt_api_key, stt_tenant_id, stt_timeout_seconds) - Add VoiceAgentID config to route voice messages to a dedicated speaking agent - New stt.go module handles transcription via configurable STT proxy service - MediaInfo struct now includes Transcript field for storing transcription results - buildMediaTags embeds transcript in media tags when available for downstream processing This enables the pipeline to process voice messages with full transcription rather than falling back to text-only handling. * test(telegram): add tests for buildMediaTags and transcribeAudio functions
This commit is contained in:
1 parent
4c67dff24d
commit
eacc481998
6 files changed
+675
-30
No files matched your search
@@ -164,7 +164,51 @@ func (c *Channel) handleMessage(ctx context.Context, update telego.Update) {
|
||||
var mediaPaths []string
|
||||
|
||||
if len(mediaList) > 0 {
|
||||
// Build media tags for content
|
||||
// First pass: process each media item.
|
||||
// For audio/voice: attempt STT transcription so that buildMediaTags can embed the transcript.
|
||||
// For documents: extract text content to append after the media tags.
|
||||
// Note: buildMediaTags is called AFTER this loop so it picks up populated Transcript fields.
|
||||
var extraContent string
|
||||
for i := range mediaList {
|
||||
m := &mediaList[i]
|
||||
|
||||
switch m.Type {
|
||||
case "audio", "voice":
|
||||
transcript, sttErr := c.transcribeAudio(ctx, m.FilePath)
|
||||
if sttErr != nil {
|
||||
slog.Warn("telegram: STT transcription failed, falling back to media placeholder",
|
||||
"type", m.Type, "error", sttErr,
|
||||
)
|
||||
} else {
|
||||
m.Transcript = transcript
|
||||
}
|
||||
|
||||
case "document":
|
||||
// Extract text content from documents
|
||||
if m.FileName != "" && m.FilePath != "" {
|
||||
docContent, err := extractDocumentContent(m.FilePath, m.FileName)
|
||||
if err != nil {
|
||||
slog.Warn("document extraction failed", "file", m.FileName, "error", err)
|
||||
} else if docContent != "" {
|
||||
extraContent += "\n\n" + docContent
|
||||
}
|
||||
}
|
||||
|
||||
case "video", "animation":
|
||||
// Video: notify user that video is not fully supported yet.
|
||||
// Only add the notice when there is no caption/text — media tags haven't been
|
||||
// prepended yet at this stage of the pipeline.
|
||||
if content == "" {
|
||||
extraContent += "\n\n[Video received — video content analysis is not yet supported, only caption text is processed]"
|
||||
}
|
||||
}
|
||||
|
||||
if m.FilePath != "" {
|
||||
mediaPaths = append(mediaPaths, m.FilePath)
|
||||
}
|
||||
}
|
||||
|
||||
// Build media tags AFTER the processing loop so transcript fields are populated.
|
||||
mediaTags := buildMediaTags(mediaList)
|
||||
if mediaTags != "" {
|
||||
if content != "" {
|
||||
@@ -174,32 +218,9 @@ func (c *Channel) handleMessage(ctx context.Context, update telego.Update) {
|
||||
}
|
||||
}
|
||||
|
||||
// Process each media item
|
||||
for i := range mediaList {
|
||||
m := &mediaList[i]
|
||||
|
||||
switch m.Type {
|
||||
case "document":
|
||||
// Extract text content from documents
|
||||
if m.FileName != "" && m.FilePath != "" {
|
||||
docContent, err := extractDocumentContent(m.FilePath, m.FileName)
|
||||
if err != nil {
|
||||
slog.Warn("document extraction failed", "file", m.FileName, "error", err)
|
||||
} else if docContent != "" {
|
||||
content += "\n\n" + docContent
|
||||
}
|
||||
}
|
||||
|
||||
case "video", "animation":
|
||||
// Video: notify user that video is not fully supported yet
|
||||
if content == "" || content == buildMediaTags(mediaList) {
|
||||
content += "\n\n[Video received — video content analysis is not yet supported, only caption text is processed]"
|
||||
}
|
||||
}
|
||||
|
||||
if m.FilePath != "" {
|
||||
mediaPaths = append(mediaPaths, m.FilePath)
|
||||
}
|
||||
// Append any extra content accumulated during processing (doc text, video note, etc.)
|
||||
if extraContent != "" {
|
||||
content += extraContent
|
||||
}
|
||||
}
|
||||
|
||||
@@ -353,6 +374,22 @@ func (c *Channel) handleMessage(ctx context.Context, update telego.Update) {
|
||||
peerKind = "group"
|
||||
}
|
||||
|
||||
// Audio-aware routing: if a voice/audio message was received and a dedicated speaking agent
|
||||
// is configured, route to that agent instead of the default channel agent.
|
||||
// This prevents voice turns from landing on a text-router agent that cannot handle audio.
|
||||
targetAgentID := c.AgentID()
|
||||
if c.config.VoiceAgentID != "" {
|
||||
for _, m := range mediaList {
|
||||
if m.Type == "audio" || m.Type == "voice" {
|
||||
targetAgentID = c.config.VoiceAgentID
|
||||
slog.Debug("telegram: routing voice inbound to speaking agent",
|
||||
"agent_id", targetAgentID, "media_type", m.Type,
|
||||
)
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
c.Bus().PublishInbound(bus.InboundMessage{
|
||||
Channel: c.Name(),
|
||||
SenderID: senderID,
|
||||
@@ -361,7 +398,7 @@ func (c *Channel) handleMessage(ctx context.Context, update telego.Update) {
|
||||
Media: mediaPaths,
|
||||
PeerKind: peerKind,
|
||||
UserID: userID,
|
||||
AgentID: c.AgentID(),
|
||||
AgentID: targetAgentID,
|
||||
HistoryLimit: c.historyLimit,
|
||||
Metadata: metadata,
|
||||
})
|
||||
|
||||
@@ -39,6 +39,7 @@ type MediaInfo struct {
|
||||
ContentType string // MIME type
|
||||
FileName string // original filename
|
||||
FileSize int64
|
||||
Transcript string // STT transcript for audio/voice media (empty if not transcribed)
|
||||
}
|
||||
|
||||
// mediaGroupBuffer buffers media group (album) messages before processing them together.
|
||||
@@ -251,6 +252,7 @@ func (c *Channel) downloadMedia(ctx context.Context, fileID string, maxBytes int
|
||||
}
|
||||
|
||||
// buildMediaTags generates content tags for media items (matching TS media placeholder format).
|
||||
// For audio/voice items that have been transcribed, the transcript is embedded in a <transcript> block.
|
||||
func buildMediaTags(mediaList []MediaInfo) string {
|
||||
var tags []string
|
||||
for _, m := range mediaList {
|
||||
@@ -260,9 +262,17 @@ func buildMediaTags(mediaList []MediaInfo) string {
|
||||
case "video", "animation":
|
||||
tags = append(tags, "<media:video>")
|
||||
case "audio":
|
||||
tags = append(tags, "<media:audio>")
|
||||
if m.Transcript != "" {
|
||||
tags = append(tags, fmt.Sprintf("<media:audio>\n<transcript>%s</transcript>", html.EscapeString(m.Transcript)))
|
||||
} else {
|
||||
tags = append(tags, "<media:audio>")
|
||||
}
|
||||
case "voice":
|
||||
tags = append(tags, "<media:voice>")
|
||||
if m.Transcript != "" {
|
||||
tags = append(tags, fmt.Sprintf("<media:voice>\n<transcript>%s</transcript>", html.EscapeString(m.Transcript)))
|
||||
} else {
|
||||
tags = append(tags, "<media:voice>")
|
||||
}
|
||||
case "document":
|
||||
tags = append(tags, "<media:document>")
|
||||
}
|
||||
|
||||
@@ -0,0 +1,150 @@
|
||||
package telegram
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// --- buildMediaTags tests ---
|
||||
|
||||
// TestBuildMediaTags_NoTranscript_Legacy verifies that the pre-patch behaviour is
|
||||
// preserved: audio/voice items without a transcript still produce plain tags,
|
||||
// and all other media types are unaffected.
|
||||
func TestBuildMediaTags_NoTranscript_Legacy(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
items []MediaInfo
|
||||
want string
|
||||
}{
|
||||
{
|
||||
name: "image",
|
||||
items: []MediaInfo{{Type: "image"}},
|
||||
want: "<media:image>",
|
||||
},
|
||||
{
|
||||
name: "video",
|
||||
items: []MediaInfo{{Type: "video"}},
|
||||
want: "<media:video>",
|
||||
},
|
||||
{
|
||||
name: "animation",
|
||||
items: []MediaInfo{{Type: "animation"}},
|
||||
want: "<media:video>",
|
||||
},
|
||||
{
|
||||
name: "audio without transcript",
|
||||
items: []MediaInfo{{Type: "audio"}},
|
||||
want: "<media:audio>",
|
||||
},
|
||||
{
|
||||
name: "voice without transcript",
|
||||
items: []MediaInfo{{Type: "voice"}},
|
||||
want: "<media:voice>",
|
||||
},
|
||||
{
|
||||
name: "document",
|
||||
items: []MediaInfo{{Type: "document"}},
|
||||
want: "<media:document>",
|
||||
},
|
||||
{
|
||||
name: "empty list",
|
||||
items: []MediaInfo{},
|
||||
want: "",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
got := buildMediaTags(tt.items)
|
||||
if got != tt.want {
|
||||
t.Errorf("buildMediaTags(%v) = %q, want %q", tt.items, got, tt.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestBuildMediaTags_VoiceWithTranscript verifies that a voice item with a
|
||||
// populated Transcript field generates the <transcript> sub-block.
|
||||
func TestBuildMediaTags_VoiceWithTranscript(t *testing.T) {
|
||||
items := []MediaInfo{{Type: "voice", Transcript: "xin chào"}}
|
||||
got := buildMediaTags(items)
|
||||
|
||||
if !strings.HasPrefix(got, "<media:voice>") {
|
||||
t.Errorf("expected output to start with <media:voice>, got: %q", got)
|
||||
}
|
||||
if !strings.Contains(got, "<transcript>") {
|
||||
t.Errorf("expected <transcript> block, got: %q", got)
|
||||
}
|
||||
if !strings.Contains(got, "xin chào") {
|
||||
t.Errorf("expected transcript text in output, got: %q", got)
|
||||
}
|
||||
if !strings.Contains(got, "</transcript>") {
|
||||
t.Errorf("expected closing </transcript>, got: %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
// TestBuildMediaTags_AudioWithTranscript verifies the same for audio type.
|
||||
func TestBuildMediaTags_AudioWithTranscript(t *testing.T) {
|
||||
items := []MediaInfo{{Type: "audio", Transcript: "hello world"}}
|
||||
got := buildMediaTags(items)
|
||||
|
||||
if !strings.HasPrefix(got, "<media:audio>") {
|
||||
t.Errorf("expected output to start with <media:audio>, got: %q", got)
|
||||
}
|
||||
if !strings.Contains(got, "<transcript>hello world</transcript>") {
|
||||
t.Errorf("expected transcript content, got: %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
// TestBuildMediaTags_TranscriptHTMLEscaping verifies that special HTML characters
|
||||
// in the transcript are properly escaped to prevent XML injection.
|
||||
func TestBuildMediaTags_TranscriptHTMLEscaping(t *testing.T) {
|
||||
items := []MediaInfo{{Type: "voice", Transcript: `<script>alert("xss")</script>`}}
|
||||
got := buildMediaTags(items)
|
||||
|
||||
// Raw angle brackets must NOT appear inside the transcript block.
|
||||
if strings.Contains(got, "<script>") {
|
||||
t.Errorf("unescaped <script> tag found in output — XSS risk: %q", got)
|
||||
}
|
||||
// Escaped form must be present.
|
||||
if !strings.Contains(got, "<script>") {
|
||||
t.Errorf("expected HTML-escaped content, got: %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
// TestBuildMediaTags_MultipleItems verifies correct handling of mixed media lists,
|
||||
// including one voice with transcript and others without.
|
||||
func TestBuildMediaTags_MultipleItems(t *testing.T) {
|
||||
items := []MediaInfo{
|
||||
{Type: "image"},
|
||||
{Type: "voice", Transcript: "hey there"},
|
||||
{Type: "document"},
|
||||
}
|
||||
got := buildMediaTags(items)
|
||||
parts := strings.Split(got, "\n")
|
||||
|
||||
// Should have 3 top-level entries (image, voice block [2 lines], document)
|
||||
// but since voice produces 2 lines the split will have 4 parts.
|
||||
if !strings.Contains(parts[0], "<media:image>") {
|
||||
t.Errorf("first part should be image tag, got: %q", parts[0])
|
||||
}
|
||||
if !strings.Contains(got, "<media:voice>") {
|
||||
t.Errorf("expected voice tag, not found in: %q", got)
|
||||
}
|
||||
if !strings.Contains(got, "hey there") {
|
||||
t.Errorf("expected transcript text, not found in: %q", got)
|
||||
}
|
||||
if !strings.Contains(got, "<media:document>") {
|
||||
t.Errorf("expected document tag, not found in: %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
// TestBuildMediaTags_UnknownType verifies that an unrecognised media type is
|
||||
// silently ignored (no panic, no output).
|
||||
func TestBuildMediaTags_UnknownType(t *testing.T) {
|
||||
items := []MediaInfo{{Type: "sticker"}}
|
||||
got := buildMediaTags(items)
|
||||
if got != "" {
|
||||
t.Errorf("expected empty string for unknown type, got: %q", got)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,133 @@
|
||||
package telegram
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"log/slog"
|
||||
"mime/multipart"
|
||||
"net/http"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"time"
|
||||
)
|
||||
|
||||
const (
|
||||
// defaultSTTTimeoutSeconds is the default timeout for STT proxy requests.
|
||||
defaultSTTTimeoutSeconds = 30
|
||||
|
||||
// sttTranscribeEndpoint is the path appended to STTProxyURL.
|
||||
sttTranscribeEndpoint = "/transcribe_audio"
|
||||
)
|
||||
|
||||
// sttResponse is the expected JSON response from the STT proxy.
|
||||
type sttResponse struct {
|
||||
Transcript string `json:"transcript"`
|
||||
}
|
||||
|
||||
// transcribeAudio calls the configured STT proxy service with the given audio file and returns
|
||||
// the transcribed text. It returns ("", nil) silently when:
|
||||
// - STT is not configured (STTProxyURL is empty), or
|
||||
// - filePath is empty (download failed earlier in the pipeline).
|
||||
//
|
||||
// Any HTTP or parse error is returned so the caller can log it and fall back gracefully.
|
||||
// This matches the TS speaking-service /transcribe_audio contract used in managed deployments.
|
||||
func (c *Channel) transcribeAudio(ctx context.Context, filePath string) (string, error) {
|
||||
if c.config.STTProxyURL == "" {
|
||||
// STT not configured — skip silently.
|
||||
return "", nil
|
||||
}
|
||||
if filePath == "" {
|
||||
// File download failed earlier; nothing to transcribe.
|
||||
return "", nil
|
||||
}
|
||||
|
||||
// Resolve request timeout.
|
||||
timeoutSec := c.config.STTTimeoutSeconds
|
||||
if timeoutSec <= 0 {
|
||||
timeoutSec = defaultSTTTimeoutSeconds
|
||||
}
|
||||
|
||||
// Open the downloaded audio file.
|
||||
f, err := os.Open(filePath)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("stt: open audio file %q: %w", filePath, err)
|
||||
}
|
||||
defer f.Close()
|
||||
|
||||
// Build multipart/form-data body.
|
||||
// Fields:
|
||||
// file — audio file bytes (required)
|
||||
// tenant_id — optional tenant identifier forwarded to the proxy
|
||||
var body bytes.Buffer
|
||||
w := multipart.NewWriter(&body)
|
||||
|
||||
fw, err := w.CreateFormFile("file", filepath.Base(filePath))
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("stt: create form file field: %w", err)
|
||||
}
|
||||
if _, err := io.Copy(fw, f); err != nil {
|
||||
return "", fmt.Errorf("stt: write audio bytes to form: %w", err)
|
||||
}
|
||||
|
||||
if c.config.STTTenantID != "" {
|
||||
if err := w.WriteField("tenant_id", c.config.STTTenantID); err != nil {
|
||||
return "", fmt.Errorf("stt: write tenant_id field: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
if err := w.Close(); err != nil {
|
||||
return "", fmt.Errorf("stt: close multipart writer: %w", err)
|
||||
}
|
||||
|
||||
// Build HTTP request with a deadline.
|
||||
reqCtx, cancel := context.WithTimeout(ctx, time.Duration(timeoutSec)*time.Second)
|
||||
defer cancel()
|
||||
|
||||
url := c.config.STTProxyURL + sttTranscribeEndpoint
|
||||
req, err := http.NewRequestWithContext(reqCtx, http.MethodPost, url, &body)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("stt: build request to %q: %w", url, err)
|
||||
}
|
||||
req.Header.Set("Content-Type", w.FormDataContentType())
|
||||
if c.config.STTAPIKey != "" {
|
||||
req.Header.Set("Authorization", "Bearer "+c.config.STTAPIKey)
|
||||
}
|
||||
|
||||
slog.Debug("telegram: calling STT proxy", "url", url, "file", filepath.Base(filePath))
|
||||
|
||||
client := &http.Client{}
|
||||
resp, err := client.Do(req)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("stt: request to %q failed: %w", url, err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
respBody, err := io.ReadAll(io.LimitReader(resp.Body, 1<<20)) // 1 MB cap
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("stt: read response body: %w", err)
|
||||
}
|
||||
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return "", fmt.Errorf("stt: upstream returned %d: %s", resp.StatusCode, string(respBody))
|
||||
}
|
||||
|
||||
var result sttResponse
|
||||
if err := json.Unmarshal(respBody, &result); err != nil {
|
||||
return "", fmt.Errorf("stt: parse response JSON: %w", err)
|
||||
}
|
||||
|
||||
slog.Debug("telegram: STT transcript received",
|
||||
"length", len(result.Transcript),
|
||||
"preview", func() string {
|
||||
if len(result.Transcript) > 80 {
|
||||
return result.Transcript[:80] + "..."
|
||||
}
|
||||
return result.Transcript
|
||||
}(),
|
||||
)
|
||||
|
||||
return result.Transcript, nil
|
||||
}
|
||||
@@ -0,0 +1,304 @@
|
||||
package telegram
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"os"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/nextlevelbuilder/goclaw/internal/config"
|
||||
)
|
||||
|
||||
// newChannelWithSTT is a minimal Channel stub for STT unit tests.
|
||||
// It skips bot initialisation (which requires a real Telegram token).
|
||||
func newChannelWithSTT(cfg config.TelegramConfig) *Channel {
|
||||
return &Channel{config: cfg}
|
||||
}
|
||||
|
||||
// writeTempAudio writes a fake audio file and returns its path.
|
||||
// The caller is responsible for removing the file after the test.
|
||||
func writeTempAudio(t *testing.T, content string) string {
|
||||
t.Helper()
|
||||
f, err := os.CreateTemp("", "stt_test_*.ogg")
|
||||
if err != nil {
|
||||
t.Fatalf("create temp audio file: %v", err)
|
||||
}
|
||||
if _, err := f.WriteString(content); err != nil {
|
||||
t.Fatalf("write temp audio file: %v", err)
|
||||
}
|
||||
f.Close()
|
||||
return f.Name()
|
||||
}
|
||||
|
||||
// --- transcribeAudio unit tests ---
|
||||
|
||||
// TestTranscribeAudio_NoProxy verifies that when STTProxyURL is empty, the
|
||||
// function returns ("", nil) without making any HTTP call.
|
||||
func TestTranscribeAudio_NoProxy(t *testing.T) {
|
||||
c := newChannelWithSTT(config.TelegramConfig{})
|
||||
transcript, err := c.transcribeAudio(context.Background(), "/any/file.ogg")
|
||||
if err != nil {
|
||||
t.Fatalf("expected nil error, got: %v", err)
|
||||
}
|
||||
if transcript != "" {
|
||||
t.Fatalf("expected empty transcript, got: %q", transcript)
|
||||
}
|
||||
}
|
||||
|
||||
// TestTranscribeAudio_EmptyFilePath verifies that an empty filePath is a silent
|
||||
// no-op even when STT is configured.
|
||||
func TestTranscribeAudio_EmptyFilePath(t *testing.T) {
|
||||
c := newChannelWithSTT(config.TelegramConfig{
|
||||
STTProxyURL: "https://stt.example.com",
|
||||
})
|
||||
transcript, err := c.transcribeAudio(context.Background(), "")
|
||||
if err != nil {
|
||||
t.Fatalf("expected nil error, got: %v", err)
|
||||
}
|
||||
if transcript != "" {
|
||||
t.Fatalf("expected empty transcript, got: %q", transcript)
|
||||
}
|
||||
}
|
||||
|
||||
// TestTranscribeAudio_MissingFile verifies that a non-existent file path returns
|
||||
// an error (not a silent empty result).
|
||||
func TestTranscribeAudio_MissingFile(t *testing.T) {
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
// Should never be reached.
|
||||
t.Error("unexpected HTTP call for missing file")
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
c := newChannelWithSTT(config.TelegramConfig{STTProxyURL: srv.URL})
|
||||
_, err := c.transcribeAudio(context.Background(), "/nonexistent/file.ogg")
|
||||
if err == nil {
|
||||
t.Fatal("expected an error for missing file, got nil")
|
||||
}
|
||||
}
|
||||
|
||||
// TestTranscribeAudio_Success verifies the happy path: a real HTTP server returns
|
||||
// {"transcript": "hello world"} and the function returns that string.
|
||||
func TestTranscribeAudio_Success(t *testing.T) {
|
||||
audioFile := writeTempAudio(t, "fake-ogg-bytes")
|
||||
defer os.Remove(audioFile)
|
||||
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
// Verify endpoint path.
|
||||
if r.URL.Path != sttTranscribeEndpoint {
|
||||
t.Errorf("unexpected path: %s", r.URL.Path)
|
||||
}
|
||||
// Verify method.
|
||||
if r.Method != http.MethodPost {
|
||||
t.Errorf("expected POST, got %s", r.Method)
|
||||
}
|
||||
// Verify multipart body contains a "file" field.
|
||||
if err := r.ParseMultipartForm(1 << 20); err != nil {
|
||||
t.Errorf("parse multipart: %v", err)
|
||||
}
|
||||
if _, _, err := r.FormFile("file"); err != nil {
|
||||
t.Errorf("expected 'file' field in multipart form: %v", err)
|
||||
}
|
||||
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
json.NewEncoder(w).Encode(sttResponse{Transcript: "hello world"})
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
c := newChannelWithSTT(config.TelegramConfig{STTProxyURL: srv.URL})
|
||||
transcript, err := c.transcribeAudio(context.Background(), audioFile)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if transcript != "hello world" {
|
||||
t.Errorf("expected %q, got %q", "hello world", transcript)
|
||||
}
|
||||
}
|
||||
|
||||
// TestTranscribeAudio_BearerToken verifies that STTAPIKey is sent as an
|
||||
// Authorization: Bearer header.
|
||||
func TestTranscribeAudio_BearerToken(t *testing.T) {
|
||||
audioFile := writeTempAudio(t, "fake-ogg-bytes")
|
||||
defer os.Remove(audioFile)
|
||||
|
||||
const wantKey = "super-secret-key"
|
||||
var gotAuth string
|
||||
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
gotAuth = r.Header.Get("Authorization")
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
json.NewEncoder(w).Encode(sttResponse{Transcript: "ok"})
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
c := newChannelWithSTT(config.TelegramConfig{
|
||||
STTProxyURL: srv.URL,
|
||||
STTAPIKey: wantKey,
|
||||
})
|
||||
if _, err := c.transcribeAudio(context.Background(), audioFile); err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if gotAuth != "Bearer "+wantKey {
|
||||
t.Errorf("expected Authorization %q, got %q", "Bearer "+wantKey, gotAuth)
|
||||
}
|
||||
}
|
||||
|
||||
// TestTranscribeAudio_NoAuthHeader verifies that no Authorization header is sent
|
||||
// when STTAPIKey is empty.
|
||||
func TestTranscribeAudio_NoAuthHeader(t *testing.T) {
|
||||
audioFile := writeTempAudio(t, "fake-ogg-bytes")
|
||||
defer os.Remove(audioFile)
|
||||
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if auth := r.Header.Get("Authorization"); auth != "" {
|
||||
t.Errorf("expected no Authorization header, got %q", auth)
|
||||
}
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
json.NewEncoder(w).Encode(sttResponse{Transcript: "ok"})
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
c := newChannelWithSTT(config.TelegramConfig{STTProxyURL: srv.URL})
|
||||
if _, err := c.transcribeAudio(context.Background(), audioFile); err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// TestTranscribeAudio_TenantID verifies that STTTenantID is forwarded as a
|
||||
// multipart "tenant_id" field when set.
|
||||
func TestTranscribeAudio_TenantID(t *testing.T) {
|
||||
audioFile := writeTempAudio(t, "fake-ogg-bytes")
|
||||
defer os.Remove(audioFile)
|
||||
|
||||
const wantTenant = "acme-corp"
|
||||
var gotTenant string
|
||||
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if err := r.ParseMultipartForm(1 << 20); err == nil {
|
||||
gotTenant = r.FormValue("tenant_id")
|
||||
}
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
json.NewEncoder(w).Encode(sttResponse{Transcript: "ok"})
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
c := newChannelWithSTT(config.TelegramConfig{
|
||||
STTProxyURL: srv.URL,
|
||||
STTTenantID: wantTenant,
|
||||
})
|
||||
if _, err := c.transcribeAudio(context.Background(), audioFile); err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if gotTenant != wantTenant {
|
||||
t.Errorf("expected tenant_id %q, got %q", wantTenant, gotTenant)
|
||||
}
|
||||
}
|
||||
|
||||
// TestTranscribeAudio_NoTenantField verifies that when STTTenantID is empty, the
|
||||
// multipart form does NOT include a "tenant_id" field.
|
||||
func TestTranscribeAudio_NoTenantField(t *testing.T) {
|
||||
audioFile := writeTempAudio(t, "fake-ogg-bytes")
|
||||
defer os.Remove(audioFile)
|
||||
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if err := r.ParseMultipartForm(1 << 20); err == nil {
|
||||
if tid := r.FormValue("tenant_id"); tid != "" {
|
||||
t.Errorf("expected no tenant_id field, got %q", tid)
|
||||
}
|
||||
}
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
json.NewEncoder(w).Encode(sttResponse{Transcript: "ok"})
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
c := newChannelWithSTT(config.TelegramConfig{STTProxyURL: srv.URL})
|
||||
if _, err := c.transcribeAudio(context.Background(), audioFile); err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// TestTranscribeAudio_UpstreamError verifies that a non-200 response is surfaced
|
||||
// as an error (not silently swallowed).
|
||||
func TestTranscribeAudio_UpstreamError(t *testing.T) {
|
||||
audioFile := writeTempAudio(t, "fake-ogg-bytes")
|
||||
defer os.Remove(audioFile)
|
||||
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
http.Error(w, "service unavailable", http.StatusServiceUnavailable)
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
c := newChannelWithSTT(config.TelegramConfig{STTProxyURL: srv.URL})
|
||||
_, err := c.transcribeAudio(context.Background(), audioFile)
|
||||
if err == nil {
|
||||
t.Fatal("expected error for non-200 response, got nil")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "503") {
|
||||
t.Errorf("expected error to mention status 503, got: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// TestTranscribeAudio_InvalidJSON verifies that a 200 response with malformed
|
||||
// JSON is returned as an error.
|
||||
func TestTranscribeAudio_InvalidJSON(t *testing.T) {
|
||||
audioFile := writeTempAudio(t, "fake-ogg-bytes")
|
||||
defer os.Remove(audioFile)
|
||||
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.Write([]byte(`not-json`))
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
c := newChannelWithSTT(config.TelegramConfig{STTProxyURL: srv.URL})
|
||||
_, err := c.transcribeAudio(context.Background(), audioFile)
|
||||
if err == nil {
|
||||
t.Fatal("expected error for invalid JSON, got nil")
|
||||
}
|
||||
}
|
||||
|
||||
// TestTranscribeAudio_EmptyTranscript verifies that a 200 response with an empty
|
||||
// transcript field returns ("", nil) — not an error.
|
||||
func TestTranscribeAudio_EmptyTranscript(t *testing.T) {
|
||||
audioFile := writeTempAudio(t, "fake-ogg-bytes")
|
||||
defer os.Remove(audioFile)
|
||||
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
json.NewEncoder(w).Encode(sttResponse{Transcript: ""})
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
c := newChannelWithSTT(config.TelegramConfig{STTProxyURL: srv.URL})
|
||||
transcript, err := c.transcribeAudio(context.Background(), audioFile)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if transcript != "" {
|
||||
t.Errorf("expected empty transcript, got %q", transcript)
|
||||
}
|
||||
}
|
||||
|
||||
// TestTranscribeAudio_ContextCancelled verifies that a cancelled context causes
|
||||
// the HTTP call to fail fast.
|
||||
func TestTranscribeAudio_ContextCancelled(t *testing.T) {
|
||||
audioFile := writeTempAudio(t, "fake-ogg-bytes")
|
||||
defer os.Remove(audioFile)
|
||||
|
||||
// Server that blocks until the test is done — ensures the cancel fires first.
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
<-r.Context().Done()
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
cancel() // cancel immediately
|
||||
|
||||
c := newChannelWithSTT(config.TelegramConfig{STTProxyURL: srv.URL})
|
||||
_, err := c.transcribeAudio(ctx, audioFile)
|
||||
if err == nil {
|
||||
t.Fatal("expected error for cancelled context, got nil")
|
||||
}
|
||||
}
|
||||
@@ -23,6 +23,17 @@ type TelegramConfig struct {
|
||||
ReactionLevel string `json:"reaction_level,omitempty"` // "off" (default), "minimal", "full" — status emoji reactions
|
||||
MediaMaxBytes int64 `json:"media_max_bytes,omitempty"` // max media download size in bytes (default 20MB)
|
||||
LinkPreview *bool `json:"link_preview,omitempty"` // enable URL previews in messages (default true)
|
||||
|
||||
// Optional STT (Speech-to-Text) pipeline for voice/audio inbound messages.
|
||||
// When stt_proxy_url is set, audio/voice messages are transcribed before being forwarded to the agent.
|
||||
STTProxyURL string `json:"stt_proxy_url,omitempty"` // base URL of the STT proxy service (e.g. "https://stt.example.com")
|
||||
STTAPIKey string `json:"stt_api_key,omitempty"` // Bearer token for the STT proxy
|
||||
STTTenantID string `json:"stt_tenant_id,omitempty"` // optional tenant/org identifier forwarded to the STT proxy
|
||||
STTTimeoutSeconds int `json:"stt_timeout_seconds,omitempty"` // per-request timeout for STT calls (default 30s)
|
||||
|
||||
// Optional audio-aware routing: when set, voice/audio inbound messages are routed to this
|
||||
// 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")
|
||||
}
|
||||
|
||||
type DiscordConfig struct {
|
||||
|
||||
Reference in new issue
Block a user