feat: add ImageHandler and ImageService for image retrieval from channels

This commit is contained in:
2026-03-13 17:18:08 +03:30
parent 71a2c756c2
commit 7aab3f55ed
8 changed files with 189 additions and 12 deletions
+1
View File
@@ -21,6 +21,7 @@ func main() {
handlers.NewChannelHandler,
handlers.NewAuthHandler,
handlers.NewImageHandler,
rss.NewRSSGenerator,
rss.NewFeedCache,
+67
View File
@@ -0,0 +1,67 @@
package handlers
import (
"context"
"io"
"net/http"
"strconv"
"tgss/internal/config"
"tgss/internal/telegram"
"time"
"github.com/gin-gonic/gin"
"go.uber.org/zap"
)
type ImageHandler struct {
logger *zap.Logger
imageService *telegram.ImageService
}
func NewImageHandler(
imageService *telegram.ImageService,
logger *zap.Logger,
) *ImageHandler {
return &ImageHandler{
logger: logger,
imageService: imageService,
}
}
func (ih *ImageHandler) GetImage(c *gin.Context) {
ctx, cancel := context.WithTimeout(context.Background(), config.AuthStatusTimeout)
defer cancel()
channelId := c.Param("channelId")
if channelId == "" {
c.JSON(400, gin.H{"error": "invalid channel ID or message ID"})
return
}
msgIdStr := c.Param("messageId")
msgId, err := strconv.Atoi(msgIdStr)
if err != nil {
ih.logger.Error("message Id is invalid", zap.Error(err))
c.JSON(400, gin.H{"error": "message Id is invalid"})
return
}
reader, err := ih.imageService.GetChannelsMessageImageById(ctx, channelId, msgId)
if err != nil {
ih.logger.Error("failed to get image", zap.Error(err))
c.JSON(500, gin.H{"error": err.Error()})
return
}
defer reader.(io.ReadCloser).Close()
if closer, ok := reader.(io.ReadCloser); ok {
defer closer.Close()
}
extraHeaders := map[string]string{
"Cache-Control": "public, max-age=31536000, immutable",
"Expires": time.Now().Add(1 * 365 * 24 * time.Hour).Format(http.TimeFormat),
}
c.DataFromReader(http.StatusOK, -1, "image/jpeg", reader, extraHeaders)
}
+2
View File
@@ -123,3 +123,5 @@ func (r *RSSGenerator) messageToItem(msg tg.MessageClass, channelId string) (*RS
Guid: messageURL.String(),
}, nil
}
// TODO: add a hasPhoto helper to add the rss tag accordingly
+4 -2
View File
@@ -25,6 +25,7 @@ type RouterParams struct {
ChannelHandler *handlers.ChannelHandler
AuthHandler *handlers.AuthHandler
ImageHandler *handlers.ImageHandler
}
func NewGin() *gin.Engine {
@@ -47,12 +48,13 @@ func RegisterRoutes(params RouterParams) {
})
})
// params.GinEngine.GET("/feed/:id/json", params.ChannelHandler.GetMessagesJson)
params.GinEngine.GET("/feed/:id/json", params.ChannelHandler.GetMessagesJson)
params.GinEngine.GET("/feed/:id", params.RateLimiter.RateLimit(), params.ChannelHandler.GetMessagesRSS)
params.GinEngine.GET("/image/:channelId/:messageId", params.ImageHandler.GetImage)
// TODO: improve accurecy in rss channel fields
// TODO: add ui with templates under /setup with fetch
// TODO: image endpoint + hash and expiry
// TODO: image endpoint + HMAC
authStatCtx, cancel := context.WithTimeout(context.Background(), config.AuthStatusTimeout)
authStat, err := params.TgService.AuthStatus(authStatCtx)
cancel()
+1
View File
@@ -11,6 +11,7 @@ import (
"golang.org/x/time/rate"
)
// TODO: another limiter rate for images
type ClientLimiter struct {
limiter *rate.Limiter
lastSeen time.Time
+109
View File
@@ -0,0 +1,109 @@
package telegram
import (
"context"
"errors"
"io"
"github.com/gotd/td/telegram"
"github.com/gotd/td/telegram/downloader"
"github.com/gotd/td/tg"
"go.uber.org/zap"
)
type ImageService struct {
client *telegram.Client
logger *zap.Logger
downloader *downloader.Downloader
}
func NewImageService(client *telegram.Client, logger *zap.Logger) *ImageService {
return &ImageService{
client: client,
logger: logger,
downloader: downloader.NewDownloader(),
}
}
func (i *ImageService) GetChannelsMessageImageById(ctx context.Context, channelId string, messageId int) (io.Reader, error) {
api := i.client.API()
resolvedChats, err := api.ContactsResolveUsername(ctx, &tg.ContactsResolveUsernameRequest{
Username: channelId,
})
if err != nil {
return nil, err
}
if len(resolvedChats.Chats) == 0 {
return nil, errors.New("channel not found")
}
channel, ok := resolvedChats.Chats[0].(*tg.Channel)
if !ok {
return nil, errors.New("resolved peer is not a channel")
}
messages, err := api.ChannelsGetMessages(ctx, &tg.ChannelsGetMessagesRequest{
Channel: channel.AsInput(),
ID: []tg.InputMessageClass{
&tg.InputMessageID{ID: messageId},
},
})
if err != nil {
return nil, errors.New("unable to fetch messages from channel")
}
msgs, ok := messages.(*tg.MessagesChannelMessages)
if !ok {
return nil, errors.New("messages fetched do not represent a channel message")
}
// Count will always be 1, because we are giving one ID and even if its not found, an empty message is returned
message, ok := msgs.Messages[0].(*tg.Message)
if !ok {
return nil, errors.New("no message found")
}
media, ok := message.Media.(*tg.MessageMediaPhoto)
if !ok {
return nil, errors.New("the message does not contain a photo media object")
}
photo, ok := media.Photo.(*tg.Photo)
if !ok {
return nil, errors.New("the message media has no photo payload")
}
var largestSize *tg.PhotoSize
for _, size := range photo.Sizes {
if ps, ok := size.(*tg.PhotoSize); ok {
if largestSize == nil || ps.Size > largestSize.Size {
largestSize = size.(*tg.PhotoSize)
}
}
}
if largestSize == nil {
return nil, errors.New("no valid photo size found")
}
photoLocation := &tg.InputPhotoFileLocation{
ID: photo.ID,
AccessHash: photo.AccessHash,
FileReference: photo.FileReference,
ThumbSize: largestSize.Type,
}
pr, pw := io.Pipe()
go func() {
defer pw.Close()
_, err := i.downloader.Download(api, photoLocation).Stream(ctx, pw)
if err != nil {
pw.CloseWithError(errors.New("unable to download photo"))
}
}()
return pr, nil
}
+1
View File
@@ -90,6 +90,7 @@ var Module = fx.Options(
fx.Provide(
NewTelegramClient,
NewService,
NewImageService,
),
fx.Invoke(RunClient),
)
+4 -10
View File
@@ -127,16 +127,10 @@ func (s *Service) LastMessages(ctx context.Context, username string, limit int)
return nil, err
}
var msgs []tg.MessageClass
switch h := history.(type) {
case *tg.MessagesMessages:
msgs = h.Messages
case *tg.MessagesMessagesSlice:
msgs = h.Messages
case *tg.MessagesChannelMessages:
msgs = h.Messages
msgs, ok := history.(*tg.MessagesChannelMessages)
if !ok {
return nil, errors.New("resolved messages are not from a channel")
}
return msgs, nil
return msgs.Messages, nil
}