2026-08-15 21:48:30 -03:00
package main
import (
"context"
"encoding/base64"
"encoding/json"
"errors"
"fmt"
"io"
"net/http"
"net/url"
"os"
"path"
"strings"
2026-08-17 14:39:21 +00:00
"sync"
2026-08-15 21:48:30 -03:00
"time"
)
const telegramInputImageLimit = 20 << 20
type telegramInputMedia struct {
MIME string
Path string
}
2026-08-17 14:39:21 +00:00
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
}
}
2026-08-15 21:48:30 -03:00
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
}
2026-08-16 15:59:09 -03:00
func streamTelegramImageRequest ( writer io . Writer , prompt string , media telegramInputMedia , topic activeTelegramTopic ) error {
2026-08-15 21:48:30 -03:00
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
}
2026-08-16 15:59:09 -03:00
conversationJSON , err := json . Marshal ( topic . Conversation )
if err != nil {
return err
}
_ , err = fmt . Fprintf ( writer , ` "}]}],"conversation":%s,"store":true,"truncation":"auto"} ` , conversationJSON )
2026-08-15 21:48:30 -03:00
return err
}
2026-08-16 15:59:09 -03:00
func ( bot * telegramBot ) askTenantImage ( slot int , prompt string , media telegramInputMedia , updateID int64 , topic activeTelegramTopic ) ( string , error ) {
2026-08-15 21:48:30 -03:00
reader , writer := io . Pipe ( )
writeDone := make ( chan error , 1 )
go func ( ) {
2026-08-16 15:59:09 -03:00
err := streamTelegramImageRequest ( writer , prompt , media , topic )
2026-08-15 21:48:30 -03:00
_ = writer . CloseWithError ( err )
writeDone <- err
} ( )
2026-08-16 15:59:09 -03:00
answer , requestErr := bot . askTenantRequest ( slot , reader , updateID , topic )
2026-08-15 21:48:30 -03:00
_ = reader . Close ( )
writeErr := <- writeDone
if requestErr != nil {
return "" , requestErr
}
if writeErr != nil {
return "" , errors . New ( "encode Telegram image" )
}
return answer , nil
}