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:
maemreyo authored and GitHub committed 2026-02-28 11:54:33 +07:00
1 parent 4c67dff24d
commit eacc481998
6 files changed
+675 -30

No files matched your search

+65 -28
View File
@@ -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,
})
+12 -2
View File
@@ -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>")
}
+150
View File
@@ -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, "&lt;script&gt;") {
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)
}
}
+133
View File
@@ -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
}
+304
View File
@@ -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")
}
}
+11
View File
@@ -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 {