200 lines
6.0 KiB
Go
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
|
|
}
|