4 files changed,
248 insertions(+),
17 deletions(-)
Author:
Oleksandr Smirnov
olexsmir@gmail.com
Committed at:
2026-04-22 20:13:38 +0300
Authored at:
2026-04-22 19:43:42 +0300
Change ID:
zkuwytwtyrrvkrplmrszslnyvxprwrxn
Parent:
704cdb2
M
sources/telegram/sdk.go
··· 3 3 import ( 4 4 "bytes" 5 5 "context" 6 + "encoding/base64" 6 7 "encoding/json" 7 8 "fmt" 9 + "io" 8 10 "net/http" 9 11 "net/url" 10 12 "strconv" 13 + "strings" 11 14 ) 12 15 13 -const apiBase = "https://api.telegram.org" 16 +const ( 17 + apiBase = "https://api.telegram.org" 18 + maxPhotoBytes = 20 << 20 19 +) 14 20 15 21 type TelegramSDK struct { 16 22 client *http.Client ··· 46 52 } 47 53 48 54 type Message struct { 49 - MessageID int64 `json:"message_id"` 50 - From *User `json:"from"` 51 - Chat *Chat `json:"chat"` 52 - Text string `json:"text"` 53 - Date int64 `json:"date"` 55 + MessageID int64 `json:"message_id"` 56 + From *User `json:"from"` 57 + Chat *Chat `json:"chat"` 58 + Text string `json:"text"` 59 + Date int64 `json:"date"` 60 + Photo []PhotoSize `json:"photo,omitempty"` 61 + PhotoBase64 string `json:"photo_base64,omitempty"` 62 + PhotoMIMEType string `json:"photo_mime_type,omitempty"` 63 +} 64 + 65 +type PhotoSize struct { 66 + FileID string `json:"file_id"` 67 + Width int `json:"width"` 68 + Height int `json:"height"` 69 + FileSize int64 `json:"file_size"` 54 70 } 55 71 56 72 func (t *TelegramSDK) GetUpdates(ctx context.Context, offset int64) ([]Update, error) { ··· 62 78 if err := t.req(ctx, "getUpdates", params, nil, &resp); err != nil { 63 79 return nil, err 64 80 } 81 + 82 + for i := range resp.Result { 83 + msg := resp.Result[i].Message 84 + if msg == nil || len(msg.Photo) == 0 { 85 + continue 86 + } 87 + data, mimeType, err := t.downloadLargestPhoto(ctx, msg.Photo) 88 + if err != nil { 89 + return nil, err 90 + } 91 + msg.PhotoBase64 = base64.StdEncoding.EncodeToString(data) 92 + msg.PhotoMIMEType = mimeType 93 + } 65 94 return resp.Result, nil 66 95 } 67 96 ··· 83 112 MessageID: messageID, 84 113 Reaction: []messageReactionReq{{Type: "emoji", Emoji: emoji}}, 85 114 }, &resp) 115 +} 116 + 117 +type tgFile struct { 118 + FilePath string `json:"file_path"` 119 +} 120 + 121 +func (t *TelegramSDK) downloadLargestPhoto(ctx context.Context, photos []PhotoSize) ([]byte, string, error) { 122 + if len(photos) == 0 { 123 + return nil, "", nil 124 + } 125 + 126 + filePath, err := t.getFilePath(ctx, photos[len(photos)-1].FileID) 127 + if err != nil { 128 + return nil, "", err 129 + } 130 + 131 + fileURL := fmt.Sprintf("%s/file/bot%s/%s", apiBase, t.token, filePath) 132 + req, err := http.NewRequestWithContext(ctx, http.MethodGet, fileURL, nil) 133 + if err != nil { 134 + return nil, "", err 135 + } 136 + 137 + res, err := t.client.Do(req) 138 + if err != nil { 139 + return nil, "", err 140 + } 141 + defer res.Body.Close() 142 + if res.StatusCode != http.StatusOK { 143 + return nil, "", fmt.Errorf("photo download failed with status %d", res.StatusCode) 144 + } 145 + 146 + data, err := io.ReadAll(io.LimitReader(res.Body, maxPhotoBytes+1)) 147 + if err != nil { 148 + return nil, "", err 149 + } 150 + if len(data) > maxPhotoBytes { 151 + return nil, "", fmt.Errorf("photo too large: %d bytes", len(data)) 152 + } 153 + 154 + mimeType := http.DetectContentType(data) 155 + if !strings.HasPrefix(mimeType, "image/") { 156 + mimeType = "image/jpeg" 157 + } 158 + return data, mimeType, nil 159 +} 160 + 161 +func (t *TelegramSDK) getFilePath(ctx context.Context, fileID string) (string, error) { 162 + params := url.Values{} 163 + params.Set("file_id", fileID) 164 + 165 + var resp Response[tgFile] 166 + if err := t.req(ctx, "getFile", params, nil, &resp); err != nil { 167 + return "", err 168 + } 169 + return resp.Result.FilePath, nil 86 170 } 87 171 88 172 func (t *TelegramSDK) req(ctx context.Context, method string, params url.Values, body any, out any) error {
M
sources/telegram/sdk_test.go
··· 1 1 package telegram 2 + 3 +import ( 4 + "context" 5 + "encoding/base64" 6 + "io" 7 + "net/http" 8 + "strings" 9 + "testing" 10 + 11 + "olexsmir.xyz/x/is" 12 +) 13 + 14 +type roundTripFunc func(*http.Request) (*http.Response, error) 15 + 16 +func (f roundTripFunc) RoundTrip(r *http.Request) (*http.Response, error) { 17 + return f(r) 18 +} 19 + 20 +func TestGetUpdatesHydratesPhotoBase64(t *testing.T) { 21 + const token = "TEST_TOKEN" 22 + seenGetFileForID := "" 23 + pngData := []byte{0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a, 0x01} 24 + 25 + client := &http.Client{Transport: roundTripFunc(func(r *http.Request) (*http.Response, error) { 26 + switch { 27 + case strings.Contains(r.URL.Path, "/getUpdates"): 28 + return jsonResponse(`{ 29 + "ok": true, 30 + "result": [{ 31 + "update_id": 1, 32 + "message": { 33 + "message_id": 7, 34 + "date": 1713790000, 35 + "text": "photo msg", 36 + "photo": [ 37 + {"file_id": "small", "width": 90, "height": 90, "file_size": 100}, 38 + {"file_id": "large", "width": 1280, "height": 720, "file_size": 2048} 39 + ] 40 + } 41 + }] 42 + }`) 43 + case strings.Contains(r.URL.Path, "/getFile"): 44 + seenGetFileForID = r.URL.Query().Get("file_id") 45 + return jsonResponse(`{"ok": true, "result": {"file_path": "photos/large.png"}}`) 46 + case strings.Contains(r.URL.Path, "/file/bot"+token+"/photos/large.png"): 47 + return byteResponse(pngData), nil 48 + default: 49 + t.Fatalf("unexpected request URL: %s", r.URL.String()) 50 + return nil, nil 51 + } 52 + })} 53 + 54 + sdk := NewSDK(client, token) 55 + updates, err := sdk.GetUpdates(context.Background(), 0) 56 + is.Err(t, err, nil) 57 + is.Equal(t, 1, len(updates)) 58 + 59 + msg := updates[0].Message 60 + is.Equal(t, "large", seenGetFileForID) 61 + is.Equal(t, base64.StdEncoding.EncodeToString(pngData), msg.PhotoBase64) 62 + is.Equal(t, "image/png", msg.PhotoMIMEType) 63 +} 64 + 65 +func jsonResponse(body string) (*http.Response, error) { 66 + return &http.Response{ 67 + StatusCode: http.StatusOK, 68 + Body: io.NopCloser(strings.NewReader(body)), 69 + Header: make(http.Header), 70 + }, nil 71 +} 72 + 73 +func byteResponse(data []byte) *http.Response { 74 + return &http.Response{ 75 + StatusCode: http.StatusOK, 76 + Body: io.NopCloser(strings.NewReader(string(data))), 77 + Header: make(http.Header), 78 + } 79 +}
M
sources/telegram/telegram.go
··· 6 6 "encoding/binary" 7 7 "encoding/gob" 8 8 "fmt" 9 + "html" 9 10 "log/slog" 10 11 "net/http" 12 + "strings" 11 13 "time" 12 14 13 15 "olexsmir.xyz/rss-tools/app" ··· 57 59 58 60 feed := app.NewFeed("Telegram feed", "telegram-feed") 59 61 for _, m := range messages { 60 - title := m.Text 61 - if len(title) > 64 { 62 - title = title[:64] + "..." 63 - } 64 - feed.Add(app.FeedEntry{ 65 - Title: title, 66 - ID: fmt.Sprintf("telegram-%d", m.MessageID), 67 - Content: m.Text, 68 - Updated: time.Unix(m.Date, 0), 69 - }) 62 + feed.Add(feedEntryFromMessage(m)) 70 63 } 71 64 72 65 w.WriteHeader(http.StatusOK) ··· 96 89 slog.InfoContext(ctx, "message from", "user_id", u.Message.From.ID, "username", u.Message.From.Username, "msg", u.Message.Text) 97 90 } 98 91 99 - if u.Message == nil && u.Message.From == nil || u.Message.From.ID != t.allowedID { 92 + if u.Message == nil || u.Message.From == nil || u.Message.From.ID != t.allowedID { 100 93 offset = u.UpdateID + 1 101 94 continue 102 95 } ··· 157 150 }) 158 151 return messages, err 159 152 } 153 + 154 +func feedEntryFromMessage(m *Message) app.FeedEntry { 155 + updated := time.Unix(m.Date, 0) 156 + if m.PhotoBase64 == "" { 157 + title := m.Text 158 + if len(title) > 64 { 159 + title = title[:64] + "..." 160 + } 161 + return app.FeedEntry{ 162 + Title: title, 163 + ID: fmt.Sprintf("telegram-%d", m.MessageID), 164 + Content: m.Text, 165 + Updated: updated, 166 + } 167 + } 168 + 169 + parts := make([]string, 0, 2) 170 + if text := strings.TrimSpace(m.Text); text != "" { 171 + parts = append(parts, "<p>"+html.EscapeString(text)+"</p>") 172 + } 173 + mimeType := m.PhotoMIMEType 174 + if mimeType == "" { 175 + mimeType = "image/jpeg" 176 + } 177 + parts = append(parts, fmt.Sprintf(`<p><img src="data:%s;base64,%s" alt="telegram image"/></p>`, mimeType, m.PhotoBase64)) 178 + 179 + return app.FeedEntry{ 180 + Title: fmt.Sprintf("🖼️ [%s]", updated.Format("2006-01-02")), 181 + ID: fmt.Sprintf("telegram-%d", m.MessageID), 182 + Content: strings.Join(parts, ""), 183 + ContentType: "html", 184 + Updated: updated, 185 + } 186 +}
A
sources/telegram/telegram_test.go
··· 1 +package telegram 2 + 3 +import ( 4 + "strings" 5 + "testing" 6 + "time" 7 + 8 + "olexsmir.xyz/x/is" 9 +) 10 + 11 +func TestFeedEntryFromMessageWithImage(t *testing.T) { 12 + msg := &Message{ 13 + MessageID: 42, 14 + Text: "hello <world>", 15 + Date: time.Date(2026, 4, 22, 19, 38, 0, 0, time.UTC).Unix(), 16 + PhotoBase64: "YWJj", 17 + PhotoMIMEType: "image/png", 18 + } 19 + 20 + entry := feedEntryFromMessage(msg) 21 + is.Equal(t, "🖼️ [2026-04-22]", entry.Title) 22 + is.Equal(t, "html", entry.ContentType) 23 + if !strings.Contains(entry.Content, "<p>hello <world></p>") { 24 + t.Fatalf("expected escaped text in image entry: %s", entry.Content) 25 + } 26 + if !strings.Contains(entry.Content, `src="data:image/png;base64,YWJj"`) { 27 + t.Fatalf("expected image data URI in image entry: %s", entry.Content) 28 + } 29 +} 30 + 31 +func TestFeedEntryFromMessageTextOnly(t *testing.T) { 32 + msg := &Message{ 33 + MessageID: 11, 34 + Text: "plain text", 35 + Date: time.Date(2026, 4, 22, 19, 38, 0, 0, time.UTC).Unix(), 36 + } 37 + 38 + entry := feedEntryFromMessage(msg) 39 + is.Equal(t, "plain text", entry.Title) 40 + is.Equal(t, "", entry.ContentType) 41 + is.Equal(t, "plain text", entry.Content) 42 +}