atlas-iac/services/hermes/router/telegram_input.go
2026-08-17 14:39:21 +00:00

288 lines
8.8 KiB
Go

package main
import (
"context"
"encoding/base64"
"encoding/json"
"errors"
"fmt"
"io"
"net/http"
"net/url"
"os"
"path"
"strings"
"sync"
"time"
)
const telegramInputImageLimit = 20 << 20
type telegramInputMedia struct {
MIME string
Path string
}
func commandParts(text string) (string, []string) {
fields := strings.Fields(strings.TrimSpace(text))
if len(fields) == 0 || !strings.HasPrefix(fields[0], "/") {
return "", nil
}
command := strings.TrimPrefix(strings.ToLower(fields[0]), "/")
command, _, _ = strings.Cut(command, "@")
return command, fields[1:]
}
const inboundTelegramEditNotice = "Transport note: this Telegram photo is available for visual analysis on this turn, but it is not stored as a generated-image artifact. The image_edit_latest tools can reuse only an image Hermes generated. Do not claim an exact edit of this inbound photo; explain the limitation and offer either a new image inspired by it or an edit of the latest Hermes-generated image."
func looksLikeImageEdit(text string) bool {
lower := strings.ToLower(strings.Join(strings.Fields(text), " "))
for _, phrase := range []string{
"edit this", "edit the image", "edit the photo", "change this image",
"change this photo", "modify this", "retouch", "turn this into",
"turn this cat", "turn this dog", "remove from the image",
"replace in the image", "edit it", "change it", "modify it", "crop it",
"resize it", "upscale it",
} {
if strings.Contains(lower, phrase) {
return true
}
}
hasTarget := false
for _, target := range []string{"image", "photo", "picture", "portrait", "selfie", "screenshot"} {
if strings.Contains(lower, target) {
hasTarget = true
break
}
}
if hasTarget {
for _, action := range []string{
"add ", "blur", "brighten", "change", "color", "convert", "crop",
"edit", "make ", "modify", "remove", "replace", "resize", "sharpen",
"transform", "turn ", "upscale",
} {
if strings.Contains(lower, action) {
return true
}
}
}
return false
}
func telegramInputPrompt(text string, hasPhoto bool) string {
if hasPhoto {
return inboundTelegramEditNotice + "\n\nUser request: " + text
}
if !looksLikeImageEdit(text) {
return text
}
return "Transport note: no image is attached to this Telegram message. Use image_edit_latest only if Hermes previously generated an image in this private tenant. Otherwise do not claim access to an earlier inbound Telegram photo; offer a new image or ask the user to use Hermes WebUI with the source attachment.\n\nUser request: " + text
}
func (bot *telegramBot) startTelegramActivity(chatID int64) func() {
done := make(chan struct{})
stopped := make(chan struct{})
ctx, cancel := context.WithCancel(context.Background())
every := bot.activityEvery
if every <= 0 {
every = 4 * time.Second
}
go func() {
defer close(stopped)
ticker := time.NewTicker(every)
defer ticker.Stop()
for {
_ = bot.sendActionContext(ctx, chatID, "typing")
select {
case <-done:
return
case <-ticker.C:
}
}
}()
var once sync.Once
return func() {
once.Do(func() {
close(done)
cancel()
})
<-stopped
}
}
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
}