package main import ( "context" "encoding/base64" "encoding/json" "errors" "fmt" "io" "net/http" "net/url" "os" "path" "strings" "time" ) const telegramInputImageLimit = 20 << 20 type telegramInputMedia struct { MIME string Path string } func largestTelegramPhoto(photos []telegramPhotoSize) (telegramPhotoSize, error) { var selected telegramPhotoSize for _, photo := range photos { if strings.TrimSpace(photo.FileID) == "" { continue } if selected.FileID == "" || int64(photo.Width)*int64(photo.Height) > int64(selected.Width)*int64(selected.Height) { selected = photo } } if selected.FileID == "" { return telegramPhotoSize{}, errors.New("Telegram photo has no file identifier") } if selected.FileSize > telegramInputImageLimit { return telegramPhotoSize{}, errors.New("Telegram photo exceeds input limit") } return selected, nil } func safeTelegramFilePath(raw string) (string, error) { value := strings.TrimSpace(raw) if value == "" || strings.Contains(value, `\`) || strings.ContainsRune(value, '\x00') { return "", errors.New("invalid Telegram file path") } cleaned := path.Clean(value) if cleaned == "." || path.IsAbs(cleaned) || cleaned == ".." || strings.HasPrefix(cleaned, "../") { return "", errors.New("invalid Telegram file path") } return cleaned, nil } func escapedTelegramFilePath(value string) string { components := strings.Split(value, "/") for index := range components { components[index] = url.PathEscape(components[index]) } return strings.Join(components, "/") } func supportedTelegramImageType(header []byte) (string, error) { detected := http.DetectContentType(header) switch detected { case "image/jpeg", "image/png", "image/webp", "image/gif": return detected, nil default: return "", errors.New("unsupported Telegram image type") } } func (bot *telegramBot) fetchTelegramPhoto(photos []telegramPhotoSize) (telegramInputMedia, error) { selected, err := largestTelegramPhoto(photos) if err != nil { return telegramInputMedia{}, err } ctx, cancel := context.WithTimeout(context.Background(), 65*time.Second) defer cancel() var remote struct { FilePath string `json:"file_path"` FileSize int64 `json:"file_size"` } if err := bot.call(ctx, "getFile", url.Values{"file_id": {selected.FileID}}, &remote); err != nil { return telegramInputMedia{}, errors.New("Telegram file lookup failed") } if remote.FileSize > telegramInputImageLimit { return telegramInputMedia{}, errors.New("Telegram photo exceeds input limit") } remotePath, err := safeTelegramFilePath(remote.FilePath) if err != nil { return telegramInputMedia{}, err } request, err := http.NewRequestWithContext(ctx, http.MethodGet, strings.TrimRight(bot.fileBase, "/")+"/"+escapedTelegramFilePath(remotePath), nil) if err != nil { return telegramInputMedia{}, errors.New("create Telegram file request") } response, err := bot.client.Do(request) if err != nil { return telegramInputMedia{}, errors.New("Telegram file download failed") } defer response.Body.Close() if response.StatusCode != http.StatusOK { return telegramInputMedia{}, errors.New("Telegram file download rejected") } if response.ContentLength > telegramInputImageLimit { return telegramInputMedia{}, errors.New("Telegram photo exceeds input limit") } temporary, err := os.CreateTemp("", "hermes-telegram-input-*") if err != nil { return telegramInputMedia{}, errors.New("stage Telegram image") } temporaryPath := temporary.Name() keep := false defer func() { _ = temporary.Close() if !keep { _ = os.Remove(temporaryPath) } }() written, err := io.Copy(temporary, io.LimitReader(response.Body, telegramInputImageLimit+1)) if err != nil { return telegramInputMedia{}, errors.New("read Telegram image") } if written > telegramInputImageLimit { return telegramInputMedia{}, errors.New("Telegram photo exceeds input limit") } if _, err := temporary.Seek(0, io.SeekStart); err != nil { return telegramInputMedia{}, errors.New("inspect Telegram image") } header := make([]byte, 512) read, readErr := temporary.Read(header) if readErr != nil && readErr != io.EOF { return telegramInputMedia{}, errors.New("inspect Telegram image") } mimeType, err := supportedTelegramImageType(header[:read]) if err != nil { return telegramInputMedia{}, err } if err := temporary.Close(); err != nil { return telegramInputMedia{}, errors.New("stage Telegram image") } keep = true return telegramInputMedia{MIME: mimeType, Path: temporaryPath}, nil } func streamTelegramImageRequest(writer io.Writer, prompt string, media telegramInputMedia, topic activeTelegramTopic) error { promptJSON, err := json.Marshal(prompt) if err != nil { return err } if _, err := fmt.Fprintf(writer, `{"input":[{"role":"user","content":[{"type":"input_text","text":%s},{"type":"input_image","image_url":"data:%s;base64,`, promptJSON, media.MIME); err != nil { return err } file, err := os.Open(media.Path) if err != nil { return err } encoder := base64.NewEncoder(base64.StdEncoding, writer) _, copyErr := io.Copy(encoder, file) closeEncoderErr := encoder.Close() closeFileErr := file.Close() if copyErr != nil { return copyErr } if closeEncoderErr != nil { return closeEncoderErr } if closeFileErr != nil { return closeFileErr } conversationJSON, err := json.Marshal(topic.Conversation) if err != nil { return err } _, err = fmt.Fprintf(writer, `"}]}],"conversation":%s,"store":true,"truncation":"auto"}`, conversationJSON) return err } func (bot *telegramBot) askTenantImage(slot int, prompt string, media telegramInputMedia, updateID int64, topic activeTelegramTopic) (string, error) { reader, writer := io.Pipe() writeDone := make(chan error, 1) go func() { err := streamTelegramImageRequest(writer, prompt, media, topic) _ = writer.CloseWithError(err) writeDone <- err }() answer, requestErr := bot.askTenantRequest(slot, reader, updateID, topic) _ = reader.Close() writeErr := <-writeDone if requestErr != nil { return "", requestErr } if writeErr != nil { return "", errors.New("encode Telegram image") } return answer, nil }