2026-08-15 00:34:08 -03:00
package main
import (
"context"
"encoding/json"
"errors"
"fmt"
"io"
"mime"
"mime/multipart"
"net/http"
"net/url"
"os"
"path/filepath"
"regexp"
"strconv"
"strings"
"time"
"unicode/utf8"
)
const (
telegramCaptionLimit = 1000
tenantMediaLimit = 50 << 20
)
2026-08-17 14:39:21 +00:00
var (
telegramMediaMarker = regexp . MustCompile ( ` (?i)MEDIA:\s*([^\s\)\]\}"']+) ` )
telegramMediaLabel = regexp . MustCompile ( ` (?i)MEDIA: ` )
telegramPrivatePath = regexp . MustCompile ( ` (?i)(?:/opt/data/(?:cache/images|workspace)|/workspace)/[^\s\)\]\}"']+ ` )
)
2026-08-15 00:34:08 -03:00
type telegramReply struct {
Text string
MediaPaths [ ] string
}
type tenantMedia struct {
Name string
MIME string
Path string
}
func newTenantMediaClient ( ) * http . Client {
return & http . Client { Timeout : 70 * time . Second }
}
func parseTelegramReply ( text string ) telegramReply {
matches := telegramMediaMarker . FindAllStringSubmatch ( text , - 1 )
paths := make ( [ ] string , 0 , len ( matches ) )
for _ , match := range matches {
if len ( match ) == 2 {
paths = append ( paths , match [ 1 ] )
}
}
cleaned := telegramMediaMarker . ReplaceAllString ( text , "" )
2026-08-17 14:39:21 +00:00
cleaned = telegramPrivatePath . ReplaceAllString ( cleaned , "[private attachment]" )
cleaned = telegramMediaLabel . ReplaceAllString ( cleaned , "" )
2026-08-15 00:34:08 -03:00
lines := strings . Split ( cleaned , "\n" )
compact := make ( [ ] string , 0 , len ( lines ) )
blank := false
for _ , line := range lines {
line = strings . TrimRight ( line , " \t" )
if strings . TrimSpace ( line ) == "" {
if len ( compact ) > 0 && ! blank {
compact = append ( compact , "" )
blank = true
}
continue
}
compact = append ( compact , line )
blank = false
}
return telegramReply {
Text : strings . TrimSpace ( strings . Join ( compact , "\n" ) ) ,
MediaPaths : paths ,
}
}
func hasParentTraversal ( path string ) bool {
for _ , component := range strings . Split ( filepath . ToSlash ( path ) , "/" ) {
if component == ".." {
return true
}
}
return false
}
func pathWithin ( path , root string ) bool {
relative , err := filepath . Rel ( root , path )
return err == nil && relative != ".." && ! strings . HasPrefix ( relative , ".." + string ( filepath . Separator ) )
}
func normalizeTenantMediaPath ( raw string ) ( string , error ) {
path := strings . TrimSpace ( raw )
if path == "" || strings . ContainsRune ( path , '\x00' ) || strings . Contains ( path , ` \ ` ) || hasParentTraversal ( path ) {
return "" , errors . New ( "invalid media path" )
}
if path == "/workspace" || strings . HasPrefix ( path , "/workspace/" ) {
path = "/opt/data/workspace" + strings . TrimPrefix ( path , "/workspace" )
}
if ! filepath . IsAbs ( path ) {
return "" , errors . New ( "media path must be absolute" )
}
path = filepath . Clean ( path )
for _ , root := range [ ] string { "/opt/data/cache/images" , "/opt/data/workspace" } {
if pathWithin ( path , root ) {
return path , nil
}
}
return "" , errors . New ( "media path is outside the tenant media roots" )
}
func ( bot * telegramBot ) tenantMediaRequest ( ctx context . Context , slot int , path string ) ( * http . Response , error ) {
if bot . router == nil || bot . router . backendMediaURL == nil {
return nil , errors . New ( "tenant media API unavailable" )
}
endpoint := bot . router . backendMediaURL ( slot ) + "/media?" + url . Values {
"path" : { path } ,
} . Encode ( )
request , err := http . NewRequestWithContext ( ctx , http . MethodGet , endpoint , nil )
if err != nil {
return nil , errors . New ( "create tenant media request" )
}
request . Header . Set ( "Authorization" , "Bearer " + bot . config . RelayKey )
request . Header . Set ( "X-Hermes-Tenant-Slot" , strconv . Itoa ( slot ) )
return bot . mediaClient . Do ( request )
}
func ( bot * telegramBot ) fetchTenantMedia ( ctx context . Context , slot int , path string ) ( tenantMedia , error ) {
if bot . mediaClient == nil {
bot . mediaClient = newTenantMediaClient ( )
}
response , err := bot . tenantMediaRequest ( ctx , slot , path )
if err != nil {
return tenantMedia { } , errors . New ( "tenant media unavailable" )
}
defer response . Body . Close ( )
if response . StatusCode != http . StatusOK {
return tenantMedia { } , errors . New ( "tenant media rejected" )
}
temporary , err := os . CreateTemp ( "" , "hermes-telegram-media-*" )
if err != nil {
return tenantMedia { } , errors . New ( "stage tenant media" )
}
temporaryPath := temporary . Name ( )
keep := false
defer func ( ) {
_ = temporary . Close ( )
if ! keep {
_ = os . Remove ( temporaryPath )
}
} ( )
written , err := io . Copy ( temporary , io . LimitReader ( response . Body , tenantMediaLimit + 1 ) )
if err != nil {
return tenantMedia { } , errors . New ( "read tenant media" )
}
if written > tenantMediaLimit {
return tenantMedia { } , errors . New ( "tenant media exceeds upload limit" )
}
declaredType , _ , _ := mime . ParseMediaType ( response . Header . Get ( "Content-Type" ) )
if _ , err := temporary . Seek ( 0 , io . SeekStart ) ; err != nil {
return tenantMedia { } , errors . New ( "inspect tenant media" )
}
header := make ( [ ] byte , 512 )
read , readErr := temporary . Read ( header )
if readErr != nil && readErr != io . EOF {
return tenantMedia { } , errors . New ( "inspect tenant media" )
}
mediaType := http . DetectContentType ( header [ : read ] )
if mediaType == "application/octet-stream" && declaredType != "" {
mediaType = declaredType
}
if err := temporary . Close ( ) ; err != nil {
return tenantMedia { } , errors . New ( "stage tenant media" )
}
keep = true
return tenantMedia { Name : filepath . Base ( path ) , MIME : mediaType , Path : temporaryPath } , nil
}
func ( bot * telegramBot ) callMultipart (
ctx context . Context ,
method string ,
fields map [ string ] string ,
fileField string ,
media tenantMedia ,
) error {
reader , writer := io . Pipe ( )
multipartWriter := multipart . NewWriter ( writer )
request , err := http . NewRequestWithContext ( ctx , http . MethodPost , bot . apiBase + "/" + method , reader )
if err != nil {
reader . Close ( )
writer . Close ( )
return errors . New ( "create Telegram request" )
}
request . Header . Set ( "Content-Type" , multipartWriter . FormDataContentType ( ) )
writeDone := make ( chan error , 1 )
go func ( ) {
var writeErr error
mediaFile , openErr := os . Open ( media . Path )
if openErr != nil {
writeErr = openErr
} else {
defer mediaFile . Close ( )
}
for key , value := range fields {
if writeErr == nil {
writeErr = multipartWriter . WriteField ( key , value )
}
}
if writeErr == nil {
var part io . Writer
part , writeErr = multipartWriter . CreateFormFile ( fileField , media . Name )
if writeErr == nil {
_ , writeErr = io . Copy ( part , mediaFile )
}
}
if closeErr := multipartWriter . Close ( ) ; writeErr == nil {
writeErr = closeErr
}
if writeErr != nil {
_ = writer . CloseWithError ( writeErr )
} else {
_ = writer . Close ( )
}
writeDone <- writeErr
} ( )
response , err := bot . client . Do ( request )
if err != nil {
_ = reader . Close ( )
<- writeDone
return errors . New ( "Telegram API unavailable" )
}
defer response . Body . Close ( )
if err := <- writeDone ; err != nil {
return errors . New ( "encode Telegram upload" )
}
body , err := io . ReadAll ( io . LimitReader ( response . Body , 2 << 20 ) )
if err != nil {
return errors . New ( "read Telegram response" )
}
var envelope struct {
OK bool ` json:"ok" `
Description string ` json:"description" `
ErrorCode int ` json:"error_code" `
}
if err := json . Unmarshal ( body , & envelope ) ; err != nil {
return fmt . Errorf ( "Telegram API returned undecodable status %d" , response . StatusCode )
}
if response . StatusCode != http . StatusOK || ! envelope . OK {
description := strings . TrimSpace ( envelope . Description )
if description == "" {
description = http . StatusText ( response . StatusCode )
}
return fmt . Errorf ( "Telegram API error %d: %s" , envelope . ErrorCode , description )
}
return nil
}
func telegramPhotoMIME ( mediaType string ) bool {
return mediaType == "image/jpeg" || mediaType == "image/png"
}
func ( bot * telegramBot ) sendMedia ( chatID int64 , media tenantMedia , caption string ) error {
fields := map [ string ] string { "chat_id" : strconv . FormatInt ( chatID , 10 ) }
if caption != "" {
fields [ "caption" ] = caption
}
if telegramPhotoMIME ( media . MIME ) {
ctx , cancel := context . WithTimeout ( context . Background ( ) , 70 * time . Second )
err := bot . callMultipart ( ctx , "sendPhoto" , fields , "photo" , media )
cancel ( )
if err == nil {
return nil
}
}
ctx , cancel := context . WithTimeout ( context . Background ( ) , 70 * time . Second )
defer cancel ( )
return bot . callMultipart ( ctx , "sendDocument" , fields , "document" , media )
}
func ( bot * telegramBot ) sendReply ( chatID int64 , slot int , text string ) error {
reply := parseTelegramReply ( text )
if len ( reply . MediaPaths ) == 0 {
return bot . sendText ( chatID , reply . Text )
}
captionAvailable := reply . Text != "" && utf8 . RuneCountInString ( reply . Text ) <= telegramCaptionLimit
captionSent := false
var firstError error
if reply . Text != "" && ! captionAvailable {
if err := bot . sendText ( chatID , reply . Text ) ; err != nil {
firstError = err
} else {
captionSent = true
}
}
for _ , rawPath := range reply . MediaPaths {
path , err := normalizeTenantMediaPath ( rawPath )
if err == nil {
ctx , cancel := context . WithTimeout ( context . Background ( ) , 70 * time . Second )
media , fetchErr := bot . fetchTenantMedia ( ctx , slot , path )
cancel ( )
err = fetchErr
if err == nil {
caption := ""
if captionAvailable && ! captionSent {
caption = reply . Text
}
err = bot . sendMedia ( chatID , media , caption )
_ = os . Remove ( media . Path )
if err == nil && caption != "" {
captionSent = true
}
}
}
if err != nil {
if firstError == nil {
firstError = err
}
if fallbackErr := bot . sendText ( chatID , "I created an attachment, but Telegram could not deliver it. You can still open it in Hermes WebUI." ) ; fallbackErr != nil && firstError == nil {
firstError = fallbackErr
}
}
}
if reply . Text != "" && ! captionSent {
if err := bot . sendText ( chatID , reply . Text ) ; err != nil && firstError == nil {
firstError = err
}
}
return firstError
}