Skip to content

Commit c01463e

Browse files
committed
feat(telegram): add document/file message support
Users can now send documents/files to the bot via Telegram. Documents are downloaded to ~/.odek/media/ and injected as context for the agent (via OnDocumentMessage callback). Falls back to generated filename if the document has no name. Adds: DownloadDocument, OnDocumentMessage callback, handleMessage routing, telegram.go wiring. 3 new tests.
1 parent 8cf3ba4 commit c01463e

4 files changed

Lines changed: 199 additions & 1 deletion

File tree

cmd/odek/telegram.go

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -436,6 +436,21 @@ func telegramCmd(args []string) error {
436436
return "", nil
437437
}
438438

439+
handler.OnDocumentMessage = func(chatID int64, messageID int, fileID string, fileName string) (string, error) {
440+
localPath, err := telegram.DownloadDocument(bot, fileID, fileName)
441+
if err != nil {
442+
handlerLog.Warn("document download failed", "chat_id", chatID, "file_name", fileName, "error", err)
443+
go handleChatMessage(chatID, messageID,
444+
fmt.Sprintf("[document received — download failed: %v]", err),
445+
bot, handler, sessionManager, resolved, systemMessage, handlerLog)
446+
return "", nil
447+
}
448+
go handleChatMessage(chatID, messageID,
449+
fmt.Sprintf("📄 Document received and saved to %q. Use shell tools to analyze and respond.", localPath),
450+
bot, handler, sessionManager, resolved, systemMessage, handlerLog)
451+
return "", nil
452+
}
453+
439454
handler.OnError = func(chatID int64, err error) {
440455
handlerLog.Error("handler error", "chat_id", chatID, "error", err)
441456
}

internal/telegram/download.go

Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -121,6 +121,50 @@ func DownloadPhoto(bot *Bot, fileIDs []string) (string, error) {
121121
return localPath, nil
122122
}
123123

124+
// ── Document Download ─────────────────────────────────────────────────────
125+
126+
// DownloadDocument downloads a document/file from Telegram and saves it
127+
// to the media directory. Returns the local file path. The file preserves
128+
// the original filename from the Telegram Document metadata.
129+
func DownloadDocument(bot *Bot, fileID, fileName string) (string, error) {
130+
dir, err := MediaDir()
131+
if err != nil {
132+
return "", err
133+
}
134+
135+
// Get file metadata from Telegram.
136+
f, err := bot.GetFile(fileID)
137+
if err != nil {
138+
return "", fmt.Errorf("telegram document: get file: %w", err)
139+
}
140+
if f.FilePath == "" {
141+
return "", fmt.Errorf("telegram document: no file path returned")
142+
}
143+
144+
// Download raw bytes.
145+
data, err := bot.DownloadFile(f.FilePath)
146+
if err != nil {
147+
return "", fmt.Errorf("telegram document: download: %w", err)
148+
}
149+
150+
// Use original filename or generate one from file ID.
151+
safeName := fileName
152+
if safeName == "" {
153+
ext := filepath.Ext(f.FilePath)
154+
if ext == "" {
155+
ext = ".bin"
156+
}
157+
safeName = "doc_" + fileID[:min(16, len(fileID))] + ext
158+
}
159+
localPath := filepath.Join(dir, safeName)
160+
161+
if err := os.WriteFile(localPath, data, 0600); err != nil {
162+
return "", fmt.Errorf("telegram document: save: %w", err)
163+
}
164+
165+
return localPath, nil
166+
}
167+
124168
// ── Media Cleanup ──────────────────────────────────────────────────────────
125169

126170
// CleanupMedia removes media files older than maxAge from the downloaded

internal/telegram/handler.go

Lines changed: 34 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -58,6 +58,12 @@ type Handler struct {
5858
// Callers should use DownloadPhoto with the last element.
5959
OnPhotoMessage func(chatID int64, messageID int, fileIDs []string) (string, error)
6060

61+
// OnDocumentMessage is called when a document/file message is received.
62+
// Returns the response text (may be empty).
63+
// fileID is the Telegram file ID. Callers should use DownloadDocument
64+
// and pass the document's fileName to save the file locally.
65+
OnDocumentMessage func(chatID int64, messageID int, fileID string, fileName string) (string, error)
66+
6167
// OnError is called when a processing error occurs.
6268
OnError func(chatID int64, err error)
6369
}
@@ -98,6 +104,7 @@ func NewHandler(bot *Bot) *Handler {
98104
OnCommand: defaultCommandHandler(),
99105
OnVoiceMessage: defaultVoiceHandler(bot),
100106
OnPhotoMessage: defaultPhotoHandler(bot),
107+
OnDocumentMessage: defaultDocumentHandler(bot),
101108
}
102109
}
103110

@@ -155,6 +162,18 @@ func defaultPhotoHandler(bot *Bot) func(int64, int, []string) (string, error) {
155162
}
156163
}
157164

165+
// defaultDocumentHandler returns a default OnDocumentMessage callback that
166+
// downloads the document and returns a MEDIA: response.
167+
func defaultDocumentHandler(bot *Bot) func(int64, int, string, string) (string, error) {
168+
return func(chatID int64, _ int, fileID string, fileName string) (string, error) {
169+
path, err := DownloadDocument(bot, fileID, fileName)
170+
if err != nil {
171+
return "", fmt.Errorf("telegram handler: download document: %w", err)
172+
}
173+
return fmt.Sprintf("MEDIA:document:%s:%s", path, fileName), nil
174+
}
175+
}
176+
158177
// ─── Update Routing ───────────────────────────────────────────────────────
159178

160179
// HandleUpdate routes an incoming Telegram update to the appropriate handler.
@@ -233,7 +252,21 @@ func (h *Handler) handleMessage(msg *Message) {
233252
h.SendResponse(msg.Chat.ID, resp, msg.ID)
234253
}
235254
}
236-
case msg.Text != "":
255+
case msg.Document != nil:
256+
if h.OnDocumentMessage != nil {
257+
resp, err := h.OnDocumentMessage(msg.Chat.ID, msg.ID, msg.Document.FileID, msg.Document.FileName)
258+
if err != nil {
259+
h.log.Error("document message handler failed", "chat_id", msg.Chat.ID, "error", err)
260+
if h.OnError != nil {
261+
h.OnError(msg.Chat.ID, err)
262+
}
263+
return
264+
}
265+
if resp != "" {
266+
h.SendResponse(msg.Chat.ID, resp, msg.ID)
267+
}
268+
}
269+
case msg.Text != "":
237270
if h.OnTextMessage != nil {
238271
resp, err := h.OnTextMessage(msg.Chat.ID, msg.ID, msg.Text)
239272
if err != nil {
Lines changed: 106 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,106 @@
1+
package telegram
2+
3+
import (
4+
"fmt"
5+
"net/http"
6+
"net/http/httptest"
7+
"os"
8+
"path/filepath"
9+
"strings"
10+
"testing"
11+
)
12+
13+
// TestDownloadDocument_Success verifies document download with filename.
14+
func TestDownloadDocument_Success(t *testing.T) {
15+
ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
16+
if strings.Contains(r.URL.String(), "getFile") {
17+
fmt.Fprintf(w, `{"ok":true,"result":{"file_id":"doc1","file_path":"docs/report.pdf"}}`)
18+
return
19+
}
20+
w.Write([]byte("fake-pdf-data"))
21+
}))
22+
defer ts.Close()
23+
bot := testBot(t, ts)
24+
25+
path, err := DownloadDocument(bot, "doc1", "report.pdf")
26+
if err != nil {
27+
t.Fatalf("DownloadDocument: %v", err)
28+
}
29+
if !strings.Contains(path, "report.pdf") {
30+
t.Errorf("expected filename in path, got %q", path)
31+
}
32+
33+
data, err := os.ReadFile(path)
34+
if err != nil {
35+
t.Fatalf("read file: %v", err)
36+
}
37+
if string(data) != "fake-pdf-data" {
38+
t.Errorf("content = %q, want %q", string(data), "fake-pdf-data")
39+
}
40+
os.Remove(path)
41+
}
42+
43+
// TestDownloadDocument_NoFileName verifies fallback when filename is empty.
44+
func TestDownloadDocument_NoFileName(t *testing.T) {
45+
ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
46+
if strings.Contains(r.URL.String(), "getFile") {
47+
fmt.Fprintf(w, `{"ok":true,"result":{"file_id":"doc2","file_path":"docs/data"}}`)
48+
return
49+
}
50+
w.Write([]byte("binary-data"))
51+
}))
52+
defer ts.Close()
53+
bot := testBot(t, ts)
54+
55+
path, err := DownloadDocument(bot, "doc2", "")
56+
if err != nil {
57+
t.Fatalf("DownloadDocument: %v", err)
58+
}
59+
// Should have generated filename with fileID prefix
60+
if !strings.Contains(filepath.Base(path), "doc_") {
61+
t.Errorf("expected generated filename, got %q", path)
62+
}
63+
os.Remove(path)
64+
}
65+
66+
// TestHandleUpdate_Document verifies document message routing.
67+
func TestHandleUpdate_Document(t *testing.T) {
68+
var (
69+
capturedFileID string
70+
capturedFileName string
71+
)
72+
ts := testServer(t, nil)
73+
defer ts.Close()
74+
bot := testBot(t, ts)
75+
h := NewHandler(bot)
76+
77+
h.OnDocumentMessage = func(chatID int64, messageID int, fileID string, fileName string) (string, error) {
78+
capturedFileID = fileID
79+
capturedFileName = fileName
80+
return "document received", nil
81+
}
82+
83+
upd := Update{
84+
ID: 1,
85+
Message: &Message{
86+
ID: 55,
87+
Chat: &Chat{ID: 777},
88+
From: &User{ID: 888},
89+
Document: &Document{
90+
FileID: "doc_file_123",
91+
FileName: "report.pdf",
92+
MimeType: "application/pdf",
93+
FileSize: 1024,
94+
},
95+
},
96+
}
97+
98+
h.HandleUpdate(upd)
99+
100+
if capturedFileID != "doc_file_123" {
101+
t.Errorf("fileID = %q, want %q", capturedFileID, "doc_file_123")
102+
}
103+
if capturedFileName != "report.pdf" {
104+
t.Errorf("fileName = %q, want %q", capturedFileName, "report.pdf")
105+
}
106+
}

0 commit comments

Comments
 (0)