Skip to content
Open
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 docs/features/LOG-FEATS.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
2 changes: 1 addition & 1 deletion docs/features/OVERVIEW.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
18 changes: 15 additions & 3 deletions docs/features/feat-telegram-bot.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 `<workspace>/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: <path>\n\nUser message: <caption>` 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 `<workspace>/downloads/<type>/` 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: <abs-path>
File type: <photo|document|audio|voice|video>
File size: <bytes> bytes
Original name: <name> ← only when Telegram provides FileName
MIME type: <mime> ← only when Telegram provides MimeType

User message: <caption>
```

`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
Expand All @@ -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
Expand Down
98 changes: 65 additions & 33 deletions internal/bot/handlers.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
}
Expand Down
56 changes: 53 additions & 3 deletions internal/media/downloader.go
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import (
"net/http"
"os"
"path/filepath"
"strings"
"time"

"github.com/go-telegram/bot"
Expand All @@ -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:
Expand Down Expand Up @@ -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 {
Expand All @@ -90,4 +140,4 @@ func GenerateTimestampedFilename(dir, ext string) string {
}
name = fmt.Sprintf("%s_%d%s", base, i, ext)
}
}
}
Loading