diff --git a/docs/features/LOG-FEATS.md b/docs/features/LOG-FEATS.md index 67aadba..c847342 100644 --- a/docs/features/LOG-FEATS.md +++ b/docs/features/LOG-FEATS.md @@ -20,3 +20,4 @@ 2026-07-07-10-26 | Renamed project from `opencode-telegram` to `acolyte` (GitHub repo, local folder, Go module path, binary name, workspace dir, OpenCode agent name, install scripts, CI, embedded workspace templates, docs). Clean break, no data migration. 2026-07-07-10-26 | Added timestamped filenames for downloaded Telegram media - new `GenerateTimestampedFilename(dir, ext)` helper in `internal/media/downloader.go` produces UTC-stamped names (`20060102_150405.jpg`) with `_1`/`_2` collision suffixes; `GetFilePath` now takes an extension instead of a filename; removed obsolete `FilenameFromTelegramPath` helper and its test. 2026-07-07-12-11 | Added self-update feature - `acolyte update`/`version` subcommands with SHA256-verified GitHub Releases replacement of the running binary, optional in-process restart, ad-hoc codesign on macOS, and a non-blocking 5s startup outdated-check in `acolyte start` that drops a row into the notifications table. Also extends `.github/workflows/release.yml` to publish a `checksums.txt`. +2026-07-13-21-58 | Extended Telegram bot media support from photos-only to all five attachment types (photo, document, audio, voice, video). Added `media.MediaMetadata` struct, `media.ExtractFileRef` helper, and richer `media.BuildPrompt(path, meta, caption)` signature in `internal/media/downloader.go`. Refactored `internal/bot/handlers.go` DefaultHandler to use a private `processMediaAttachment` helper that handles all media types through one path. Stickers, contacts, and locations remain unsupported. Added 11 new unit tests covering the new helpers and prompt format. diff --git a/docs/features/OVERVIEW.md b/docs/features/OVERVIEW.md index 3572559..9f7c33a 100644 --- a/docs/features/OVERVIEW.md +++ b/docs/features/OVERVIEW.md @@ -4,7 +4,7 @@ Telegram bot gateway that allows users to interact with an OpenCode AI agent dir # Files -- feat-telegram-bot.md - Core Telegram bot with message handling, photo support (UTC-timestamped filenames), and user authentication +- feat-telegram-bot.md - Core Telegram bot with message handling, media support (photos, documents, audio, voice, video with UTC-timestamped filenames), and user authentication - feat-install-script.md - Installation script for easy binary deployment - feat-opencode-run-integration.md - OpenCode run command integration replacing HTTP server - feat-progress-updates.md - Progress updates for long-running operations diff --git a/docs/features/feat-telegram-bot.md b/docs/features/feat-telegram-bot.md index 7fd7057..16c1dd3 100644 --- a/docs/features/feat-telegram-bot.md +++ b/docs/features/feat-telegram-bot.md @@ -6,8 +6,20 @@ Core Telegram bot that enables users to interact with OpenCode AI agent from Tel - Implements Telegram Bot API using go-telegram/bot library - Handles text messages and forwards to OpenCode -- Accepts photo uploads: downloads them, stores them under `/downloads/images/` with UTC-timestamped filenames (e.g. `20240706_143052.jpg`, with `_1`/`_2` collision suffixes for same-second duplicates), and forwards a `File located at: \n\nUser message: ` prompt to OpenCode -- Documents, audio, voice, video, stickers, contacts, and locations are ignored (only photos are wired in) +- Accepts media attachments: photos, documents, audio files, voice messages, and videos. Each is downloaded from Telegram, saved under `/downloads//` with UTC-timestamped filenames (e.g. `20240706_143052.pdf`, with `_1`/`_2` collision suffixes for same-second duplicates), and forwarded to OpenCode with a metadata-rich prompt: + + ``` + File located at: + File type: + File size: bytes + Original name: ← only when Telegram provides FileName + MIME type: ← only when Telegram provides MimeType + + User message: + ``` + + `Original name` and `MIME type` lines are omitted when Telegram does not provide them (common for photos and voice messages). The trailing `User message:` line is always present, even when the caption is empty. +- Stickers, contacts, and locations are still ignored. - Sender-side media (the bot sending photos/documents back to Telegram) is NOT implemented; all replies are plain text - Implements slash commands: /set-agent, /set-model, /set-provider, /workspace, /help, /new-session - Restricts access to the configured allowed user @@ -22,7 +34,7 @@ Core Telegram bot that enables users to interact with OpenCode AI agent from Tel - cmd/new.go - Workspace initialization - cmd/logs.go - Log viewing command - internal/bot/client.go - Bot initialization -- internal/bot/handlers.go - Message handlers (text + photo) +- internal/bot/handlers.go - Message handlers (text + media) - internal/bot/commands.go - Slash command implementations - internal/config/config.go - Configuration loading - internal/opencode/runner.go - OpenCode CLI runner diff --git a/internal/bot/handlers.go b/internal/bot/handlers.go index 358d810..77d60e9 100644 --- a/internal/bot/handlers.go +++ b/internal/bot/handlers.go @@ -33,6 +33,67 @@ func SetConfig(c *config.Config) { } } +func mediaKindLabel(msg *models.Message) string { + switch { + case msg.Photo != nil: + return "Photo" + case msg.Document != nil: + return "Document" + case msg.Voice != nil: + return "Voice" + case msg.Audio != nil: + return "Audio" + case msg.Video != nil: + return "Video" + default: + return "Media" + } +} + +func processMediaAttachment(ctx context.Context, b *bot.Bot, msg *models.Message, chatID int64, workspace string, userID int64) (string, bool) { + fileID, fileSize, mimeType, originalName, mediaType, ext, ok := media.ExtractFileRef(msg) + if !ok || fileID == "" { + return "", false + } + + kind := mediaKindLabel(msg) + + data, _, err := media.DownloadFile(ctx, b, fileID) + if err != nil { + logger.Log(logger.ERROR, userID, fmt.Sprintf("Failed to download %s: %v", kind, err)) + b.SendMessage(ctx, &bot.SendMessageParams{ + ChatID: chatID, + Text: fmt.Sprintf("Error: failed to download %s: %v", kind, err), + }) + return "", true + } + + localPath := media.GetFilePath(workspace, mediaType, ext) + if err := media.SaveFile(localPath, data); err != nil { + logger.Log(logger.ERROR, userID, fmt.Sprintf("Failed to save %s: %v", kind, err)) + b.SendMessage(ctx, &bot.SendMessageParams{ + ChatID: chatID, + Text: fmt.Sprintf("Error: failed to save %s: %v", kind, err), + }) + return "", true + } + + caption := strings.TrimSpace(msg.Caption) + meta := media.MediaMetadata{ + Kind: kind, + FileSize: fileSize, + MimeType: mimeType, + OriginalName: originalName, + } + prompt := media.BuildPrompt(localPath, meta, caption) + + logger.Log(logger.INPUT, userID, fmt.Sprintf("%s received: fileID=%s size=%d saved=%s", + kind, fileID, fileSize, localPath)) + log.Printf("Downloaded %s for user %d to %s (%d bytes)", kind, userID, localPath, len(data)) + + return prompt, false +} + func DefaultHandler(ctx context.Context, b *bot.Bot, update *models.Update) { if update.Message == nil { return @@ -67,44 +128,15 @@ func DefaultHandler(ctx context.Context, b *bot.Bot, update *models.Update) { chatID := update.Message.Chat.ID workspace := cfg.Workspace.Path - var userMessage string - if len(update.Message.Photo) > 0 { - largest := update.Message.Photo[len(update.Message.Photo)-1] - if largest.FileID != "" { - mediaType, ext, _ := media.GetMediaType(update.Message) - data, _, err := media.DownloadFile(ctx, b, largest.FileID) - if err != nil { - logger.Log(logger.ERROR, userID, fmt.Sprintf("Failed to download photo: %v", err)) - b.SendMessage(ctx, &bot.SendMessageParams{ - ChatID: chatID, - Text: fmt.Sprintf("Error: failed to download photo: %v", err), - }) - return - } - localPath := media.GetFilePath(workspace, mediaType, ext) - if err := media.SaveFile(localPath, data); err != nil { - logger.Log(logger.ERROR, userID, fmt.Sprintf("Failed to save photo: %v", err)) - b.SendMessage(ctx, &bot.SendMessageParams{ - ChatID: chatID, - Text: fmt.Sprintf("Error: failed to save photo: %v", err), - }) - return - } - caption := strings.TrimSpace(update.Message.Text) - if caption == "" { - caption = strings.TrimSpace(update.Message.Caption) - } - userMessage = media.BuildPrompt(localPath, caption) - logger.Log(logger.INPUT, userID, fmt.Sprintf("Photo received: fileID=%s size=%d saved=%s", - largest.FileID, largest.FileSize, localPath)) - log.Printf("Downloaded photo for user %d to %s (%d bytes)", userID, localPath, len(data)) - } + userMessage, mediaHandled := processMediaAttachment(ctx, b, update.Message, chatID, workspace, userID) + if mediaHandled && userMessage == "" { + return } if userMessage == "" { userMessage = update.Message.Text } - if len(update.Message.Photo) == 0 && strings.TrimSpace(update.Message.Text) == "" { + if strings.TrimSpace(userMessage) == "" { logger.Log(logger.DEBUG, userID, "Received empty or whitespace-only message, skipping") return } diff --git a/internal/media/downloader.go b/internal/media/downloader.go index c44268e..1aed9e8 100644 --- a/internal/media/downloader.go +++ b/internal/media/downloader.go @@ -7,6 +7,7 @@ import ( "net/http" "os" "path/filepath" + "strings" "time" "github.com/go-telegram/bot" @@ -23,6 +24,13 @@ const ( MediaTypeVideo MediaType = "videos" ) +type MediaMetadata struct { + Kind string + FileSize int64 + MimeType string + OriginalName string +} + func GetMediaType(message *models.Message) (MediaType, string, error) { switch { case message.Photo != nil: @@ -77,8 +85,50 @@ func SaveFile(path string, data []byte) error { return os.WriteFile(path, data, 0644) } -func BuildPrompt(mediaPath string, userMessage string) string { - return fmt.Sprintf("File located at: %s\n\nUser message: %s", mediaPath, userMessage) +func BuildPrompt(mediaPath string, meta MediaMetadata, userMessage string) string { + var b strings.Builder + b.WriteString("File located at: ") + b.WriteString(mediaPath) + b.WriteByte('\n') + b.WriteString("File type: ") + b.WriteString(meta.Kind) + b.WriteByte('\n') + fmt.Fprintf(&b, "File size: %d bytes\n", meta.FileSize) + if meta.OriginalName != "" { + b.WriteString("Original name: ") + b.WriteString(meta.OriginalName) + b.WriteByte('\n') + } + if meta.MimeType != "" { + b.WriteString("MIME type: ") + b.WriteString(meta.MimeType) + b.WriteByte('\n') + } + b.WriteString("\nUser message: ") + b.WriteString(userMessage) + return b.String() +} + +func ExtractFileRef(message *models.Message) (fileID string, fileSize int64, mimeType string, originalName string, mediaType MediaType, ext string, ok bool) { + switch { + case len(message.Photo) > 0: + largest := message.Photo[len(message.Photo)-1] + return largest.FileID, int64(largest.FileSize), "", "", MediaTypePhoto, ".jpg", true + case message.Audio != nil: + return message.Audio.FileID, message.Audio.FileSize, message.Audio.MimeType, message.Audio.FileName, MediaTypeAudio, ".mp3", true + case message.Voice != nil: + return message.Voice.FileID, message.Voice.FileSize, message.Voice.MimeType, "", MediaTypeVoice, ".ogg", true + case message.Document != nil: + ext := ".bin" + if message.Document.FileName != "" { + ext = filepath.Ext(message.Document.FileName) + } + return message.Document.FileID, message.Document.FileSize, message.Document.MimeType, message.Document.FileName, MediaTypeDocument, ext, true + case message.Video != nil: + return message.Video.FileID, message.Video.FileSize, message.Video.MimeType, message.Video.FileName, MediaTypeVideo, ".mp4", true + default: + return "", 0, "", "", "", "", false + } } func GenerateTimestampedFilename(dir, ext string) string { @@ -90,4 +140,4 @@ func GenerateTimestampedFilename(dir, ext string) string { } name = fmt.Sprintf("%s_%d%s", base, i, ext) } -} +} \ No newline at end of file diff --git a/internal/media/downloader_test.go b/internal/media/downloader_test.go index 3c6d4a4..ee684f0 100644 --- a/internal/media/downloader_test.go +++ b/internal/media/downloader_test.go @@ -162,17 +162,266 @@ func TestGenerateTimestampedFilename(t *testing.T) { } func TestBuildPrompt(t *testing.T) { - got := BuildPrompt("/tmp/x.jpg", "hi") - want := "File located at: /tmp/x.jpg\n\nUser message: hi" + got := BuildPrompt("/tmp/x.jpg", MediaMetadata{}, "hi") + want := "File located at: /tmp/x.jpg\nFile type: \nFile size: 0 bytes\n\nUser message: hi" if got != want { t.Errorf("BuildPrompt = %q, want %q", got, want) } } func TestBuildPrompt_EmptyCaption(t *testing.T) { - got := BuildPrompt("/tmp/x.jpg", "") - want := "File located at: /tmp/x.jpg\n\nUser message: " + got := BuildPrompt("/tmp/x.jpg", MediaMetadata{}, "") + want := "File located at: /tmp/x.jpg\nFile type: \nFile size: 0 bytes\n\nUser message: " if got != want { t.Errorf("BuildPrompt = %q, want %q", got, want) } } + +func TestBuildPrompt_WithMetadata(t *testing.T) { + meta := MediaMetadata{ + Kind: "document", + FileSize: 2345678, + MimeType: "application/pdf", + OriginalName: "report.pdf", + } + got := BuildPrompt("/tmp/report.pdf", meta, "hi") + want := "File located at: /tmp/report.pdf\n" + + "File type: document\n" + + "File size: 2345678 bytes\n" + + "Original name: report.pdf\n" + + "MIME type: application/pdf\n" + + "\n" + + "User message: hi" + if got != want { + t.Errorf("BuildPrompt = %q, want %q", got, want) + } +} + +func TestBuildPrompt_PartialMetadata(t *testing.T) { + meta := MediaMetadata{Kind: "photo", FileSize: 12345} + got := BuildPrompt("/tmp/x.jpg", meta, "look") + if !strings.Contains(got, "File located at: /tmp/x.jpg") { + t.Errorf("missing 'File located at' line in:\n%s", got) + } + if !strings.Contains(got, "File type: photo") { + t.Errorf("missing 'File type' line in:\n%s", got) + } + if !strings.Contains(got, "File size: 12345 bytes") { + t.Errorf("missing 'File size' line in:\n%s", got) + } + if strings.Contains(got, "Original name:") { + t.Errorf("'Original name' line should be absent when OriginalName is empty:\n%s", got) + } + if strings.Contains(got, "MIME type:") { + t.Errorf("'MIME type' line should be absent when MimeType is empty:\n%s", got) + } + if !strings.HasSuffix(got, "User message: look") { + t.Errorf("expected trailing 'User message: look', got suffix in:\n%s", got) + } +} + +func TestBuildPrompt_ZeroFileSize(t *testing.T) { + meta := MediaMetadata{Kind: "photo", FileSize: 0} + got := BuildPrompt("/tmp/x.jpg", meta, "x") + if !strings.Contains(got, "File size: 0 bytes") { + t.Errorf("expected 'File size: 0 bytes' line, got:\n%s", got) + } +} + +func TestExtractFileRef_Photo(t *testing.T) { + msg := &models.Message{ + Photo: []models.PhotoSize{ + {FileID: "small", Width: 100, Height: 100, FileSize: 1000}, + {FileID: "large", Width: 800, Height: 600, FileSize: 5000}, + }, + } + fileID, fileSize, mimeType, originalName, mediaType, ext, ok := ExtractFileRef(msg) + if !ok { + t.Fatal("expected ok=true for photo message") + } + if fileID != "large" { + t.Errorf("fileID = %q, want %q (largest)", fileID, "large") + } + if fileSize != 5000 { + t.Errorf("fileSize = %d, want %d", fileSize, 5000) + } + if mimeType != "" { + t.Errorf("mimeType = %q, want empty for photo", mimeType) + } + if originalName != "" { + t.Errorf("originalName = %q, want empty for photo", originalName) + } + if mediaType != MediaTypePhoto { + t.Errorf("mediaType = %q, want %q", mediaType, MediaTypePhoto) + } + if ext != ".jpg" { + t.Errorf("ext = %q, want %q", ext, ".jpg") + } +} + +func TestExtractFileRef_Audio(t *testing.T) { + msg := &models.Message{ + Audio: &models.Audio{ + FileID: "aud", + FileSize: 1000, + MimeType: "audio/mpeg", + FileName: "song.mp3", + }, + } + fileID, fileSize, mimeType, originalName, mediaType, ext, ok := ExtractFileRef(msg) + if !ok { + t.Fatal("expected ok=true for audio message") + } + if fileID != "aud" { + t.Errorf("fileID = %q, want %q", fileID, "aud") + } + if fileSize != 1000 { + t.Errorf("fileSize = %d, want %d", fileSize, 1000) + } + if mimeType != "audio/mpeg" { + t.Errorf("mimeType = %q, want %q", mimeType, "audio/mpeg") + } + if originalName != "song.mp3" { + t.Errorf("originalName = %q, want %q", originalName, "song.mp3") + } + if mediaType != MediaTypeAudio { + t.Errorf("mediaType = %q, want %q", mediaType, MediaTypeAudio) + } + if ext != ".mp3" { + t.Errorf("ext = %q, want %q", ext, ".mp3") + } +} + +func TestExtractFileRef_Voice(t *testing.T) { + msg := &models.Message{ + Voice: &models.Voice{ + FileID: "vox", + FileSize: 500, + MimeType: "audio/ogg", + }, + } + fileID, fileSize, mimeType, originalName, mediaType, ext, ok := ExtractFileRef(msg) + if !ok { + t.Fatal("expected ok=true for voice message") + } + if fileID != "vox" { + t.Errorf("fileID = %q, want %q", fileID, "vox") + } + if fileSize != 500 { + t.Errorf("fileSize = %d, want %d", fileSize, 500) + } + if mimeType != "audio/ogg" { + t.Errorf("mimeType = %q, want %q", mimeType, "audio/ogg") + } + if originalName != "" { + t.Errorf("originalName = %q, want empty for voice (no FileName field)", originalName) + } + if mediaType != MediaTypeVoice { + t.Errorf("mediaType = %q, want %q", mediaType, MediaTypeVoice) + } + if ext != ".ogg" { + t.Errorf("ext = %q, want %q", ext, ".ogg") + } +} + +func TestExtractFileRef_Document(t *testing.T) { + msg := &models.Message{ + Document: &models.Document{ + FileID: "doc", + FileSize: 2000, + MimeType: "application/pdf", + FileName: "report.pdf", + }, + } + fileID, fileSize, mimeType, originalName, mediaType, ext, ok := ExtractFileRef(msg) + if !ok { + t.Fatal("expected ok=true for document message") + } + if fileID != "doc" { + t.Errorf("fileID = %q, want %q", fileID, "doc") + } + if fileSize != 2000 { + t.Errorf("fileSize = %d, want %d", fileSize, 2000) + } + if mimeType != "application/pdf" { + t.Errorf("mimeType = %q, want %q", mimeType, "application/pdf") + } + if originalName != "report.pdf" { + t.Errorf("originalName = %q, want %q", originalName, "report.pdf") + } + if mediaType != MediaTypeDocument { + t.Errorf("mediaType = %q, want %q", mediaType, MediaTypeDocument) + } + if ext != ".pdf" { + t.Errorf("ext = %q, want %q", ext, ".pdf") + } +} + +func TestExtractFileRef_DocumentNoName(t *testing.T) { + msg := &models.Message{ + Document: &models.Document{ + FileID: "doc", + }, + } + fileID, _, _, originalName, mediaType, ext, ok := ExtractFileRef(msg) + if !ok { + t.Fatal("expected ok=true for document message") + } + if fileID != "doc" { + t.Errorf("fileID = %q, want %q", fileID, "doc") + } + if originalName != "" { + t.Errorf("originalName = %q, want empty", originalName) + } + if mediaType != MediaTypeDocument { + t.Errorf("mediaType = %q, want %q", mediaType, MediaTypeDocument) + } + if ext != ".bin" { + t.Errorf("ext = %q, want %q", ext, ".bin") + } +} + +func TestExtractFileRef_Video(t *testing.T) { + msg := &models.Message{ + Video: &models.Video{ + FileID: "vid", + FileSize: 5000000, + MimeType: "video/mp4", + FileName: "clip.mp4", + }, + } + fileID, fileSize, mimeType, originalName, mediaType, ext, ok := ExtractFileRef(msg) + if !ok { + t.Fatal("expected ok=true for video message") + } + if fileID != "vid" { + t.Errorf("fileID = %q, want %q", fileID, "vid") + } + if fileSize != 5000000 { + t.Errorf("fileSize = %d, want %d", fileSize, 5000000) + } + if mimeType != "video/mp4" { + t.Errorf("mimeType = %q, want %q", mimeType, "video/mp4") + } + if originalName != "clip.mp4" { + t.Errorf("originalName = %q, want %q", originalName, "clip.mp4") + } + if mediaType != MediaTypeVideo { + t.Errorf("mediaType = %q, want %q", mediaType, MediaTypeVideo) + } + if ext != ".mp4" { + t.Errorf("ext = %q, want %q", ext, ".mp4") + } +} + +func TestExtractFileRef_NoMedia(t *testing.T) { + msg := &models.Message{} + fileID, fileSize, mimeType, originalName, mediaType, ext, ok := ExtractFileRef(msg) + if ok { + t.Fatal("expected ok=false for empty message") + } + if fileID != "" || fileSize != 0 || mimeType != "" || originalName != "" || mediaType != "" || ext != "" { + t.Errorf("expected all zero values, got fileID=%q fileSize=%d mimeType=%q originalName=%q mediaType=%q ext=%q", + fileID, fileSize, mimeType, originalName, mediaType, ext) + } +} \ No newline at end of file