atlas-iac/services/hermes/router/telegram_input.go
2026-08-16 15:59:09 -03:00

200 lines
6.0 KiB
Go

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
}