all repos

rss-tools @ 4f31c23166ead37fa81152dd4de985cfde107dfb

get rss feed from sources that(i need and) dont provide one
4 files changed, 248 insertions(+), 17 deletions(-)
telegram: support images
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 &lt;world&gt;</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
        +}