Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions cmd/chat_tools.go
Original file line number Diff line number Diff line change
Expand Up @@ -107,6 +107,7 @@ func optionalTools() []tool.Tool {
tool.DevEnvTool{},
tool.ProjectVerifyTool{},
tool.AppVerifyTool{},
tool.GenerateMediaTool{},
tool.DependencyAuditTool{},
tool.GitHubTool{},
&tool.PRGeneratorTool{},
Expand Down
110 changes: 109 additions & 1 deletion internal/daemon/telegram.go
Original file line number Diff line number Diff line change
Expand Up @@ -10,8 +10,12 @@ import (
"log/slog"
"net/http"
"net/url"
"os"
"path/filepath"
"strings"
"time"

"github.com/GrayCodeAI/hawk/internal/stt"
)

// TelegramGateway connects hawk to a Telegram bot.
Expand Down Expand Up @@ -46,6 +50,19 @@ type TelegramMessage struct {
From struct {
Username string `json:"username"`
} `json:"from"`
// Voice is set for Telegram voice notes; Audio for audio-file attachments.
// When either is present and an STT transcriber is installed, the audio is
// transcribed and the text is replaced with the transcript so the agent
// can answer it.
Voice *telegramAudio `json:"voice,omitempty"`
Audio *telegramAudio `json:"audio,omitempty"`
}

// telegramAudio describes a Telegram voice/audio attachment.
type telegramAudio struct {
FileID string `json:"file_id"`
Duration int `json:"duration"`
MimeType string `json:"mime_type"`
}

// NewTelegramGateway creates a gateway with the given bot token. The authorizer
Expand Down Expand Up @@ -182,8 +199,22 @@ func (tg *TelegramGateway) handleMessage(ctx context.Context, msg *TelegramMessa
return
}

// Transcribe voice/audio attachments when an STT backend is installed.
prompt := msg.Text
if audio := msg.Voice; audio != nil {
prompt = tg.transcribeAudio(ctx, msg.Chat.ID, audio)
if prompt == "" {
return // error already replied
}
} else if audio := msg.Audio; audio != nil {
prompt = tg.transcribeAudio(ctx, msg.Chat.ID, audio)
if prompt == "" {
return
}
}

// Forward to hawk daemon
response, err := tg.forwardToHawk(ctx, msg.Text)
response, err := tg.forwardToHawk(ctx, prompt)
if err != nil {
response = fmt.Sprintf("Error: %v", err)
}
Expand All @@ -196,6 +227,83 @@ func (tg *TelegramGateway) handleMessage(ctx context.Context, msg *TelegramMessa
tg.reply(ctx, msg.Chat.ID, response)
}

// transcribeAudio downloads a Telegram voice/audio attachment, transcribes it
// via the installed STT engine, and returns the transcript prefixed with
// a marker. On any error it replies to the user with the problem and returns "".
func (tg *TelegramGateway) transcribeAudio(ctx context.Context, chatID int64, a *telegramAudio) string {
if !stt.Enabled() {
return ""
}

// Resolve file_id to a download URL via Telegram's bot API.
filePath, err := tg.getAudioFilePath(ctx, a.FileID)
if err != nil {
tg.reply(ctx, chatID, fmt.Sprintf("Audio resolve failed: %v", err))
return ""
}
downloadURL := fmt.Sprintf("https://api.telegram.org/file/bot%s/%s", tg.Token, filePath)

localPath, err := stt.DownloadAttachment(ctx, tg.client, downloadURL, "", safeFileName(a.FileID, a.MimeType))
if err != nil {
tg.reply(ctx, chatID, fmt.Sprintf("Audio download failed: %v", err))
return ""
}
defer func() {
// Clean up the temp dir containing the downloaded file.
_ = os.RemoveAll(filepath.Dir(localPath))
}()

text, err := stt.Transcribe(ctx, localPath, "")
if err != nil {
tg.reply(ctx, chatID, fmt.Sprintf("Transcription failed: %v", err))
return ""
}
return "[Voice transcript] " + text
}

// getAudioFilePath resolves a Telegram file_id to a file path on the bot API
// server so the message can be downloaded via the file URL.
func (tg *TelegramGateway) getAudioFilePath(ctx context.Context, fileID string) (string, error) {
req, err := http.NewRequestWithContext(ctx, http.MethodGet,
"https://api.telegram.org/bot"+tg.Token+"/getFile?file_id="+url.QueryEscape(fileID), nil)
if err != nil {
return "", err
}
resp, err := tg.client.Do(req)
if err != nil {
return "", err
}
defer func() { _ = resp.Body.Close() }()
body, err := io.ReadAll(io.LimitReader(resp.Body, 1<<16))
if err != nil {
return "", err
}
var result struct {
OK bool `json:"ok"`
Result struct {
FilePath string `json:"file_path"`
} `json:"result"`
}
if err := json.Unmarshal(body, &result); err != nil {
return "", err
}
if !result.OK {
return "", fmt.Errorf("telegram getFile returned not OK")
}
return result.Result.FilePath, nil
}

// safeFileName builds a human-readable filename for a downloaded Telegram audio
// file using the file_id prefix and the MIME-suggested extension.
func safeFileName(fileID, mimeType string) string {
ext := stt.ExtensionForMedia(fileID, mimeType)
prefix := fileID
if len(prefix) > 16 {
prefix = prefix[:16]
}
return prefix + ext
}

// reply sends text and logs (rather than swallows) any delivery failure.
func (tg *TelegramGateway) reply(ctx context.Context, chatID int64, text string) {
if err := tg.sendMessage(ctx, chatID, text); err != nil {
Expand Down
30 changes: 30 additions & 0 deletions internal/daemon/telegram_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -155,3 +155,33 @@ func jsonResp(body string) *http.Response {
Header: http.Header{"Content-Type": []string{"application/json"}},
}
}

func TestTelegramSafeFileName(t *testing.T) {
if got := safeFileName("AAabcdef0123456789xyz", "audio/ogg"); got != "AAabcdef01234567.ogg" {
t.Fatalf("safeFileName = %q", got)
}
if got := safeFileName("f", "audio/mpeg"); got != "f.mp3" {
t.Fatalf("safeFileName short id = %q", got)
}
}

func TestTelegramVoiceWithoutTranscriberFallsBackToText(t *testing.T) {
hawk := newIPv4TelegramServer(t, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
writeJSON(w, http.StatusOK, ChatResponse{Response: "hawk-reply"})
}))
defer hawk.Close()

// No STT transcriber installed: a voice message must fall back to the
// existing text path (transcribeAudio returns "" with no reply).
tg := newTelegramGatewayFromConfig(TelegramConfig{Token: "tok", AllowList: []string{"user"}}, hawk.URL, "k")
tg.handleMessage(context.Background(), &TelegramMessage{
Text: "hello",
Chat: struct {
ID int64 `json:"id"`
}{ID: 1},
From: struct {
Username string `json:"username"`
}{Username: "user"},
Voice: &telegramAudio{FileID: "fid", MimeType: "audio/ogg"},
})
}
1 change: 1 addition & 0 deletions internal/engine/safety/capabilities.go
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,7 @@ var toolPolicies = map[string]ToolPolicy{
"Diagnostics": {Name: "Diagnostics", Capabilities: []Capability{CapabilityFilesystemRead, CapabilityProcessExecute}, DefaultRisk: RiskMedium},
"ProjectVerify": {Name: "ProjectVerify", Capabilities: []Capability{CapabilityFilesystemRead, CapabilityProcessExecute}, DefaultRisk: RiskMedium},
"AppVerify": {Name: "AppVerify", Capabilities: []Capability{CapabilityFilesystemRead, CapabilityProcessExecute}, DefaultRisk: RiskMedium},
"GenerateMedia": {Name: "GenerateMedia", Capabilities: []Capability{CapabilityNetworkAccess, CapabilityFilesystemWrite}, DefaultRisk: RiskMedium},
"DependencyAudit": {Name: "DependencyAudit", Capabilities: []Capability{CapabilityFilesystemRead, CapabilityProcessExecute, CapabilityNetworkAccess}, DefaultRisk: RiskMedium},
"Git": {Name: "Git", Capabilities: []Capability{CapabilityProcessExecute}, DefaultRisk: RiskMedium},
"GitHub": {Name: "GitHub", Capabilities: []Capability{CapabilityNetworkAccess, CapabilityProcessExecute}, DefaultRisk: RiskMedium},
Expand Down
2 changes: 2 additions & 0 deletions internal/engine/safety/permission.go
Original file line number Diff line number Diff line change
Expand Up @@ -321,6 +321,8 @@ func canonicalToolName(name string) string {
return "ProjectVerify"
case "app_verify", "appverify", "verify_app":
return "AppVerify"
case "generate_media", "generatemedia", "media":
return "GenerateMedia"
case "dependency_audit", "dependencyaudit", "deps":
return "DependencyAudit"
case "git_history", "githistory", "git-history":
Expand Down
173 changes: 173 additions & 0 deletions internal/stt/stt.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,173 @@
// Package stt provides pluggable speech-to-text transcription for messaging
// gateways (e.g. Telegram voice notes), adopted from grok-cli's audio-input
// flow. A Transcriber backs the actual cloud STT call; the package handles the
// safe plumbing around it: downloading an attachment to a temp file with a
// path-traversal guard and mapping media types to extensions, so the same
// transcription pipeline works regardless of which STT backend is installed.
package stt

import (
"context"
"fmt"
"io"
"net/http"
"net/url"
"os"
"path/filepath"
"strings"
)

// Transcriber turns audio bytes into text. hawk does not bundle an STT
// provider; a host wires one in via SetTranscriber. A nil transcriber makes
// Transcription disabled and returns a clear error.
type Transcriber interface {
// Name identifies the STT backend for provenance/logging.
Name() string
// Transcribe converts audio data (already on disk at localPath) to text.
// language is optional ("", "en", etc.).
Transcribe(ctx context.Context, localPath, language string) (string, error)
}

var transcriber Transcriber

// SetTranscriber installs the speech-to-text backend.
func SetTranscriber(t Transcriber) { transcriber = t }

// Enabled reports whether an STT backend is installed.
func Enabled() bool { return transcriber != nil }

// TranscribeResult is the outcome of transcribing a downloaded attachment.
type TranscribeResult struct {
Text string
Language string
DurationMS int64
}

// DownloadAttachment downloads a Telegram (or generic) hosted file to a
// per-request temp directory and returns the local path. The token is used for
// Telegram's file endpoint; for other hosts set downloadToken to "".
//
// The returned path always lives inside the created temp dir, which defeats
// path-traversal attempts in the file path or name.
func DownloadAttachment(ctx context.Context, client *http.Client, downloadURL, downloadToken, suggestedName string) (string, error) {
if client == nil {
client = http.DefaultClient
}
u, err := url.Parse(downloadURL)
if err != nil {
return "", fmt.Errorf("stt: parse download url: %w", err)
}
if u.Scheme != "https" && u.Scheme != "http" {
return "", fmt.Errorf("stt: unsupported download scheme %q", u.Scheme)
}
req, err := http.NewRequestWithContext(ctx, http.MethodGet, downloadURL, nil)
if err != nil {
return "", fmt.Errorf("stt: build download request: %w", err)
}
if downloadToken != "" {
req.Header.Set("Authorization", "Bearer "+downloadToken)
}
resp, err := client.Do(req)
if err != nil {
return "", fmt.Errorf("stt: download: %w", err)
}
defer func() { _ = resp.Body.Close() }()
if resp.StatusCode != http.StatusOK {
return "", fmt.Errorf("stt: download status %d", resp.StatusCode)
}

// Create a dedicated temp dir and confine the file to it.
dir, err := os.MkdirTemp("", "hawk-stt-*")
if err != nil {
return "", fmt.Errorf("stt: create temp dir: %w", err)
}
name := safeFileName(suggestedName)
path := filepath.Join(dir, name)
if !isPathInside(dir, path) {
// Defensive: MkdirTemp guarantees this, but never trust a caller's name.
return "", fmt.Errorf("stt: suggested name escapes temp dir")
}
f, err := os.OpenFile(path, os.O_CREATE|os.O_WRONLY|os.O_TRUNC, 0o600)
if err != nil {
return "", fmt.Errorf("stt: create temp file: %w", err)
}
defer func() { _ = f.Close() }()
if _, err := io.Copy(f, io.LimitReader(resp.Body, 50<<20)); err != nil { // 50 MB cap
return "", fmt.Errorf("stt: write attachment: %w", err)
}
return path, nil
}

// Transcribe runs the installed transcriber over the audio file at localPath.
// It returns the transcript, or a clear error when no STT backend is installed.
func Transcribe(ctx context.Context, localPath, language string) (string, error) {
if transcriber == nil {
return "", fmt.Errorf("stt: no transcriber installed — configure a speech-to-text backend first")
}
return transcriber.Transcribe(ctx, localPath, language)
}

// isPathInside reports whether child is lexically inside parent after cleaning.
func isPathInside(parent, child string) bool {
rel, err := filepath.Rel(parent, child)
if err != nil {
return false
}
return rel != ".." && !strings.HasPrefix(rel, ".."+string(filepath.Separator))
}

// safeFileName strips path separators and control characters from a suggested
// attachment name so it can never traverse out of the temp dir.
func safeFileName(name string) string {
name = filepath.Base(strings.TrimSpace(name))
if name == "" || name == "." || name == ".." || name == string(filepath.Separator) {
return "attachment.ogg"
}
var b strings.Builder
for _, r := range name {
if r == '/' || r == '\\' || r < 0x20 || r == 0x7f {
continue
}
b.WriteRune(r)
}
cleaned := b.String()
if cleaned == "" {
return "attachment.ogg"
}
return cleaned
}

// ExtensionForMedia infers a file extension from a Telegram media path/name or
// MIME type. Telegram voice notes are .ogg (Opus); audio attachments vary.
func ExtensionForMedia(nameOrPath, mime string) string {
base := strings.ToLower(filepath.Base(nameOrPath))
switch {
case strings.HasSuffix(base, ".oga"), strings.HasSuffix(base, ".ogg"):
return ".ogg"
case strings.HasSuffix(base, ".mp3"):
return ".mp3"
case strings.HasSuffix(base, ".m4a"):
return ".m4a"
case strings.HasSuffix(base, ".wav"):
return ".wav"
case strings.HasSuffix(base, ".flac"):
return ".flac"
case strings.HasSuffix(base, ".aac"):
return ".aac"
}
switch strings.ToLower(mime) {
case "audio/ogg", "application/ogg", "audio/opus":
return ".ogg"
case "audio/mpeg":
return ".mp3"
case "audio/mp4", "audio/x-m4a":
return ".m4a"
case "audio/wav", "audio/x-wav":
return ".wav"
case "audio/flac":
return ".flac"
case "audio/aac":
return ".aac"
}
return ".audio"
}
Loading
Loading