diff --git a/internal/channels/telegram/handlers.go b/internal/channels/telegram/handlers.go index c21e0c1c..381d8e3d 100644 --- a/internal/channels/telegram/handlers.go +++ b/internal/channels/telegram/handlers.go @@ -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, }) diff --git a/internal/channels/telegram/media.go b/internal/channels/telegram/media.go index c50cdc57..07935f2f 100644 --- a/internal/channels/telegram/media.go +++ b/internal/channels/telegram/media.go @@ -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 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, "") case "audio": - tags = append(tags, "") + if m.Transcript != "" { + tags = append(tags, fmt.Sprintf("\n%s", html.EscapeString(m.Transcript))) + } else { + tags = append(tags, "") + } case "voice": - tags = append(tags, "") + if m.Transcript != "" { + tags = append(tags, fmt.Sprintf("\n%s", html.EscapeString(m.Transcript))) + } else { + tags = append(tags, "") + } case "document": tags = append(tags, "") } diff --git a/internal/channels/telegram/media_test.go b/internal/channels/telegram/media_test.go new file mode 100644 index 00000000..f568db34 --- /dev/null +++ b/internal/channels/telegram/media_test.go @@ -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: "", + }, + { + name: "video", + items: []MediaInfo{{Type: "video"}}, + want: "", + }, + { + name: "animation", + items: []MediaInfo{{Type: "animation"}}, + want: "", + }, + { + name: "audio without transcript", + items: []MediaInfo{{Type: "audio"}}, + want: "", + }, + { + name: "voice without transcript", + items: []MediaInfo{{Type: "voice"}}, + want: "", + }, + { + name: "document", + items: []MediaInfo{{Type: "document"}}, + want: "", + }, + { + 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 sub-block. +func TestBuildMediaTags_VoiceWithTranscript(t *testing.T) { + items := []MediaInfo{{Type: "voice", Transcript: "xin chào"}} + got := buildMediaTags(items) + + if !strings.HasPrefix(got, "") { + t.Errorf("expected output to start with , got: %q", got) + } + if !strings.Contains(got, "") { + t.Errorf("expected 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, "") { + t.Errorf("expected closing , 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, "") { + t.Errorf("expected output to start with , got: %q", got) + } + if !strings.Contains(got, "hello world") { + 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: ``}} + got := buildMediaTags(items) + + // Raw angle brackets must NOT appear inside the transcript block. + if strings.Contains(got, "